@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 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 scope = args.flags.global ? 'global' : 'project';
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 pickFromList(
113
- rl,
114
- 'Which assistant(s)?',
115
- ASSISTANT_IDS.map((id) => ({ id, label: adapters[id].label }))
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 pickFromList(
120
- rl,
121
- 'Which skill(s)?',
122
- skills.map((s) => ({ id: s.name, label: `${s.name} — ${truncate(s.description, 60)}` }))
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@smitdev/ai-skills",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Install reusable AI assistant skills (Claude Code, GitHub Copilot, Cursor, Windsurf) with one command.",
5
5
  "bin": {
6
6
  "ai-skills": "bin/cli.js"