@skillsgate/tui 0.1.15 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skillsgate/tui",
3
- "version": "0.1.15",
3
+ "version": "0.2.0",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "skillsgate-tui": "bin/skillsgate-tui"
@@ -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" },
@@ -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
@@ -299,6 +299,89 @@ function parseDelimitedOutput(output: string): ScannedRemoteSkill[] {
299
299
  return skills
300
300
  }
301
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
+
302
385
  // ---------- Sync Orchestrator ----------
303
386
 
304
387
  export interface SyncResult {
@@ -0,0 +1,251 @@
1
+ // packages/tui/src/views/push-dialog.tsx
2
+ import { useState } from "react"
3
+ import { useKeyboard } from "@opentui/react"
4
+ import { colors } from "../utils/colors.js"
5
+ import type { RemoteServer } from "../db/servers.js"
6
+ import { planPush, applyPush } from "../db/push.js"
7
+ import type { PushPreview, PushResult } from "../db/push.js"
8
+
9
+ interface PushDialogProps {
10
+ server: RemoteServer
11
+ onClose: (result?: PushResult) => void
12
+ }
13
+
14
+ type Stage = "choose" | "previewing" | "review" | "applying" | "done" | "error"
15
+
16
+ /**
17
+ * TUI push dialog. State machine: choose -> previewing -> review -> applying -> done | error.
18
+ * Keybindings:
19
+ * m toggle Mirror mode (in choose)
20
+ * Enter advance (choose->preview, review->apply if changes exist)
21
+ * Esc cancel / go back
22
+ */
23
+ export function PushDialog({ server, onClose }: PushDialogProps) {
24
+ const [mirror, setMirror] = useState(false)
25
+ const [stage, setStage] = useState<Stage>("choose")
26
+ const [preview, setPreview] = useState<PushPreview | null>(null)
27
+ const [result, setResult] = useState<PushResult | null>(null)
28
+ const [error, setError] = useState<string | null>(null)
29
+
30
+ const totalChanges =
31
+ (preview?.toAdd.length ?? 0) +
32
+ (preview?.toUpdate.length ?? 0) +
33
+ (preview?.toDelete.length ?? 0)
34
+
35
+ useKeyboard((key) => {
36
+ if (stage === "previewing" || stage === "applying") return // no input while running
37
+
38
+ if (key.name === "escape") {
39
+ if (stage === "review") {
40
+ // Go back to choose
41
+ setStage("choose")
42
+ setPreview(null)
43
+ return
44
+ }
45
+ onClose()
46
+ return
47
+ }
48
+
49
+ if (stage === "choose") {
50
+ if (key.name === "m") {
51
+ setMirror((v) => !v)
52
+ return
53
+ }
54
+ if (key.name === "return") {
55
+ runPreview()
56
+ return
57
+ }
58
+ }
59
+
60
+ if (stage === "review") {
61
+ if (key.name === "return" && totalChanges > 0) {
62
+ runApply()
63
+ return
64
+ }
65
+ }
66
+
67
+ if (stage === "done" || stage === "error") {
68
+ if (key.name === "return" || key.name === "escape") {
69
+ onClose(result ?? undefined)
70
+ return
71
+ }
72
+ }
73
+ })
74
+
75
+ async function runPreview() {
76
+ setStage("previewing")
77
+ setError(null)
78
+ try {
79
+ const p = await planPush(server, { mirror })
80
+ setPreview(p)
81
+ setStage("review")
82
+ } catch (err) {
83
+ setError(err instanceof Error ? err.message : String(err))
84
+ setStage("error")
85
+ }
86
+ }
87
+
88
+ async function runApply() {
89
+ if (!preview) return
90
+ setStage("applying")
91
+ setError(null)
92
+ try {
93
+ const r = await applyPush(server, preview)
94
+ setResult(r)
95
+ setStage("done")
96
+ } catch (err) {
97
+ setError(err instanceof Error ? err.message : String(err))
98
+ setStage("error")
99
+ }
100
+ }
101
+
102
+ return (
103
+ <box
104
+ style={{
105
+ width: "100%",
106
+ height: "100%",
107
+ justifyContent: "center",
108
+ alignItems: "center",
109
+ backgroundColor: colors.bg,
110
+ }}
111
+ >
112
+ <box
113
+ style={{
114
+ width: 72,
115
+ border: true,
116
+ borderColor: colors.primary,
117
+ backgroundColor: "#1a1a2e",
118
+ paddingLeft: 2,
119
+ paddingRight: 2,
120
+ paddingTop: 1,
121
+ paddingBottom: 1,
122
+ flexDirection: "column",
123
+ }}
124
+ title={`Push to ${server.label}`}
125
+ >
126
+ {stage === "choose" && (
127
+ <>
128
+ <text fg={colors.primary}>
129
+ <strong>Push to {server.label}</strong>
130
+ </text>
131
+ <text>{" "}</text>
132
+ <text fg={mirror ? colors.textDim : colors.success}>
133
+ {mirror ? " " : "> "}[Push] additive — adds/updates only, never deletes
134
+ </text>
135
+ <text fg={mirror ? colors.warning : colors.textDim}>
136
+ {mirror ? "> " : " "}[Mirror] one-to-one — also deletes remote-only skills
137
+ </text>
138
+ <text>{" "}</text>
139
+ <text fg={colors.textDim}>
140
+ m=toggle mode Enter=preview Esc=cancel
141
+ </text>
142
+ </>
143
+ )}
144
+
145
+ {stage === "previewing" && (
146
+ <>
147
+ <text fg={colors.textDim}>Computing diff...</text>
148
+ </>
149
+ )}
150
+
151
+ {stage === "review" && preview && (
152
+ <>
153
+ <text fg={colors.primary}>
154
+ <strong>Preview</strong>
155
+ {preview.mirror ? (
156
+ <span fg={colors.warning}> (Mirror mode)</span>
157
+ ) : null}
158
+ </text>
159
+ <text>{" "}</text>
160
+ <text fg={colors.text}>
161
+ <span fg={colors.success}>{preview.toAdd.length} to add</span>
162
+ {" "}
163
+ <span fg={colors.warning}>{preview.toUpdate.length} to update</span>
164
+ {preview.mirror ? (
165
+ <>
166
+ {" "}
167
+ <span fg={colors.error}>{preview.toDelete.length} to delete</span>
168
+ </>
169
+ ) : null}
170
+ {" "}
171
+ <span fg={colors.textDim}>{preview.unchanged.length} unchanged</span>
172
+ </text>
173
+ <text>{" "}</text>
174
+ {totalChanges === 0 ? (
175
+ <text fg={colors.textDim}>Nothing to push — remote already matches local.</text>
176
+ ) : (
177
+ <>
178
+ {preview.toAdd.map((e, i) => (
179
+ <text key={`add-${i}`} fg={colors.success}>+ {e.folderName}</text>
180
+ ))}
181
+ {preview.toUpdate.map((e, i) => (
182
+ <text key={`upd-${i}`} fg={colors.warning}>~ {e.folderName}</text>
183
+ ))}
184
+ {preview.toDelete.map((e, i) => (
185
+ <text key={`del-${i}`} fg={colors.error}>- {e.folderName}</text>
186
+ ))}
187
+ </>
188
+ )}
189
+ {preview.mirror && preview.toDelete.length > 0 && (
190
+ <>
191
+ <text>{" "}</text>
192
+ <text fg={colors.error}>
193
+ Mirror will delete {preview.toDelete.length} skill{preview.toDelete.length === 1 ? "" : "s"} from remote.
194
+ </text>
195
+ </>
196
+ )}
197
+ <text>{" "}</text>
198
+ <text fg={colors.textDim}>
199
+ {totalChanges > 0
200
+ ? preview.mirror && preview.toDelete.length > 0
201
+ ? "Enter=confirm mirror (destructive) Esc=back"
202
+ : "Enter=apply Esc=back"
203
+ : "Esc=close"}
204
+ </text>
205
+ </>
206
+ )}
207
+
208
+ {stage === "applying" && (
209
+ <text fg={colors.textDim}>Pushing to remote...</text>
210
+ )}
211
+
212
+ {stage === "done" && result && (
213
+ <>
214
+ <text fg={colors.success}><strong>Push complete</strong></text>
215
+ <text>{" "}</text>
216
+ <text fg={colors.text}>
217
+ <span fg={colors.success}>{result.added} added</span>
218
+ {" "}
219
+ <span fg={colors.warning}>{result.updated} updated</span>
220
+ {" "}
221
+ <span fg={result.deleted > 0 ? colors.error : colors.textDim}>{result.deleted} deleted</span>
222
+ {" "}
223
+ <span fg={colors.textDim}>{result.unchanged} unchanged</span>
224
+ </text>
225
+ {result.errors.length > 0 && (
226
+ <>
227
+ <text>{" "}</text>
228
+ <text fg={colors.error}>Errors ({result.errors.length}):</text>
229
+ {result.errors.map((e, i) => (
230
+ <text key={i} fg={colors.error}> {e.folderName}: {e.message}</text>
231
+ ))}
232
+ </>
233
+ )}
234
+ <text>{" "}</text>
235
+ <text fg={colors.textDim}>Enter or Esc to close</text>
236
+ </>
237
+ )}
238
+
239
+ {stage === "error" && (
240
+ <>
241
+ <text fg={colors.error}><strong>Push failed</strong></text>
242
+ <text>{" "}</text>
243
+ <text fg={colors.error}>{error || "Unknown error"}</text>
244
+ <text>{" "}</text>
245
+ <text fg={colors.textDim}>Enter or Esc to close</text>
246
+ </>
247
+ )}
248
+ </box>
249
+ </box>
250
+ )
251
+ }
@@ -6,6 +6,7 @@ import { ConfirmDialog } from "../components/confirm-dialog.js"
6
6
  import { testConnection, syncRemoteServer } from "../db/ssh.js"
7
7
  import { colors } from "../utils/colors.js"
8
8
  import type { RemoteServer } from "../db/servers.js"
9
+ import { PushDialog } from "./push-dialog.js"
9
10
 
10
11
  interface ServersViewProps {
11
12
  onServerCountChange: (count: number) => void
@@ -40,6 +41,7 @@ export function ServersView({ onServerCountChange }: ServersViewProps) {
40
41
  const [serverList, setServerList] = useState<RemoteServer[]>(() => servers.list())
41
42
  const [selectedIndex, setSelectedIndex] = useState(0)
42
43
  const [pendingAction, setPendingAction] = useState<PendingAction>(null)
44
+ const [pushTarget, setPushTarget] = useState<RemoteServer | null>(null)
43
45
  const [syncing, setSyncing] = useState(false)
44
46
  const [syncLog, setSyncLog] = useState<string[] | null>(null)
45
47
  const [testing, setTesting] = useState(false)
@@ -67,6 +69,7 @@ export function ServersView({ onServerCountChange }: ServersViewProps) {
67
69
  if (state.activeView !== "servers") return
68
70
  if (state.showHelp) return
69
71
  if (pendingAction) return
72
+ if (pushTarget) return
70
73
  if (syncing || testing) return
71
74
 
72
75
  // j/k or arrow keys
@@ -116,6 +119,12 @@ export function ServersView({ onServerCountChange }: ServersViewProps) {
116
119
  return
117
120
  }
118
121
 
122
+ // P = push to selected server
123
+ if (key.name === "P" && selectedServer) {
124
+ setPushTarget(selectedServer)
125
+ return
126
+ }
127
+
119
128
  // Enter = browse server's skills
120
129
  if (key.name === "return" && selectedServer) {
121
130
  const count = skillCounts.get(selectedServer.id) ?? 0
@@ -193,6 +202,19 @@ export function ServersView({ onServerCountChange }: ServersViewProps) {
193
202
  }
194
203
  }, [servers, skills, dispatch, refreshList])
195
204
 
205
+ // Push dialog
206
+ if (pushTarget) {
207
+ return (
208
+ <PushDialog
209
+ server={pushTarget}
210
+ onClose={() => {
211
+ setPushTarget(null)
212
+ refreshList()
213
+ }}
214
+ />
215
+ )
216
+ }
217
+
196
218
  // Confirm dialog for delete
197
219
  if (pendingAction?.type === "delete") {
198
220
  return (
@@ -327,7 +349,7 @@ export function ServersView({ onServerCountChange }: ServersViewProps) {
327
349
  {/* Bottom shortcut hints */}
328
350
  <box style={{ height: 1, paddingLeft: 1, backgroundColor: colors.bgAlt }}>
329
351
  <text fg={colors.textDim}>
330
- S=sync Enter=browse a=add e=edit d=delete t=test
352
+ S=sync P=push Enter=browse a=add e=edit d=delete t=test
331
353
  </text>
332
354
  </box>
333
355
  </box>
@@ -400,7 +422,7 @@ function ServerDetailPanel({ server, skillCount }: ServerDetailPanelProps) {
400
422
  ) : null}
401
423
 
402
424
  <text fg={colors.border}>---</text>
403
- <text fg={colors.textDim}>S=sync t=test e=edit d=delete Enter=browse skills</text>
425
+ <text fg={colors.textDim}>S=sync P=push t=test e=edit d=delete Enter=browse skills</text>
404
426
  </box>
405
427
  )
406
428
  }