@smitdev/ai-skills 0.2.0 → 0.3.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/README.md +2 -1
- package/bin/cli.js +30 -34
- package/lib/prompt.js +185 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -14,7 +14,8 @@ No global install needed — run it with `npx`:
|
|
|
14
14
|
# See what's available
|
|
15
15
|
npx @smitdev/ai-skills list
|
|
16
16
|
|
|
17
|
-
# Interactive: pick assistant(s) and skill(s)
|
|
17
|
+
# Interactive: pick assistant(s) and skill(s) with the keyboard
|
|
18
|
+
# ↑/↓ move · space or tab toggle · a select all · enter confirm
|
|
18
19
|
npx @smitdev/ai-skills install
|
|
19
20
|
|
|
20
21
|
# Non-interactive examples
|
package/bin/cli.js
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
'use strict';
|
|
3
3
|
|
|
4
|
-
const readline = require('readline');
|
|
5
4
|
const path = require('path');
|
|
6
5
|
const { listSkills } = require('../lib/skills');
|
|
7
6
|
const { adapters } = require('../lib/adapters');
|
|
8
7
|
const { install } = require('../lib/install');
|
|
8
|
+
const { multiselect, select } = require('../lib/prompt');
|
|
9
9
|
|
|
10
10
|
const ASSISTANT_IDS = Object.keys(adapters);
|
|
11
11
|
|
|
@@ -31,10 +31,6 @@ function parseArgs(argv) {
|
|
|
31
31
|
return args;
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
-
function ask(rl, question) {
|
|
35
|
-
return new Promise((resolve) => rl.question(question, (a) => resolve(a.trim())));
|
|
36
|
-
}
|
|
37
|
-
|
|
38
34
|
function parseList(val, valid) {
|
|
39
35
|
if (val === true || !val) return [];
|
|
40
36
|
const items = String(val)
|
|
@@ -55,6 +51,8 @@ Usage:
|
|
|
55
51
|
Commands:
|
|
56
52
|
list List the skills bundled in this package
|
|
57
53
|
install Install skills for one or more assistants
|
|
54
|
+
(run with no flags for an interactive picker:
|
|
55
|
+
↑/↓ move · space/tab toggle · a all · enter confirm)
|
|
58
56
|
|
|
59
57
|
Options for "install":
|
|
60
58
|
--assistant <ids> Comma-separated: ${ASSISTANT_IDS.join(', ')} (or "all")
|
|
@@ -73,20 +71,6 @@ Examples:
|
|
|
73
71
|
`);
|
|
74
72
|
}
|
|
75
73
|
|
|
76
|
-
async function pickFromList(rl, label, options) {
|
|
77
|
-
console.log(`\n${label}`);
|
|
78
|
-
options.forEach((o, i) => console.log(` ${i + 1}. ${o.label}`));
|
|
79
|
-
console.log(` (comma-separated numbers, or "a" for all)`);
|
|
80
|
-
const ans = await ask(rl, '> ');
|
|
81
|
-
if (ans.toLowerCase() === 'a' || ans === '') return options.map((o) => o.id);
|
|
82
|
-
const picked = ans
|
|
83
|
-
.split(',')
|
|
84
|
-
.map((s) => parseInt(s.trim(), 10))
|
|
85
|
-
.filter((n) => n >= 1 && n <= options.length)
|
|
86
|
-
.map((n) => options[n - 1].id);
|
|
87
|
-
return [...new Set(picked)];
|
|
88
|
-
}
|
|
89
|
-
|
|
90
74
|
async function runInstall(args) {
|
|
91
75
|
const skills = listSkills();
|
|
92
76
|
if (skills.length === 0) {
|
|
@@ -98,31 +82,43 @@ async function runInstall(args) {
|
|
|
98
82
|
typeof args.flags.dir === 'string' ? args.flags.dir : process.cwd()
|
|
99
83
|
);
|
|
100
84
|
const dryRun = !!args.flags['dry-run'];
|
|
101
|
-
const
|
|
85
|
+
const scopeFlagged = !!args.flags.global || !!args.flags.project;
|
|
86
|
+
let scope = args.flags.global ? 'global' : 'project';
|
|
102
87
|
|
|
103
88
|
let assistants = parseList(args.flags.assistant, ASSISTANT_IDS);
|
|
104
89
|
let skillNames = parseList(args.flags.skill, skills.map((s) => s.name));
|
|
105
90
|
|
|
106
|
-
const nonInteractive =
|
|
107
|
-
args.flags.yes || (assistants.length > 0);
|
|
91
|
+
const nonInteractive = args.flags.yes || assistants.length > 0;
|
|
108
92
|
|
|
109
93
|
if (!nonInteractive && process.stdin.isTTY) {
|
|
110
|
-
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
111
94
|
if (assistants.length === 0) {
|
|
112
|
-
assistants = await
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
);
|
|
95
|
+
assistants = await multiselect({
|
|
96
|
+
title: 'Which assistant(s)? (Claude Code, Copilot, Cursor, Windsurf)',
|
|
97
|
+
options: ASSISTANT_IDS.map((id) => ({ id, label: adapters[id].label })),
|
|
98
|
+
initial: ['claude'],
|
|
99
|
+
});
|
|
117
100
|
}
|
|
118
101
|
if (skillNames.length === 0) {
|
|
119
|
-
skillNames = await
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
102
|
+
skillNames = await multiselect({
|
|
103
|
+
title: 'Which skill(s)?',
|
|
104
|
+
options: skills.map((s) => ({
|
|
105
|
+
id: s.name,
|
|
106
|
+
label: `${s.name} — ${truncate(s.description, 60)}`,
|
|
107
|
+
})),
|
|
108
|
+
initial: skills.map((s) => s.name),
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
// Only Claude Code supports a global scope; ask when relevant.
|
|
112
|
+
if (!scopeFlagged && assistants.includes('claude')) {
|
|
113
|
+
scope = await select({
|
|
114
|
+
title: 'Install scope for Claude Code?',
|
|
115
|
+
options: [
|
|
116
|
+
{ id: 'project', label: 'This project (./.claude/skills)' },
|
|
117
|
+
{ id: 'global', label: 'Global (~/.claude/skills, available everywhere)' },
|
|
118
|
+
],
|
|
119
|
+
initial: 'project',
|
|
120
|
+
});
|
|
124
121
|
}
|
|
125
|
-
rl.close();
|
|
126
122
|
}
|
|
127
123
|
|
|
128
124
|
if (assistants.length === 0) assistants = ASSISTANT_IDS.slice();
|
package/lib/prompt.js
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const readline = require('readline');
|
|
4
|
+
|
|
5
|
+
// Tiny zero-dependency interactive prompts (checkbox + radio) built on
|
|
6
|
+
// readline keypress events. Falls back to nothing in non-TTY environments —
|
|
7
|
+
// callers should guard with process.stdin.isTTY.
|
|
8
|
+
|
|
9
|
+
const useColor = !!process.stdout.isTTY && !process.env.NO_COLOR;
|
|
10
|
+
const paint = (code) => (s) => (useColor ? `\x1b[${code}m${s}\x1b[0m` : String(s));
|
|
11
|
+
const dim = paint('2');
|
|
12
|
+
const bold = paint('1');
|
|
13
|
+
const cyan = paint('36');
|
|
14
|
+
const green = paint('32');
|
|
15
|
+
|
|
16
|
+
function setupKeys() {
|
|
17
|
+
const input = process.stdin;
|
|
18
|
+
readline.emitKeypressEvents(input);
|
|
19
|
+
if (input.isTTY) input.setRawMode(true);
|
|
20
|
+
input.resume();
|
|
21
|
+
return input;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function teardownKeys(input, onKey) {
|
|
25
|
+
input.removeListener('keypress', onKey);
|
|
26
|
+
if (input.isTTY) input.setRawMode(false);
|
|
27
|
+
input.pause();
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function hideCursor() {
|
|
31
|
+
if (useColor) process.stdout.write('\x1b[?25l');
|
|
32
|
+
}
|
|
33
|
+
function showCursor() {
|
|
34
|
+
if (useColor) process.stdout.write('\x1b[?25h');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Multi-select checkbox prompt.
|
|
39
|
+
* options: [{ id, label }]
|
|
40
|
+
* initial: array of pre-selected ids
|
|
41
|
+
* Resolves to an array of selected ids (in option order). Always returns at
|
|
42
|
+
* least one (the highlighted item) so callers never get an empty selection.
|
|
43
|
+
*/
|
|
44
|
+
function multiselect({ title, options, initial }) {
|
|
45
|
+
return new Promise((resolve) => {
|
|
46
|
+
const selected = new Set(initial || []);
|
|
47
|
+
let cursor = 0;
|
|
48
|
+
let rendered = 0;
|
|
49
|
+
const output = process.stdout;
|
|
50
|
+
|
|
51
|
+
const hint = dim(
|
|
52
|
+
'↑/↓ move · space/tab toggle · a all · enter confirm'
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
function render(first) {
|
|
56
|
+
if (!first) output.write(`\x1b[${rendered}A`);
|
|
57
|
+
const lines = [bold(title), hint];
|
|
58
|
+
options.forEach((o, i) => {
|
|
59
|
+
const active = i === cursor;
|
|
60
|
+
const pointer = active ? cyan('›') : ' ';
|
|
61
|
+
const box = selected.has(o.id) ? green('◉') : dim('◯');
|
|
62
|
+
const label = active ? cyan(o.label) : o.label;
|
|
63
|
+
lines.push(`${pointer} ${box} ${label}`);
|
|
64
|
+
});
|
|
65
|
+
output.write(lines.map((l) => '\x1b[2K' + l).join('\n') + '\n');
|
|
66
|
+
rendered = lines.length;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const input = setupKeys();
|
|
70
|
+
hideCursor();
|
|
71
|
+
|
|
72
|
+
function finish() {
|
|
73
|
+
if (selected.size === 0) selected.add(options[cursor].id);
|
|
74
|
+
teardownKeys(input, onKey);
|
|
75
|
+
showCursor();
|
|
76
|
+
resolve(options.filter((o) => selected.has(o.id)).map((o) => o.id));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function onKey(_str, key) {
|
|
80
|
+
if (!key) return;
|
|
81
|
+
if (key.ctrl && key.name === 'c') {
|
|
82
|
+
teardownKeys(input, onKey);
|
|
83
|
+
showCursor();
|
|
84
|
+
output.write('\n');
|
|
85
|
+
process.exit(130);
|
|
86
|
+
}
|
|
87
|
+
switch (key.name) {
|
|
88
|
+
case 'up':
|
|
89
|
+
case 'k':
|
|
90
|
+
cursor = (cursor - 1 + options.length) % options.length;
|
|
91
|
+
break;
|
|
92
|
+
case 'down':
|
|
93
|
+
case 'j':
|
|
94
|
+
cursor = (cursor + 1) % options.length;
|
|
95
|
+
break;
|
|
96
|
+
case 'space':
|
|
97
|
+
case 'tab':
|
|
98
|
+
if (selected.has(options[cursor].id)) selected.delete(options[cursor].id);
|
|
99
|
+
else selected.add(options[cursor].id);
|
|
100
|
+
break;
|
|
101
|
+
case 'a': {
|
|
102
|
+
if (selected.size === options.length) selected.clear();
|
|
103
|
+
else options.forEach((o) => selected.add(o.id));
|
|
104
|
+
break;
|
|
105
|
+
}
|
|
106
|
+
case 'return':
|
|
107
|
+
case 'enter':
|
|
108
|
+
finish();
|
|
109
|
+
return;
|
|
110
|
+
default:
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
render(false);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
input.on('keypress', onKey);
|
|
117
|
+
render(true);
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Single-select radio prompt.
|
|
123
|
+
* options: [{ id, label }]
|
|
124
|
+
* Resolves to one id.
|
|
125
|
+
*/
|
|
126
|
+
function select({ title, options, initial }) {
|
|
127
|
+
return new Promise((resolve) => {
|
|
128
|
+
let cursor = Math.max(0, options.findIndex((o) => o.id === initial));
|
|
129
|
+
if (cursor < 0) cursor = 0;
|
|
130
|
+
let rendered = 0;
|
|
131
|
+
const output = process.stdout;
|
|
132
|
+
const hint = dim('↑/↓ move · enter select');
|
|
133
|
+
|
|
134
|
+
function render(first) {
|
|
135
|
+
if (!first) output.write(`\x1b[${rendered}A`);
|
|
136
|
+
const lines = [bold(title), hint];
|
|
137
|
+
options.forEach((o, i) => {
|
|
138
|
+
const active = i === cursor;
|
|
139
|
+
const pointer = active ? cyan('›') : ' ';
|
|
140
|
+
const dot = active ? green('◉') : dim('◯');
|
|
141
|
+
const label = active ? cyan(o.label) : o.label;
|
|
142
|
+
lines.push(`${pointer} ${dot} ${label}`);
|
|
143
|
+
});
|
|
144
|
+
output.write(lines.map((l) => '\x1b[2K' + l).join('\n') + '\n');
|
|
145
|
+
rendered = lines.length;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const input = setupKeys();
|
|
149
|
+
hideCursor();
|
|
150
|
+
|
|
151
|
+
function onKey(_str, key) {
|
|
152
|
+
if (!key) return;
|
|
153
|
+
if (key.ctrl && key.name === 'c') {
|
|
154
|
+
teardownKeys(input, onKey);
|
|
155
|
+
showCursor();
|
|
156
|
+
output.write('\n');
|
|
157
|
+
process.exit(130);
|
|
158
|
+
}
|
|
159
|
+
switch (key.name) {
|
|
160
|
+
case 'up':
|
|
161
|
+
case 'k':
|
|
162
|
+
cursor = (cursor - 1 + options.length) % options.length;
|
|
163
|
+
break;
|
|
164
|
+
case 'down':
|
|
165
|
+
case 'j':
|
|
166
|
+
cursor = (cursor + 1) % options.length;
|
|
167
|
+
break;
|
|
168
|
+
case 'return':
|
|
169
|
+
case 'enter':
|
|
170
|
+
teardownKeys(input, onKey);
|
|
171
|
+
showCursor();
|
|
172
|
+
resolve(options[cursor].id);
|
|
173
|
+
return;
|
|
174
|
+
default:
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
render(false);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
input.on('keypress', onKey);
|
|
181
|
+
render(true);
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
module.exports = { multiselect, select };
|
package/package.json
CHANGED