skillview 0.1.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/LICENSE +21 -0
- package/README.md +62 -0
- package/dist/app.js +37 -0
- package/dist/cli.js +45 -0
- package/dist/components/AgentList.js +33 -0
- package/dist/components/ConfirmDelete.js +13 -0
- package/dist/components/SkillList.js +105 -0
- package/dist/components/SkillView.js +50 -0
- package/dist/lib/scanner.js +109 -0
- package/dist/lib/types.js +1 -0
- package/dist/lib/wrap.js +42 -0
- package/dist/theme.js +4 -0
- package/package.json +46 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 myskills contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# skillview
|
|
2
|
+
|
|
3
|
+
An interactive terminal manager for the AI agent skills on your machine.
|
|
4
|
+
|
|
5
|
+
`skillview` scans the skill folders of your local AI agents — **Claude** (`~/.claude`),
|
|
6
|
+
**Cursor** (`~/.cursor`), and **Agents** (`~/.agents`) — and lets you browse them by agent,
|
|
7
|
+
sorted by how recently they changed. View a skill's full `SKILL.md`, search across titles and
|
|
8
|
+
descriptions, and delete skills you no longer want, all without leaving the terminal.
|
|
9
|
+
|
|
10
|
+
## Install
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
npm install -g skillview
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Then run:
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
skillview # launch the interactive browser
|
|
20
|
+
skillview --help # show usage and keybindings
|
|
21
|
+
skillview --version # print the version
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Requires Node.js >= 18.
|
|
25
|
+
|
|
26
|
+
## How it works
|
|
27
|
+
|
|
28
|
+
Skills are expected at:
|
|
29
|
+
|
|
30
|
+
```
|
|
31
|
+
~/.claude/skills/<skill>/SKILL.md
|
|
32
|
+
~/.cursor/skills/<skill>/SKILL.md
|
|
33
|
+
~/.agents/skills/<skill>/SKILL.md
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Each `SKILL.md` starts with YAML frontmatter holding `name` and `description`. `skillview` reads
|
|
37
|
+
that to show a colored title and a short description for every skill.
|
|
38
|
+
|
|
39
|
+
## Navigation
|
|
40
|
+
|
|
41
|
+
The UI has three levels:
|
|
42
|
+
|
|
43
|
+
1. **Agents** — pick `.claude`, `.cursor`, or `.agents` (shown with skill counts).
|
|
44
|
+
2. **Skills** — skills in that agent, newest first. Each shows an orange title and a two-line
|
|
45
|
+
description.
|
|
46
|
+
3. **Skill detail** — the full, scrollable `SKILL.md`.
|
|
47
|
+
|
|
48
|
+
### Keys
|
|
49
|
+
|
|
50
|
+
| Key | Action |
|
|
51
|
+
| --------------- | --------------------------------------- |
|
|
52
|
+
| `↑` / `↓` | Move selection / scroll |
|
|
53
|
+
| `Enter` | Open the selected agent or skill |
|
|
54
|
+
| `Esc` / `←` | Go back a level |
|
|
55
|
+
| `/` | Search skills (in the skills view) |
|
|
56
|
+
| `d` | Delete the selected skill (asks first) |
|
|
57
|
+
| `PgUp` / `PgDn` | Page through a long skill in detail view |
|
|
58
|
+
| `q` / `Ctrl+C` | Quit |
|
|
59
|
+
|
|
60
|
+
## License
|
|
61
|
+
|
|
62
|
+
MIT
|
package/dist/app.js
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useState, useCallback } from "react";
|
|
3
|
+
import { Box, Text, useApp } from "ink";
|
|
4
|
+
import { listAgents, listSkills } from "./lib/scanner.js";
|
|
5
|
+
import { AgentList } from "./components/AgentList.js";
|
|
6
|
+
import { SkillList } from "./components/SkillList.js";
|
|
7
|
+
import { SkillView } from "./components/SkillView.js";
|
|
8
|
+
import { ORANGE } from "./theme.js";
|
|
9
|
+
export function App() {
|
|
10
|
+
const { exit } = useApp();
|
|
11
|
+
const [view, setView] = useState("agents");
|
|
12
|
+
const [agents, setAgents] = useState(() => listAgents());
|
|
13
|
+
const [agent, setAgent] = useState(null);
|
|
14
|
+
const [skills, setSkills] = useState([]);
|
|
15
|
+
const [skill, setSkill] = useState(null);
|
|
16
|
+
// Quit ('q') is handled per-view so it never fires while typing in the search
|
|
17
|
+
// box. Ctrl+C always exits via Ink's default handler.
|
|
18
|
+
const openAgent = useCallback((a) => {
|
|
19
|
+
setAgent(a);
|
|
20
|
+
setSkills(listSkills(a));
|
|
21
|
+
setView("skills");
|
|
22
|
+
}, []);
|
|
23
|
+
const refreshSkills = useCallback(() => {
|
|
24
|
+
if (agent) {
|
|
25
|
+
// Re-read agent metadata (counts) and skills after a delete.
|
|
26
|
+
const fresh = listAgents().find((a) => a.id === agent.id) ?? agent;
|
|
27
|
+
setAgent(fresh);
|
|
28
|
+
setSkills(listSkills(fresh));
|
|
29
|
+
setAgents(listAgents());
|
|
30
|
+
}
|
|
31
|
+
}, [agent]);
|
|
32
|
+
const openSkill = useCallback((s) => {
|
|
33
|
+
setSkill(s);
|
|
34
|
+
setView("detail");
|
|
35
|
+
}, []);
|
|
36
|
+
return (_jsxs(Box, { flexDirection: "column", paddingX: 1, paddingY: 0, children: [_jsxs(Box, { marginBottom: 1, children: [_jsx(Text, { color: ORANGE, bold: true, children: "skillview" }), _jsx(Text, { color: "gray", children: " \u00B7 local AI skill manager" })] }), view === "agents" && (_jsx(AgentList, { agents: agents, onSelect: openAgent, onQuit: exit })), view === "skills" && agent && (_jsx(SkillList, { agent: agent, skills: skills, onOpen: openSkill, onBack: () => setView("agents"), onChanged: refreshSkills })), view === "detail" && skill && (_jsx(SkillView, { skill: skill, onBack: () => setView("skills") }))] }));
|
|
37
|
+
}
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
3
|
+
/// <reference types="node" />
|
|
4
|
+
import fs from "node:fs";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
import { render } from "ink";
|
|
8
|
+
import { App } from "./app.js";
|
|
9
|
+
function readVersion() {
|
|
10
|
+
try {
|
|
11
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
12
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(here, "..", "package.json"), "utf8"));
|
|
13
|
+
return typeof pkg.version === "string" ? pkg.version : "unknown";
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
return "unknown";
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
const HELP = `skillview — interactive manager for your local AI agent skills
|
|
20
|
+
|
|
21
|
+
Usage:
|
|
22
|
+
skillview Launch the interactive browser
|
|
23
|
+
skillview --help Show this help
|
|
24
|
+
skillview --version Show the version
|
|
25
|
+
|
|
26
|
+
Scans skills under ~/.claude/skills, ~/.cursor/skills, and ~/.agents/skills.
|
|
27
|
+
|
|
28
|
+
Navigation:
|
|
29
|
+
↑/↓ Move selection / scroll
|
|
30
|
+
Enter Open the selected agent or skill
|
|
31
|
+
Esc / ← Go back a level
|
|
32
|
+
/ Search skills (in the skills view)
|
|
33
|
+
d Delete the selected skill (asks first)
|
|
34
|
+
PgUp / PgDn Page through a skill in the detail view
|
|
35
|
+
q / Ctrl+C Quit`;
|
|
36
|
+
const args = process.argv.slice(2);
|
|
37
|
+
if (args.includes("--help") || args.includes("-h")) {
|
|
38
|
+
console.log(HELP);
|
|
39
|
+
process.exit(0);
|
|
40
|
+
}
|
|
41
|
+
if (args.includes("--version") || args.includes("-v")) {
|
|
42
|
+
console.log(readVersion());
|
|
43
|
+
process.exit(0);
|
|
44
|
+
}
|
|
45
|
+
render(_jsx(App, {}));
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useState } from "react";
|
|
3
|
+
import { Box, Text, useInput } from "ink";
|
|
4
|
+
import { ORANGE } from "../theme.js";
|
|
5
|
+
export function AgentList({ agents, onSelect, onQuit }) {
|
|
6
|
+
const [index, setIndex] = useState(0);
|
|
7
|
+
useInput((input, key) => {
|
|
8
|
+
if (input === "q") {
|
|
9
|
+
onQuit();
|
|
10
|
+
}
|
|
11
|
+
else if (key.upArrow || input === "k") {
|
|
12
|
+
setIndex((i) => (i - 1 + agents.length) % agents.length);
|
|
13
|
+
}
|
|
14
|
+
else if (key.downArrow || input === "j") {
|
|
15
|
+
setIndex((i) => (i + 1) % agents.length);
|
|
16
|
+
}
|
|
17
|
+
else if (key.return) {
|
|
18
|
+
const a = agents[index];
|
|
19
|
+
if (a && a.exists && a.count > 0)
|
|
20
|
+
onSelect(a);
|
|
21
|
+
}
|
|
22
|
+
});
|
|
23
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { color: "gray", children: "Select an agent:" }), _jsx(Box, { flexDirection: "column", marginTop: 1, children: agents.map((a, i) => {
|
|
24
|
+
const selected = i === index;
|
|
25
|
+
const enterable = a.exists && a.count > 0;
|
|
26
|
+
const status = !a.exists
|
|
27
|
+
? "not found"
|
|
28
|
+
: a.count === 0
|
|
29
|
+
? "empty"
|
|
30
|
+
: `${a.count} skill${a.count === 1 ? "" : "s"}`;
|
|
31
|
+
return (_jsxs(Box, { children: [_jsx(Text, { color: selected ? ORANGE : undefined, children: selected ? "❯ " : " " }), _jsx(Text, { color: selected ? ORANGE : enterable ? undefined : "gray", bold: selected, children: a.label }), _jsxs(Text, { color: "gray", children: [" ", a.id, " \u00B7 ", status] })] }, a.id));
|
|
32
|
+
}) }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { color: "gray", children: "\u2191/\u2193 move \u00B7 Enter open \u00B7 q quit" }) })] }));
|
|
33
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text, useInput } from "ink";
|
|
3
|
+
export function ConfirmDelete({ skill, onConfirm, onCancel }) {
|
|
4
|
+
useInput((input, key) => {
|
|
5
|
+
if (input === "y" || input === "Y") {
|
|
6
|
+
onConfirm();
|
|
7
|
+
}
|
|
8
|
+
else if (input === "n" || input === "N" || key.escape || key.return) {
|
|
9
|
+
onCancel();
|
|
10
|
+
}
|
|
11
|
+
});
|
|
12
|
+
return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "red", paddingX: 1, children: [_jsxs(Text, { color: "red", bold: true, children: ["Delete skill \"", skill.title, "\"?"] }), _jsx(Text, { color: "gray", children: "This permanently removes:" }), _jsx(Text, { color: "gray", children: skill.path }), _jsx(Box, { marginTop: 1, children: _jsxs(Text, { children: ["Press ", _jsx(Text, { color: "red", bold: true, children: "y" }), " to delete \u00B7 any other key to cancel"] }) })] }));
|
|
13
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useState, useMemo, useEffect } from "react";
|
|
3
|
+
import { Box, Text, useApp, useInput, useStdout } from "ink";
|
|
4
|
+
import TextInput from "ink-text-input";
|
|
5
|
+
import { deleteSkill } from "../lib/scanner.js";
|
|
6
|
+
import { wrapToLines } from "../lib/wrap.js";
|
|
7
|
+
import { ConfirmDelete } from "./ConfirmDelete.js";
|
|
8
|
+
import { ORANGE } from "../theme.js";
|
|
9
|
+
const ITEM_ROWS = 4; // title + 2 description lines + blank separator
|
|
10
|
+
export function SkillList({ agent, skills, onOpen, onBack, onChanged }) {
|
|
11
|
+
const { exit } = useApp();
|
|
12
|
+
const { stdout } = useStdout();
|
|
13
|
+
const rows = stdout?.rows ?? 24;
|
|
14
|
+
const columns = stdout?.columns ?? 80;
|
|
15
|
+
const [mode, setMode] = useState("list");
|
|
16
|
+
const [query, setQuery] = useState("");
|
|
17
|
+
const [index, setIndex] = useState(0);
|
|
18
|
+
const [offset, setOffset] = useState(0);
|
|
19
|
+
const [message, setMessage] = useState(null);
|
|
20
|
+
const filtered = useMemo(() => {
|
|
21
|
+
const q = query.trim().toLowerCase();
|
|
22
|
+
if (!q)
|
|
23
|
+
return skills;
|
|
24
|
+
return skills.filter((s) => s.title.toLowerCase().includes(q) ||
|
|
25
|
+
s.description.toLowerCase().includes(q));
|
|
26
|
+
}, [skills, query]);
|
|
27
|
+
// Keep selection in range when the list changes.
|
|
28
|
+
useEffect(() => {
|
|
29
|
+
setIndex((i) => Math.min(i, Math.max(0, filtered.length - 1)));
|
|
30
|
+
}, [filtered.length]);
|
|
31
|
+
const perPage = Math.max(1, Math.floor((rows - 8) / ITEM_ROWS));
|
|
32
|
+
// Scroll so the selected item stays visible.
|
|
33
|
+
useEffect(() => {
|
|
34
|
+
setOffset((o) => {
|
|
35
|
+
if (index < o)
|
|
36
|
+
return index;
|
|
37
|
+
if (index >= o + perPage)
|
|
38
|
+
return index - perPage + 1;
|
|
39
|
+
return o;
|
|
40
|
+
});
|
|
41
|
+
}, [index, perPage]);
|
|
42
|
+
const selected = filtered[index];
|
|
43
|
+
useInput((input, key) => {
|
|
44
|
+
if (input === "q") {
|
|
45
|
+
exit();
|
|
46
|
+
}
|
|
47
|
+
else if (key.upArrow || input === "k") {
|
|
48
|
+
setIndex((i) => (i - 1 + filtered.length) % Math.max(1, filtered.length));
|
|
49
|
+
setMessage(null);
|
|
50
|
+
}
|
|
51
|
+
else if (key.downArrow || input === "j") {
|
|
52
|
+
setIndex((i) => (i + 1) % Math.max(1, filtered.length));
|
|
53
|
+
setMessage(null);
|
|
54
|
+
}
|
|
55
|
+
else if (key.return) {
|
|
56
|
+
if (selected)
|
|
57
|
+
onOpen(selected);
|
|
58
|
+
}
|
|
59
|
+
else if (input === "d") {
|
|
60
|
+
if (selected)
|
|
61
|
+
setMode("confirm");
|
|
62
|
+
}
|
|
63
|
+
else if (input === "/") {
|
|
64
|
+
setMode("search");
|
|
65
|
+
setMessage(null);
|
|
66
|
+
}
|
|
67
|
+
else if (key.escape || key.leftArrow) {
|
|
68
|
+
if (query) {
|
|
69
|
+
setQuery("");
|
|
70
|
+
}
|
|
71
|
+
else {
|
|
72
|
+
onBack();
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}, { isActive: mode === "list" });
|
|
76
|
+
// Esc inside the search box clears the query and returns to the list.
|
|
77
|
+
useInput((_input, key) => {
|
|
78
|
+
if (key.escape) {
|
|
79
|
+
setQuery("");
|
|
80
|
+
setMode("list");
|
|
81
|
+
}
|
|
82
|
+
}, { isActive: mode === "search" });
|
|
83
|
+
if (mode === "confirm" && selected) {
|
|
84
|
+
return (_jsx(ConfirmDelete, { skill: selected, onConfirm: () => {
|
|
85
|
+
const title = selected.title;
|
|
86
|
+
try {
|
|
87
|
+
deleteSkill(selected);
|
|
88
|
+
setMessage(`Deleted "${title}"`);
|
|
89
|
+
}
|
|
90
|
+
catch (err) {
|
|
91
|
+
setMessage(`Failed to delete: ${String(err)}`);
|
|
92
|
+
}
|
|
93
|
+
setMode("list");
|
|
94
|
+
onChanged();
|
|
95
|
+
}, onCancel: () => setMode("list") }));
|
|
96
|
+
}
|
|
97
|
+
const descWidth = columns - 6;
|
|
98
|
+
const visible = filtered.slice(offset, offset + perPage);
|
|
99
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { color: ORANGE, bold: true, children: agent.label }), _jsxs(Text, { color: "gray", children: [" ", filtered.length, query ? `/${skills.length}` : "", " skill", skills.length === 1 ? "" : "s", " \u00B7 newest first"] })] }), (mode === "search" || query) && (_jsxs(Box, { children: [_jsx(Text, { color: "gray", children: "search: " }), mode === "search" ? (_jsx(TextInput, { value: query, onChange: setQuery, onSubmit: () => setMode("list") })) : (_jsx(Text, { children: query }))] })), message && (_jsx(Box, { marginTop: 1, children: _jsx(Text, { color: "green", children: message }) })), _jsx(Box, { flexDirection: "column", marginTop: 1, children: filtered.length === 0 ? (_jsx(Text, { color: "gray", children: query ? "No skills match your search." : "No skills here." })) : (visible.map((s, i) => {
|
|
100
|
+
const realIndex = offset + i;
|
|
101
|
+
const isSel = realIndex === index;
|
|
102
|
+
const descLines = wrapToLines(s.description || "(no description)", descWidth, 2);
|
|
103
|
+
return (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsxs(Box, { children: [_jsx(Text, { color: isSel ? ORANGE : "gray", children: isSel ? "❯ " : " " }), _jsx(Text, { color: ORANGE, bold: isSel, children: s.title })] }), descLines.map((line, li) => (_jsxs(Text, { color: s.description ? undefined : "gray", children: [" ", line] }, li)))] }, s.path));
|
|
104
|
+
})) }), _jsx(Box, { children: _jsx(Text, { color: "gray", children: "\u2191/\u2193 move \u00B7 Enter view \u00B7 / search \u00B7 d delete \u00B7 Esc back \u00B7 q quit" }) })] }));
|
|
105
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useState, useMemo, useEffect } from "react";
|
|
3
|
+
import { Box, Text, useApp, useInput, useStdout } from "ink";
|
|
4
|
+
import { readSkill } from "../lib/scanner.js";
|
|
5
|
+
import { ORANGE } from "../theme.js";
|
|
6
|
+
export function SkillView({ skill, onBack }) {
|
|
7
|
+
const { exit } = useApp();
|
|
8
|
+
const { stdout } = useStdout();
|
|
9
|
+
const rows = stdout?.rows ?? 24;
|
|
10
|
+
const content = useMemo(() => readSkill(skill), [skill]);
|
|
11
|
+
const lines = useMemo(() => content.replace(/\t/g, " ").split("\n"), [
|
|
12
|
+
content,
|
|
13
|
+
]);
|
|
14
|
+
const viewport = Math.max(3, rows - 6); // reserve header + footer
|
|
15
|
+
const maxOffset = Math.max(0, lines.length - viewport);
|
|
16
|
+
const [offset, setOffset] = useState(0);
|
|
17
|
+
useEffect(() => {
|
|
18
|
+
setOffset(0);
|
|
19
|
+
}, [skill]);
|
|
20
|
+
useInput((input, key) => {
|
|
21
|
+
if (key.escape || key.leftArrow || input === "b") {
|
|
22
|
+
onBack();
|
|
23
|
+
}
|
|
24
|
+
else if (input === "q") {
|
|
25
|
+
exit();
|
|
26
|
+
}
|
|
27
|
+
else if (key.upArrow || input === "k") {
|
|
28
|
+
setOffset((o) => Math.max(0, o - 1));
|
|
29
|
+
}
|
|
30
|
+
else if (key.downArrow || input === "j") {
|
|
31
|
+
setOffset((o) => Math.min(maxOffset, o + 1));
|
|
32
|
+
}
|
|
33
|
+
else if (key.pageUp) {
|
|
34
|
+
setOffset((o) => Math.max(0, o - viewport));
|
|
35
|
+
}
|
|
36
|
+
else if (key.pageDown || input === " ") {
|
|
37
|
+
setOffset((o) => Math.min(maxOffset, o + viewport));
|
|
38
|
+
}
|
|
39
|
+
else if (input === "g") {
|
|
40
|
+
setOffset(0);
|
|
41
|
+
}
|
|
42
|
+
else if (input === "G") {
|
|
43
|
+
setOffset(maxOffset);
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
const visible = lines.slice(offset, offset + viewport);
|
|
47
|
+
const atEnd = offset >= maxOffset;
|
|
48
|
+
const pct = maxOffset === 0 ? 100 : Math.round((offset / maxOffset) * 100);
|
|
49
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { color: ORANGE, bold: true, children: skill.title }), _jsxs(Text, { color: "gray", children: [" ", skill.file] })] }), _jsx(Box, { flexDirection: "column", marginTop: 1, children: visible.map((line, i) => (_jsx(Text, { children: line.length ? line : " " }, offset + i))) }), _jsx(Box, { marginTop: 1, children: _jsxs(Text, { color: "gray", children: [atEnd ? "END" : `${pct}%`, " \u00B7 \u2191/\u2193 scroll \u00B7 PgUp/PgDn page \u00B7 g/G top/bottom \u00B7 Esc back \u00B7 q quit"] }) })] }));
|
|
50
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import matter from "gray-matter";
|
|
5
|
+
/** The agent directories we manage, in display order. */
|
|
6
|
+
const AGENT_DEFS = [
|
|
7
|
+
{ id: ".claude", label: "Claude" },
|
|
8
|
+
{ id: ".cursor", label: "Cursor" },
|
|
9
|
+
{ id: ".agents", label: "Agents" },
|
|
10
|
+
];
|
|
11
|
+
const HOME = os.homedir();
|
|
12
|
+
function skillsDirFor(id) {
|
|
13
|
+
return path.join(HOME, id, "skills");
|
|
14
|
+
}
|
|
15
|
+
/** Collapse any run of whitespace (incl. newlines) into single spaces. */
|
|
16
|
+
function collapse(text) {
|
|
17
|
+
return text.replace(/\s+/g, " ").trim();
|
|
18
|
+
}
|
|
19
|
+
/** A skill entry may be a real directory or a symlink to one. */
|
|
20
|
+
function isDirLike(entry) {
|
|
21
|
+
return entry.isDirectory() || entry.isSymbolicLink();
|
|
22
|
+
}
|
|
23
|
+
function countSkills(skillsDir) {
|
|
24
|
+
try {
|
|
25
|
+
return fs
|
|
26
|
+
.readdirSync(skillsDir, { withFileTypes: true })
|
|
27
|
+
.filter((e) => isDirLike(e) &&
|
|
28
|
+
fs.existsSync(path.join(skillsDir, e.name, "SKILL.md"))).length;
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return 0;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
/** List the agent directories with their skill counts. */
|
|
35
|
+
export function listAgents() {
|
|
36
|
+
return AGENT_DEFS.map(({ id, label }) => {
|
|
37
|
+
const skillsDir = skillsDirFor(id);
|
|
38
|
+
const exists = fs.existsSync(skillsDir);
|
|
39
|
+
return {
|
|
40
|
+
id,
|
|
41
|
+
label,
|
|
42
|
+
skillsDir,
|
|
43
|
+
exists,
|
|
44
|
+
count: exists ? countSkills(skillsDir) : 0,
|
|
45
|
+
};
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
/** List the skills inside an agent, sorted by SKILL.md mtime (newest first). */
|
|
49
|
+
export function listSkills(agent) {
|
|
50
|
+
let entries;
|
|
51
|
+
try {
|
|
52
|
+
entries = fs.readdirSync(agent.skillsDir, { withFileTypes: true });
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
return [];
|
|
56
|
+
}
|
|
57
|
+
const skills = [];
|
|
58
|
+
for (const entry of entries) {
|
|
59
|
+
if (!isDirLike(entry))
|
|
60
|
+
continue;
|
|
61
|
+
const dirPath = path.join(agent.skillsDir, entry.name);
|
|
62
|
+
const file = path.join(dirPath, "SKILL.md");
|
|
63
|
+
let stat;
|
|
64
|
+
try {
|
|
65
|
+
stat = fs.statSync(file);
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
continue; // no SKILL.md -> not a skill
|
|
69
|
+
}
|
|
70
|
+
let title = entry.name;
|
|
71
|
+
let description = "";
|
|
72
|
+
try {
|
|
73
|
+
const raw = fs.readFileSync(file, "utf8");
|
|
74
|
+
const { data } = matter(raw);
|
|
75
|
+
if (typeof data.name === "string" && data.name.trim()) {
|
|
76
|
+
title = data.name.trim();
|
|
77
|
+
}
|
|
78
|
+
if (typeof data.description === "string") {
|
|
79
|
+
description = collapse(data.description);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
// keep dir-name title, empty description
|
|
84
|
+
}
|
|
85
|
+
skills.push({
|
|
86
|
+
dir: entry.name,
|
|
87
|
+
path: dirPath,
|
|
88
|
+
file,
|
|
89
|
+
title,
|
|
90
|
+
description,
|
|
91
|
+
mtime: stat.mtimeMs,
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
skills.sort((a, b) => b.mtime - a.mtime);
|
|
95
|
+
return skills;
|
|
96
|
+
}
|
|
97
|
+
/** Read the raw SKILL.md contents for the detail view. */
|
|
98
|
+
export function readSkill(skill) {
|
|
99
|
+
try {
|
|
100
|
+
return fs.readFileSync(skill.file, "utf8");
|
|
101
|
+
}
|
|
102
|
+
catch (err) {
|
|
103
|
+
return `Failed to read ${skill.file}\n\n${String(err)}`;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
/** Permanently delete a skill's directory. */
|
|
107
|
+
export function deleteSkill(skill) {
|
|
108
|
+
fs.rmSync(skill.path, { recursive: true, force: true });
|
|
109
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/lib/wrap.js
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wrap a single-line string into at most `maxLines` lines of `width` columns,
|
|
3
|
+
* appending an ellipsis if the text is truncated. Returns at least one line.
|
|
4
|
+
*/
|
|
5
|
+
export function wrapToLines(text, width, maxLines) {
|
|
6
|
+
const w = Math.max(8, width);
|
|
7
|
+
if (!text)
|
|
8
|
+
return [""];
|
|
9
|
+
const words = text.split(" ");
|
|
10
|
+
const lines = [];
|
|
11
|
+
let current = "";
|
|
12
|
+
for (const word of words) {
|
|
13
|
+
const candidate = current ? `${current} ${word}` : word;
|
|
14
|
+
if (candidate.length <= w) {
|
|
15
|
+
current = candidate;
|
|
16
|
+
continue;
|
|
17
|
+
}
|
|
18
|
+
// candidate too long
|
|
19
|
+
if (current)
|
|
20
|
+
lines.push(current);
|
|
21
|
+
current = word;
|
|
22
|
+
// a single word longer than the width: hard-split it
|
|
23
|
+
while (current.length > w) {
|
|
24
|
+
lines.push(current.slice(0, w));
|
|
25
|
+
current = current.slice(w);
|
|
26
|
+
}
|
|
27
|
+
if (lines.length >= maxLines)
|
|
28
|
+
break;
|
|
29
|
+
}
|
|
30
|
+
if (current && lines.length < maxLines)
|
|
31
|
+
lines.push(current);
|
|
32
|
+
if (lines.length > maxLines)
|
|
33
|
+
lines.length = maxLines;
|
|
34
|
+
// If we ran out of room but there was more text, mark truncation.
|
|
35
|
+
const consumed = lines.join(" ").length;
|
|
36
|
+
if (consumed < text.length && lines.length > 0) {
|
|
37
|
+
const last = lines[maxLines - 1] ?? lines[lines.length - 1] ?? "";
|
|
38
|
+
const trimmed = last.length >= w ? `${last.slice(0, w - 1)}…` : `${last}…`;
|
|
39
|
+
lines[lines.length - 1] = trimmed;
|
|
40
|
+
}
|
|
41
|
+
return lines.length ? lines : [""];
|
|
42
|
+
}
|
package/dist/theme.js
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "skillview",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Interactive terminal manager for local AI agent skills (Claude, Cursor, Agents). Browse, search, view, and delete SKILL.md files by agent and recency.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"ai",
|
|
7
|
+
"skills",
|
|
8
|
+
"claude",
|
|
9
|
+
"cursor",
|
|
10
|
+
"agents",
|
|
11
|
+
"cli",
|
|
12
|
+
"tui",
|
|
13
|
+
"ink",
|
|
14
|
+
"skill-manager"
|
|
15
|
+
],
|
|
16
|
+
"license": "MIT",
|
|
17
|
+
"author": "Jianyu",
|
|
18
|
+
"type": "module",
|
|
19
|
+
"bin": {
|
|
20
|
+
"skillview": "dist/cli.js"
|
|
21
|
+
},
|
|
22
|
+
"files": [
|
|
23
|
+
"dist"
|
|
24
|
+
],
|
|
25
|
+
"engines": {
|
|
26
|
+
"node": ">=18"
|
|
27
|
+
},
|
|
28
|
+
"scripts": {
|
|
29
|
+
"build": "tsc",
|
|
30
|
+
"dev": "tsx src/cli.tsx",
|
|
31
|
+
"start": "node dist/cli.js",
|
|
32
|
+
"prepublishOnly": "npm run build"
|
|
33
|
+
},
|
|
34
|
+
"dependencies": {
|
|
35
|
+
"gray-matter": "^4.0.3",
|
|
36
|
+
"ink": "^5.1.0",
|
|
37
|
+
"ink-text-input": "^6.0.0",
|
|
38
|
+
"react": "^18.3.1"
|
|
39
|
+
},
|
|
40
|
+
"devDependencies": {
|
|
41
|
+
"@types/node": "^22.10.0",
|
|
42
|
+
"@types/react": "^18.3.12",
|
|
43
|
+
"tsx": "^4.19.2",
|
|
44
|
+
"typescript": "^5.7.2"
|
|
45
|
+
}
|
|
46
|
+
}
|