@skillsgate/tui 0.1.15 → 0.2.4
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/bin/postinstall.cjs +12 -3
- package/bin/skillsgate-tui +31 -4
- package/package.json +1 -1
- package/src/components/agent-filter.tsx +22 -1
- package/src/components/help-overlay.tsx +2 -0
- package/src/components/layout.tsx +2 -0
- package/src/components/skill-list-item.tsx +7 -1
- package/src/components/skill-list.tsx +23 -2
- package/src/data/use-favorites.ts +31 -0
- package/src/db/context.tsx +3 -0
- package/src/db/favorites.ts +39 -0
- package/src/db/local-skills.ts +64 -0
- package/src/db/migrations.ts +11 -0
- package/src/db/push.ts +220 -0
- package/src/db/ssh.ts +83 -0
- package/src/store/reducers.ts +4 -0
- package/src/store/types.ts +5 -1
- package/src/views/home.tsx +7 -4
- package/src/views/push-dialog.tsx +251 -0
- package/src/views/servers.tsx +24 -2
package/bin/postinstall.cjs
CHANGED
|
@@ -16,7 +16,7 @@ const pkg = platformMap[os.platform()]?.[os.arch()];
|
|
|
16
16
|
if (!pkg) process.exit(0);
|
|
17
17
|
|
|
18
18
|
// Check if already available as a sibling (optionalDeps worked)
|
|
19
|
-
const siblingPath = path.join(__dirname, "..", "..",
|
|
19
|
+
const siblingPath = path.join(__dirname, "..", "..", `@skillsgate/${pkg}`);
|
|
20
20
|
if (fs.existsSync(siblingPath)) process.exit(0);
|
|
21
21
|
|
|
22
22
|
// Also check nested node_modules
|
|
@@ -27,11 +27,20 @@ try {
|
|
|
27
27
|
|
|
28
28
|
// Not found — install it explicitly
|
|
29
29
|
const version = require("../package.json").version;
|
|
30
|
+
const fullPkg = `@skillsgate/${pkg}@${version}`;
|
|
30
31
|
try {
|
|
31
|
-
execSync(`npm install -g
|
|
32
|
+
execSync(`npm install -g ${fullPkg} --no-save`, {
|
|
32
33
|
stdio: "inherit",
|
|
33
34
|
timeout: 30000,
|
|
34
35
|
});
|
|
35
36
|
} catch {
|
|
36
|
-
console.warn(`
|
|
37
|
+
console.warn(`Could not install ${fullPkg}, falling back to @latest (versions may differ)`);
|
|
38
|
+
try {
|
|
39
|
+
execSync(`npm install -g @skillsgate/${pkg}@latest --no-save`, {
|
|
40
|
+
stdio: "inherit",
|
|
41
|
+
timeout: 30000,
|
|
42
|
+
});
|
|
43
|
+
} catch {
|
|
44
|
+
console.warn(`Warning: could not install @skillsgate/${pkg}. Run manually: npm install -g @skillsgate/${pkg}`);
|
|
45
|
+
}
|
|
37
46
|
}
|
package/bin/skillsgate-tui
CHANGED
|
@@ -25,14 +25,34 @@ const binName = platform === "win32" ? "skillsgate-tui.exe" : "skillsgate-tui";
|
|
|
25
25
|
const require = createRequire(import.meta.url);
|
|
26
26
|
const thisDir = path.dirname(fileURLToPath(import.meta.url));
|
|
27
27
|
|
|
28
|
-
function
|
|
28
|
+
function getNpmGlobalRoot() {
|
|
29
|
+
try {
|
|
30
|
+
return execFileSync("npm", ["root", "-g"], {
|
|
31
|
+
encoding: "utf8",
|
|
32
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
33
|
+
timeout: 10000,
|
|
34
|
+
}).trim();
|
|
35
|
+
} catch {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function findBinary({ includeGlobal = false } = {}) {
|
|
29
41
|
const candidates = [
|
|
30
42
|
// Nested node_modules (local installs)
|
|
31
43
|
() => require.resolve(`@skillsgate/${pkg}/${binName}`),
|
|
32
44
|
// Sibling in global node_modules (npm install -g)
|
|
33
|
-
() => path.join(thisDir, "..", "..",
|
|
45
|
+
() => path.join(thisDir, "..", "..", `@skillsgate/${pkg}`, binName),
|
|
34
46
|
];
|
|
35
47
|
|
|
48
|
+
if (includeGlobal) {
|
|
49
|
+
candidates.push(() => {
|
|
50
|
+
const globalRoot = getNpmGlobalRoot();
|
|
51
|
+
if (!globalRoot) throw new Error("npm global root not found");
|
|
52
|
+
return path.join(globalRoot, `@skillsgate/${pkg}`, binName);
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
36
56
|
for (const resolve of candidates) {
|
|
37
57
|
try {
|
|
38
58
|
const p = resolve();
|
|
@@ -54,9 +74,16 @@ if (!binPath) {
|
|
|
54
74
|
console.error(`Installing platform binary (${fullPkg})...`);
|
|
55
75
|
try {
|
|
56
76
|
execSync(`npm install -g ${fullPkg}`, { stdio: "inherit", timeout: 30000 });
|
|
57
|
-
binPath = findBinary();
|
|
77
|
+
binPath = findBinary({ includeGlobal: true });
|
|
58
78
|
} catch {
|
|
59
|
-
//
|
|
79
|
+
// Fallback to latest if exact version is not published for this platform
|
|
80
|
+
console.warn(`Could not install ${fullPkg}, falling back to @latest (versions may differ)`);
|
|
81
|
+
try {
|
|
82
|
+
execSync(`npm install -g @skillsgate/${pkg}@latest`, { stdio: "inherit", timeout: 30000 });
|
|
83
|
+
binPath = findBinary({ includeGlobal: true });
|
|
84
|
+
} catch {
|
|
85
|
+
binPath = findBinary({ includeGlobal: true });
|
|
86
|
+
}
|
|
60
87
|
}
|
|
61
88
|
}
|
|
62
89
|
|
package/package.json
CHANGED
|
@@ -15,6 +15,12 @@ export function AgentFilter() {
|
|
|
15
15
|
const { servers } = useDb()
|
|
16
16
|
|
|
17
17
|
const allCount = state.installedSkills.length
|
|
18
|
+
// Count favorites that are currently installed.
|
|
19
|
+
const favSet = new Set(state.favorites)
|
|
20
|
+
const favoritesCount = state.installedSkills.reduce(
|
|
21
|
+
(n, s) => (favSet.has(s.name) ? n + 1 : n),
|
|
22
|
+
0,
|
|
23
|
+
)
|
|
18
24
|
|
|
19
25
|
// Remote servers with skill counts
|
|
20
26
|
const serverList = servers.list()
|
|
@@ -38,7 +44,7 @@ export function AgentFilter() {
|
|
|
38
44
|
if (state.focusedPane !== "agents") return
|
|
39
45
|
if (state.showHelp) return
|
|
40
46
|
|
|
41
|
-
const allOptions = ["all", ...agentOptions.map((o) => o.value)]
|
|
47
|
+
const allOptions = ["all", "favorites", ...agentOptions.map((o) => o.value)]
|
|
42
48
|
const currentIdx = allOptions.indexOf(state.selectedAgentFilter)
|
|
43
49
|
|
|
44
50
|
if (key.name === "up" || (key.name === "k" && !key.ctrl)) {
|
|
@@ -84,6 +90,21 @@ export function AgentFilter() {
|
|
|
84
90
|
<text fg={colors.textDim}> ({allCount})</text>
|
|
85
91
|
</box>
|
|
86
92
|
|
|
93
|
+
{/* Favorites */}
|
|
94
|
+
<box
|
|
95
|
+
style={{
|
|
96
|
+
paddingLeft: 1,
|
|
97
|
+
paddingRight: 1,
|
|
98
|
+
height: 1,
|
|
99
|
+
backgroundColor: state.selectedAgentFilter === "favorites" ? colors.bgAlt : "transparent",
|
|
100
|
+
}}
|
|
101
|
+
>
|
|
102
|
+
<text fg={state.selectedAgentFilter === "favorites" ? colors.primary : colors.text}>
|
|
103
|
+
{"★ "}Favorites
|
|
104
|
+
</text>
|
|
105
|
+
<text fg={colors.textDim}> ({favoritesCount})</text>
|
|
106
|
+
</box>
|
|
107
|
+
|
|
87
108
|
{/* Spacer */}
|
|
88
109
|
<box style={{ height: 1 }}>
|
|
89
110
|
<text>{" "}</text>
|
|
@@ -10,6 +10,7 @@ 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: "f", description: "Toggle favorite on selected skill" },
|
|
13
14
|
{ key: "n", description: "Create local skill (home)" },
|
|
14
15
|
{ key: "c", description: "Manage collections (home)" },
|
|
15
16
|
{ key: "/", description: "Focus search input" },
|
|
@@ -29,6 +30,7 @@ const SHORTCUTS_RIGHT: ShortcutEntry[] = [
|
|
|
29
30
|
{ key: "", description: "" },
|
|
30
31
|
{ key: "-- Servers View --", description: "" },
|
|
31
32
|
{ key: "S", description: "Sync selected server" },
|
|
33
|
+
{ key: "P", description: "push local skills to selected server" },
|
|
32
34
|
{ key: "a", description: "Add new server" },
|
|
33
35
|
{ key: "e", description: "Edit server" },
|
|
34
36
|
{ key: "t", description: "Test connection" },
|
|
@@ -4,6 +4,7 @@ import { useStore, useDispatch } from "../store/context.js"
|
|
|
4
4
|
import { useDb } from "../db/context.js"
|
|
5
5
|
import { useDetectedAgents } from "../data/use-agents.js"
|
|
6
6
|
import { useInstalledSkills } from "../data/use-installed-skills.js"
|
|
7
|
+
import { useFavorites } from "../data/use-favorites.js"
|
|
7
8
|
import { StatusBar } from "./status-bar.js"
|
|
8
9
|
import { HelpOverlay } from "./help-overlay.js"
|
|
9
10
|
import { HomeView } from "../views/home.js"
|
|
@@ -38,6 +39,7 @@ export function Layout() {
|
|
|
38
39
|
// Load agent + skill data on mount
|
|
39
40
|
useDetectedAgents()
|
|
40
41
|
useInstalledSkills()
|
|
42
|
+
useFavorites()
|
|
41
43
|
|
|
42
44
|
// Global keyboard shortcuts
|
|
43
45
|
useKeyboard((key) => {
|
|
@@ -4,13 +4,14 @@ import { colors, agentBadges as badgeMap } from "../utils/colors.js"
|
|
|
4
4
|
interface SkillListItemProps {
|
|
5
5
|
skill: EnrichedSkill
|
|
6
6
|
selected?: boolean
|
|
7
|
+
favorited?: boolean
|
|
7
8
|
}
|
|
8
9
|
|
|
9
10
|
/**
|
|
10
11
|
* Compact skill list item for the middle panel.
|
|
11
12
|
* Shows just the name and small agent dot indicators.
|
|
12
13
|
*/
|
|
13
|
-
export function SkillListItem({ skill, selected }: SkillListItemProps) {
|
|
14
|
+
export function SkillListItem({ skill, selected, favorited }: SkillListItemProps) {
|
|
14
15
|
// Build compact agent dots (single-char badges)
|
|
15
16
|
const agentDots = skill.agents.slice(0, 3).map((a) => {
|
|
16
17
|
const badge = badgeMap[a]
|
|
@@ -27,6 +28,11 @@ export function SkillListItem({ skill, selected }: SkillListItemProps) {
|
|
|
27
28
|
backgroundColor: selected ? colors.bgAlt : "transparent",
|
|
28
29
|
}}
|
|
29
30
|
>
|
|
31
|
+
{/* Star indicator (favorited skills only) */}
|
|
32
|
+
<text fg={favorited ? colors.warning : colors.textDim}>
|
|
33
|
+
{favorited ? "★ " : " "}
|
|
34
|
+
</text>
|
|
35
|
+
|
|
30
36
|
{/* Skill name */}
|
|
31
37
|
<text fg={selected ? colors.primary : colors.text} style={{ flexGrow: 1 }}>
|
|
32
38
|
{skill.name}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import { useState, useEffect } from "react"
|
|
1
|
+
import { useState, useEffect, useMemo } from "react"
|
|
2
2
|
import { useKeyboard } from "@opentui/react"
|
|
3
3
|
import { useStore, useDispatch } from "../store/context.js"
|
|
4
4
|
import { useSkillActions } from "../data/use-skill-actions.js"
|
|
5
|
+
import { useFavorites } from "../data/use-favorites.js"
|
|
5
6
|
import { SkillListItem } from "./skill-list-item.js"
|
|
6
7
|
import { ConfirmDialog } from "./confirm-dialog.js"
|
|
7
8
|
import { colors, agentBadges as badgeMap } from "../utils/colors.js"
|
|
@@ -30,6 +31,8 @@ export function SkillList({ skills }: SkillListProps) {
|
|
|
30
31
|
const dispatch = useDispatch()
|
|
31
32
|
const state = useStore()
|
|
32
33
|
const { removeSkill, removeSkillFromOneAgent, updateSkill } = useSkillActions()
|
|
34
|
+
const { favorites, toggleFavorite } = useFavorites()
|
|
35
|
+
const favoritesSet = useMemo(() => new Set(favorites), [favorites])
|
|
33
36
|
const [selectedIndex, setSelectedIndex] = useState(0)
|
|
34
37
|
const [pendingAction, setPendingAction] = useState<PendingAction>(null)
|
|
35
38
|
const [removeMode, setRemoveMode] = useState<RemoveMode>(null)
|
|
@@ -114,6 +117,21 @@ export function SkillList({ skills }: SkillListProps) {
|
|
|
114
117
|
if (key.name === "u" && skills[selectedIndex]) {
|
|
115
118
|
setPendingAction({ type: "update", skill: skills[selectedIndex] })
|
|
116
119
|
}
|
|
120
|
+
|
|
121
|
+
// f to toggle favorite for selected skill
|
|
122
|
+
if (key.name === "f" && skills[selectedIndex]) {
|
|
123
|
+
const skill = skills[selectedIndex]
|
|
124
|
+
const isFavoritedAfter = toggleFavorite(skill.name)
|
|
125
|
+
dispatch({
|
|
126
|
+
type: "SHOW_NOTIFICATION",
|
|
127
|
+
notification: {
|
|
128
|
+
type: "info",
|
|
129
|
+
message: isFavoritedAfter
|
|
130
|
+
? `Added "${skill.name}" to favorites`
|
|
131
|
+
: `Removed "${skill.name}" from favorites`,
|
|
132
|
+
},
|
|
133
|
+
})
|
|
134
|
+
}
|
|
117
135
|
})
|
|
118
136
|
|
|
119
137
|
// Handle confirm/cancel for pending actions
|
|
@@ -201,7 +219,9 @@ export function SkillList({ skills }: SkillListProps) {
|
|
|
201
219
|
<text fg={colors.textDim}>
|
|
202
220
|
{state.installedLoading
|
|
203
221
|
? "Scanning for installed skills..."
|
|
204
|
-
:
|
|
222
|
+
: state.selectedAgentFilter === "favorites"
|
|
223
|
+
? "No favorites yet. Press f on any skill to save it here."
|
|
224
|
+
: "No skills found. Install skills with: skillsgate install <source>"}
|
|
205
225
|
</text>
|
|
206
226
|
</box>
|
|
207
227
|
)
|
|
@@ -237,6 +257,7 @@ export function SkillList({ skills }: SkillListProps) {
|
|
|
237
257
|
key={skill.name}
|
|
238
258
|
skill={skill}
|
|
239
259
|
selected={i === selectedIndex}
|
|
260
|
+
favorited={favoritesSet.has(skill.name)}
|
|
240
261
|
/>
|
|
241
262
|
))}
|
|
242
263
|
</scrollbox>
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { useCallback, useEffect } from "react"
|
|
2
|
+
import { useDispatch, useStore } from "../store/context.js"
|
|
3
|
+
import { useDb } from "../db/context.js"
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Hydrates the favorites list from the local DB on mount and exposes a
|
|
7
|
+
* `toggleFavorite` callback that updates both DB and store state.
|
|
8
|
+
*/
|
|
9
|
+
export function useFavorites() {
|
|
10
|
+
const dispatch = useDispatch()
|
|
11
|
+
const state = useStore()
|
|
12
|
+
const { favorites } = useDb()
|
|
13
|
+
|
|
14
|
+
useEffect(() => {
|
|
15
|
+
dispatch({ type: "SET_FAVORITES", favorites: favorites.list() })
|
|
16
|
+
}, [])
|
|
17
|
+
|
|
18
|
+
const toggleFavorite = useCallback(
|
|
19
|
+
(name: string): boolean => {
|
|
20
|
+
const next = favorites.toggle(name)
|
|
21
|
+
dispatch({ type: "SET_FAVORITES", favorites: favorites.list() })
|
|
22
|
+
return next
|
|
23
|
+
},
|
|
24
|
+
[favorites, dispatch],
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
return {
|
|
28
|
+
favorites: state.favorites,
|
|
29
|
+
toggleFavorite,
|
|
30
|
+
}
|
|
31
|
+
}
|
package/src/db/context.tsx
CHANGED
|
@@ -3,12 +3,14 @@ import type { Database } from "bun:sqlite"
|
|
|
3
3
|
import { SettingsStore } from "./settings.js"
|
|
4
4
|
import { RemoteServerStore } from "./servers.js"
|
|
5
5
|
import { RemoteSkillStore } from "./skills.js"
|
|
6
|
+
import { FavoritesStore } from "./favorites.js"
|
|
6
7
|
|
|
7
8
|
export interface DbContext {
|
|
8
9
|
db: Database
|
|
9
10
|
settings: SettingsStore
|
|
10
11
|
servers: RemoteServerStore
|
|
11
12
|
skills: RemoteSkillStore
|
|
13
|
+
favorites: FavoritesStore
|
|
12
14
|
}
|
|
13
15
|
|
|
14
16
|
const DbCtx = createContext<DbContext | null>(null)
|
|
@@ -24,6 +26,7 @@ export function DbProvider({ db, children }: DbProviderProps) {
|
|
|
24
26
|
settings: new SettingsStore(db),
|
|
25
27
|
servers: new RemoteServerStore(db),
|
|
26
28
|
skills: new RemoteSkillStore(db),
|
|
29
|
+
favorites: new FavoritesStore(db),
|
|
27
30
|
}
|
|
28
31
|
|
|
29
32
|
return <DbCtx.Provider value={ctx}>{children}</DbCtx.Provider>
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { Database } from "bun:sqlite"
|
|
2
|
+
|
|
3
|
+
export class FavoritesStore {
|
|
4
|
+
private db: Database
|
|
5
|
+
|
|
6
|
+
constructor(db: Database) {
|
|
7
|
+
this.db = db
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
list(): string[] {
|
|
11
|
+
const rows = this.db
|
|
12
|
+
.query("SELECT skill_name FROM favorites ORDER BY created_at DESC")
|
|
13
|
+
.all() as Array<{ skill_name: string }>
|
|
14
|
+
return rows.map((r) => r.skill_name)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
has(name: string): boolean {
|
|
18
|
+
const row = this.db
|
|
19
|
+
.query("SELECT 1 FROM favorites WHERE skill_name = ?")
|
|
20
|
+
.get(name)
|
|
21
|
+
return row !== null
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
toggle(name: string): boolean {
|
|
25
|
+
if (this.has(name)) {
|
|
26
|
+
this.db.query("DELETE FROM favorites WHERE skill_name = ?").run(name)
|
|
27
|
+
return false
|
|
28
|
+
}
|
|
29
|
+
this.db.query("INSERT INTO favorites (skill_name) VALUES (?)").run(name)
|
|
30
|
+
return true
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
count(): number {
|
|
34
|
+
const row = this.db
|
|
35
|
+
.query("SELECT COUNT(*) as c FROM favorites")
|
|
36
|
+
.get() as { c: number }
|
|
37
|
+
return row.c
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -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/migrations.ts
CHANGED
|
@@ -70,6 +70,17 @@ const MIGRATIONS: Migration[] = [
|
|
|
70
70
|
INSERT OR IGNORE INTO schema_version VALUES (2);
|
|
71
71
|
`,
|
|
72
72
|
},
|
|
73
|
+
{
|
|
74
|
+
version: 3,
|
|
75
|
+
up: `
|
|
76
|
+
CREATE TABLE IF NOT EXISTS favorites (
|
|
77
|
+
skill_name TEXT PRIMARY KEY,
|
|
78
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
INSERT OR IGNORE INTO schema_version VALUES (3);
|
|
82
|
+
`,
|
|
83
|
+
},
|
|
73
84
|
]
|
|
74
85
|
|
|
75
86
|
function getCurrentVersion(db: Database): number {
|
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 {
|
package/src/store/reducers.ts
CHANGED
|
@@ -10,6 +10,7 @@ export const initialState: AppState = {
|
|
|
10
10
|
installedSkills: [],
|
|
11
11
|
installedLoading: true,
|
|
12
12
|
installedFilter: "",
|
|
13
|
+
favorites: [],
|
|
13
14
|
searchQuery: "",
|
|
14
15
|
searchResults: [],
|
|
15
16
|
searchLoading: false,
|
|
@@ -61,6 +62,9 @@ export function appReducer(state: AppState, action: Action): AppState {
|
|
|
61
62
|
case "SET_INSTALLED_FILTER":
|
|
62
63
|
return { ...state, installedFilter: action.filter }
|
|
63
64
|
|
|
65
|
+
case "SET_FAVORITES":
|
|
66
|
+
return { ...state, favorites: action.favorites }
|
|
67
|
+
|
|
64
68
|
case "SET_SEARCH_QUERY":
|
|
65
69
|
return { ...state, searchQuery: action.query }
|
|
66
70
|
|
package/src/store/types.ts
CHANGED
|
@@ -63,11 +63,14 @@ export interface AppState {
|
|
|
63
63
|
detectedAgents: DetectedAgent[]
|
|
64
64
|
|
|
65
65
|
// Installed skills (home view)
|
|
66
|
-
selectedAgentFilter: string // agent name or "
|
|
66
|
+
selectedAgentFilter: string // agent name, "all", or "favorites"
|
|
67
67
|
installedSkills: EnrichedSkill[]
|
|
68
68
|
installedLoading: boolean
|
|
69
69
|
installedFilter: string
|
|
70
70
|
|
|
71
|
+
// Favorites (skill names)
|
|
72
|
+
favorites: string[]
|
|
73
|
+
|
|
71
74
|
// Search / discover
|
|
72
75
|
searchQuery: string
|
|
73
76
|
searchResults: unknown[]
|
|
@@ -98,6 +101,7 @@ export type Action =
|
|
|
98
101
|
| { type: "SET_INSTALLED_SKILLS"; skills: EnrichedSkill[] }
|
|
99
102
|
| { type: "SET_INSTALLED_LOADING"; loading: boolean }
|
|
100
103
|
| { type: "SET_INSTALLED_FILTER"; filter: string }
|
|
104
|
+
| { type: "SET_FAVORITES"; favorites: string[] }
|
|
101
105
|
| { type: "SET_SEARCH_QUERY"; query: string }
|
|
102
106
|
| { type: "SET_SEARCH_RESULTS"; results: unknown[] }
|
|
103
107
|
| { type: "SET_SEARCH_LOADING"; loading: boolean }
|
package/src/views/home.tsx
CHANGED
|
@@ -73,8 +73,11 @@ export function HomeView() {
|
|
|
73
73
|
skills = skills.filter((skill) => ids.has(skill.canonicalPath))
|
|
74
74
|
}
|
|
75
75
|
|
|
76
|
-
// Agent filter
|
|
77
|
-
if (state.selectedAgentFilter
|
|
76
|
+
// Agent filter (or favorites)
|
|
77
|
+
if (state.selectedAgentFilter === "favorites") {
|
|
78
|
+
const favSet = new Set(state.favorites)
|
|
79
|
+
skills = skills.filter((s) => favSet.has(s.name))
|
|
80
|
+
} else if (state.selectedAgentFilter !== "all") {
|
|
78
81
|
skills = skills.filter((s) =>
|
|
79
82
|
s.agents.includes(state.selectedAgentFilter as any)
|
|
80
83
|
)
|
|
@@ -96,7 +99,7 @@ export function HomeView() {
|
|
|
96
99
|
}
|
|
97
100
|
|
|
98
101
|
return skills
|
|
99
|
-
}, [state.installedSkills, state.selectedAgentFilter, state.installedFilter, selectedCollection, collections, collectionsVersion])
|
|
102
|
+
}, [state.installedSkills, state.selectedAgentFilter, state.installedFilter, state.favorites, selectedCollection, collections, collectionsVersion])
|
|
100
103
|
|
|
101
104
|
useKeyboard((key) => {
|
|
102
105
|
if (state.activeView !== "home") return
|
|
@@ -383,7 +386,7 @@ function DetailPanel({ skill, collections, selectedCollection }: DetailPanelProp
|
|
|
383
386
|
<text>{" "}</text>
|
|
384
387
|
|
|
385
388
|
{/* Shortcut hints */}
|
|
386
|
-
<text fg={colors.textDim}>v=view detail d=remove u=update n=create skill c=collections Tab=switch pane</text>
|
|
389
|
+
<text fg={colors.textDim}>v=view detail d=remove u=update f=favorite n=create skill c=collections Tab=switch pane</text>
|
|
387
390
|
<text fg={colors.border}>---</text>
|
|
388
391
|
|
|
389
392
|
{/* SKILL.md content */}
|
|
@@ -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
|
+
}
|
package/src/views/servers.tsx
CHANGED
|
@@ -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
|
}
|