spirewise 1.0.3 → 1.6.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/bin/cli.js CHANGED
@@ -2,26 +2,25 @@
2
2
  'use strict';
3
3
 
4
4
  /**
5
- * spirewise — install copywriting Agent Skills into ALL your AI agents at once.
5
+ * spirewise — install copywriting Agent Skills into your AI agents.
6
6
  *
7
- * Installs into each agent's correct folder, in the format that agent expects,
8
- * and asks whether to install for THIS project (cwd) or GLOBALLY (home dirs).
7
+ * Run with no flags for a full-screen interactive picker:
8
+ * npx spirewise (or: npx spirewise install)
9
9
  *
10
- * Usage:
11
- * npx spirewise install # interactive: pick scope, install all
12
- * npx spirewise install --project # this project only
13
- * npx spirewise install --global # global (home) only
14
- * npx spirewise install --both # project + global
15
- * npx spirewise install --agent claude,cursor # restrict agents
16
- * npx spirewise install f6s-copywriting # restrict skills
17
- * npx spirewise list # list skills
18
- * npx spirewise agents # list supported agents + folders
10
+ * Or drive everything from flags (skips the matching picker step):
11
+ * -s, --skills <a,b> skills to install (default: all)
12
+ * -a, --agents <a,b> agents to target (default: all) alias: --agent
13
+ * -sc, --scope <s> workspace | global | both
14
+ * --workspace|--global|--both scope shortcuts
15
+ *
16
+ * Other commands:
17
+ * npx spirewise list list skills
18
+ * npx spirewise agents list supported agents + folders
19
19
  */
20
20
 
21
21
  const fs = require('fs');
22
22
  const path = require('path');
23
23
  const os = require('os');
24
- const readline = require('readline');
25
24
 
26
25
  const PKG_ROOT = path.resolve(__dirname, '..');
27
26
  const SKILLS_DIR = path.join(PKG_ROOT, 'skills');
@@ -34,8 +33,7 @@ const RAW = {
34
33
  red: '\x1b[1;31m', cyan: '\x1b[1;36m', magenta: '\x1b[1;35m',
35
34
  };
36
35
  const paint = (code, s) => (USE_COLOR ? `${code}${s}${RAW.reset}` : s);
37
- const c = USE_COLOR
38
- ? RAW
36
+ const c = USE_COLOR ? RAW
39
37
  : { reset: '', bold: '', dim: '', blue: '', green: '', yellow: '', red: '', cyan: '', magenta: '' };
40
38
 
41
39
  const info = (m) => console.log(`${c.blue}==>${c.reset} ${m}`);
@@ -58,60 +56,30 @@ const BANNER = [
58
56
  function banner() {
59
57
  console.log('');
60
58
  for (const line of BANNER) console.log(paint(RAW.cyan, line));
61
- const tag = ` Agent Skills · F6S & LinkedIn copywriting${PKG_VERSION ? ' · v' + PKG_VERSION : ''}`;
62
- console.log(paint(RAW.magenta, tag));
59
+ console.log(paint(RAW.magenta, ` Agent Skills · F6S & LinkedIn copywriting${PKG_VERSION ? ' · v' + PKG_VERSION : ''}`));
63
60
  console.log('');
64
61
  }
65
62
 
66
- let STEP_TOTAL = 4;
67
- function step(n, msg) {
68
- console.log(`${paint(RAW.cyan, ' ▸')} ${paint(RAW.bold, `Step ${n}/${STEP_TOTAL}`)} ${msg}`);
69
- }
70
- function substep(msg) {
71
- console.log(` ${paint(RAW.green, '✓')} ${msg}`);
72
- }
73
-
74
- function box(lines) {
75
- const vis = (s) => s.replace(/\x1b\[[0-9;]*m/g, '');
76
- const width = Math.max(...lines.map((l) => vis(l).length));
77
- const top = ' ┌' + '─'.repeat(width + 2) + '┐';
78
- const bot = ' └' + '─'.repeat(width + 2) + '┘';
79
- console.log(paint(RAW.green, top));
80
- for (const l of lines) console.log(paint(RAW.green, ' │ ') + l + ' '.repeat(width - vis(l).length) + paint(RAW.green, ' │'));
81
- console.log(paint(RAW.green, bot));
82
- }
83
-
84
63
  /**
85
64
  * Agent registry. `format`:
86
65
  * - 'skill': copy the skill folder verbatim (SKILL.md + any files)
87
66
  * - 'rule' : write one rule file derived from SKILL.md (ext required)
88
- * `project` is resolved from cwd; `global` uses ~ for the home directory.
67
+ * `project` resolves from cwd; `global` uses ~ for the home directory.
68
+ * Newer tools' folders are best-effort conventions — edit freely.
89
69
  */
90
70
  const AGENTS = {
91
- claude: {
92
- label: 'Claude Code', format: 'skill',
93
- project: '.claude/skills', global: '~/.claude/skills',
94
- },
95
- copilot: {
96
- label: 'GitHub Copilot', format: 'skill',
97
- project: '.github/skills', global: '~/.copilot/skills',
98
- },
99
- cursor: {
100
- label: 'Cursor', format: 'rule', ext: '.mdc',
101
- project: '.cursor/rules', global: '~/.cursor/rules',
102
- },
103
- windsurf: {
104
- label: 'Windsurf', format: 'rule', ext: '.md',
105
- project: '.windsurf/rules', global: '~/.codeium/windsurf/global_rules',
106
- },
107
- codex: {
108
- label: 'Codex CLI', format: 'skill',
109
- project: '.codex/skills', global: '~/.codex/skills',
110
- },
111
- gemini: {
112
- label: 'Gemini CLI', format: 'skill',
113
- project: '.gemini/skills', global: '~/.gemini/skills',
114
- },
71
+ claude: { label: 'Claude Code', format: 'skill', project: '.claude/skills', global: '~/.claude/skills' },
72
+ copilot: { label: 'GitHub Copilot', format: 'skill', project: '.github/skills', global: '~/.copilot/skills' },
73
+ cursor: { label: 'Cursor', format: 'rule', ext: '.mdc', project: '.cursor/rules', global: '~/.cursor/rules' },
74
+ windsurf: { label: 'Windsurf', format: 'rule', ext: '.md', project: '.windsurf/rules', global: '~/.codeium/windsurf/global_rules' },
75
+ codex: { label: 'Codex CLI', format: 'skill', project: '.codex/skills', global: '~/.codex/skills' },
76
+ gemini: { label: 'Gemini CLI', format: 'skill', project: '.gemini/skills', global: '~/.gemini/skills' },
77
+ opencode: { label: 'OpenCode', format: 'skill', project: '.opencode/skills', global: '~/.config/opencode/skills' },
78
+ cline: { label: 'Cline', format: 'rule', ext: '.md', project: '.clinerules', global: '~/.clinerules' },
79
+ roo: { label: 'Roo Code', format: 'rule', ext: '.md', project: '.roo/rules', global: '~/.roo/rules' },
80
+ kilocode: { label: 'Kilo Code', format: 'rule', ext: '.md', project: '.kilocode/rules', global: '~/.kilocode/rules' },
81
+ continue: { label: 'Continue', format: 'rule', ext: '.md', project: '.continue/rules', global: '~/.continue/rules' },
82
+ amp: { label: 'Amp', format: 'rule', ext: '.md', project: '.amp/rules', global: '~/.config/amp/rules' },
115
83
  };
116
84
 
117
85
  function expandHome(p) {
@@ -119,26 +87,21 @@ function expandHome(p) {
119
87
  if (p.startsWith('~/')) return path.join(HOME, p.slice(2));
120
88
  return p;
121
89
  }
122
-
123
90
  function resolveTarget(agent, scope) {
124
91
  const rel = scope === 'project' ? agent.project : agent.global;
125
- return scope === 'project'
126
- ? path.resolve(process.cwd(), rel)
127
- : path.resolve(expandHome(rel));
92
+ return scope === 'project' ? path.resolve(process.cwd(), rel) : path.resolve(expandHome(rel));
128
93
  }
129
94
 
130
95
  function availableSkills() {
131
96
  if (!fs.existsSync(SKILLS_DIR)) return [];
132
97
  return fs.readdirSync(SKILLS_DIR, { withFileTypes: true })
133
98
  .filter((d) => d.isDirectory() && fs.existsSync(path.join(SKILLS_DIR, d.name, 'SKILL.md')))
134
- .map((d) => d.name)
135
- .sort();
99
+ .map((d) => d.name).sort();
136
100
  }
137
101
 
138
102
  function readSkill(name) {
139
103
  const raw = fs.readFileSync(path.join(SKILLS_DIR, name, 'SKILL.md'), 'utf8');
140
- let description = name;
141
- let body = raw;
104
+ let description = name, body = raw;
142
105
  const m = raw.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
143
106
  if (m) {
144
107
  body = m[2];
@@ -147,14 +110,19 @@ function readSkill(name) {
147
110
  }
148
111
  return { description, body: body.replace(/^\s+/, '') };
149
112
  }
113
+ function skillHint(name) {
114
+ try {
115
+ const d = readSkill(name).description;
116
+ const first = d.split('. ')[0];
117
+ return first.length > 52 ? first.slice(0, 49) + '…' : first;
118
+ } catch (_) { return ''; }
119
+ }
150
120
 
151
121
  function copyDir(src, dest) {
152
122
  fs.mkdirSync(dest, { recursive: true });
153
123
  for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
154
- const s = path.join(src, entry.name);
155
- const d = path.join(dest, entry.name);
156
- if (entry.isDirectory()) copyDir(s, d);
157
- else fs.copyFileSync(s, d);
124
+ const s = path.join(src, entry.name), d = path.join(dest, entry.name);
125
+ if (entry.isDirectory()) copyDir(s, d); else fs.copyFileSync(s, d);
158
126
  }
159
127
  }
160
128
 
@@ -165,149 +133,283 @@ function installToAgent(agentKey, agent, scope, skills) {
165
133
  if (agent.format === 'skill') {
166
134
  const dest = path.join(targetDir, skill);
167
135
  copyDir(path.join(SKILLS_DIR, skill), dest);
168
- console.log(` ${paint(RAW.green, '✓')} ${paint(RAW.bold, agent.label)} ${c.dim}(${scope})${c.reset} ${skill} ${c.dim}→ ${dest}${c.reset}`);
136
+ console.log(` ${paint(RAW.green, '✓')} ${paint(RAW.bold, agent.label)} ${c.dim}(${scope === 'project' ? 'workspace' : scope})${c.reset} ${skill} ${c.dim}→ ${dest}${c.reset}`);
169
137
  } else {
170
138
  const { description, body } = readSkill(skill);
171
139
  const front = `---\ndescription: ${description}\nalwaysApply: false\n---\n\n`;
172
140
  const file = path.join(targetDir, skill + (agent.ext || '.md'));
173
141
  fs.writeFileSync(file, front + body);
174
- console.log(` ${paint(RAW.green, '✓')} ${paint(RAW.bold, agent.label)} ${c.dim}(${scope})${c.reset} ${skill} ${c.dim}→ ${file}${c.reset}`);
142
+ console.log(` ${paint(RAW.green, '✓')} ${paint(RAW.bold, agent.label)} ${c.dim}(${scope === 'project' ? 'workspace' : scope})${c.reset} ${skill} ${c.dim}→ ${file}${c.reset}`);
175
143
  }
176
144
  }
177
145
  }
178
146
 
179
- function usage() {
180
- console.log(`spirewise install copywriting Agent Skills into all your AI agents
181
-
182
- Usage:
183
- spirewise install [skill ...] [options]
184
- spirewise list
185
- spirewise agents
186
-
187
- Scope (where to install):
188
- --workspace Install only into this folder / current project (alias: --project)
189
- --global Install only into global/home folders
190
- --both Install into both workspace and global
191
- (no scope flag -> you are asked interactively)
192
-
193
- Selection:
194
- [skill ...] Limit to named skills (default: all)
195
- --agent <a,b> Limit to named agents (default: all)
147
+ // Returns the number of items actually removed.
148
+ function removeFromAgent(agentKey, agent, scope, skills) {
149
+ const targetDir = resolveTarget(agent, scope);
150
+ const tag = scope === 'project' ? 'workspace' : scope;
151
+ let removed = 0;
152
+ for (const skill of skills) {
153
+ const target = agent.format === 'skill'
154
+ ? path.join(targetDir, skill)
155
+ : path.join(targetDir, skill + (agent.ext || '.md'));
156
+ if (fs.existsSync(target)) {
157
+ fs.rmSync(target, { recursive: true, force: true });
158
+ removed++;
159
+ console.log(` ${paint(RAW.green, '✓')} ${paint(RAW.bold, agent.label)} ${c.dim}(${tag})${c.reset} ${skill} ${c.dim}removed ← ${target}${c.reset}`);
160
+ } else {
161
+ console.log(` ${paint(RAW.dim, '·')} ${agent.label} ${c.dim}(${tag})${c.reset} ${skill} ${c.dim}— not found, skipped${c.reset}`);
162
+ }
163
+ }
164
+ // Tidy up the now-empty rules/skills dir we may have created.
165
+ try {
166
+ if (fs.existsSync(targetDir) && fs.readdirSync(targetDir).length === 0) fs.rmdirSync(targetDir);
167
+ } catch (_) {}
168
+ return removed;
169
+ }
196
170
 
197
- Other:
198
- -h, --help Show this help
171
+ function box(lines) {
172
+ const vis = (s) => s.replace(/\x1b\[[0-9;]*m/g, '');
173
+ const width = Math.max(...lines.map((l) => vis(l).length));
174
+ console.log(paint(RAW.green, ' ┌' + '─'.repeat(width + 2) + '┐'));
175
+ for (const l of lines) console.log(paint(RAW.green, ' │ ') + l + ' '.repeat(width - vis(l).length) + paint(RAW.green, ' │'));
176
+ console.log(paint(RAW.green, ' └' + '─'.repeat(width + 2) + '┘'));
177
+ }
199
178
 
200
- Supported agents:
201
- ${Object.entries(AGENTS).map(([k, a]) => ` - ${k} (${a.label})`).join('\n')}
179
+ // --- Interactive full-width selector ---------------------------------------
180
+ function interactiveSelect({ title, subtitle, items, multi = true, preselected = [] }) {
181
+ return new Promise((resolve) => {
182
+ const stdin = process.stdin, stdout = process.stdout;
183
+ if (!stdin.isTTY) { resolve(null); return; }
184
+
185
+ let index = 0;
186
+ const selected = new Set();
187
+ if (multi) items.forEach((it, i) => { if (preselected.includes(it.value)) selected.add(i); });
188
+ else { const pi = items.findIndex((it) => preselected.includes(it.value)); if (pi >= 0) index = pi; }
189
+
190
+ const cols = () => stdout.columns || 80;
191
+ let lastLines = 0;
192
+
193
+ const bar = (text) => {
194
+ const w = cols(), label = ` ${text} `;
195
+ const side = Math.max(0, w - label.length), left = Math.floor(side / 2);
196
+ return paint(RAW.cyan, '═'.repeat(left) + label + '═'.repeat(side - left));
197
+ };
198
+
199
+ function render() {
200
+ const lines = ['', bar(title)];
201
+ if (subtitle) lines.push(paint(RAW.dim, ' ' + subtitle));
202
+ lines.push('');
203
+ items.forEach((it, i) => {
204
+ const cur = i === index ? paint(RAW.cyan, '❯') : ' ';
205
+ const mark = multi ? (selected.has(i) ? paint(RAW.green, '◉') : paint(RAW.dim, '◯'))
206
+ : (i === index ? paint(RAW.green, '◉') : paint(RAW.dim, '◯'));
207
+ const label = i === index ? paint(RAW.bold, it.label) : it.label;
208
+ const hint = it.hint ? paint(RAW.dim, ' ' + it.hint) : '';
209
+ lines.push(` ${cur} ${mark} ${label}${hint}`);
210
+ });
211
+ lines.push('');
212
+ lines.push(paint(RAW.dim, ' ' + (multi
213
+ ? '↑/↓ move · space toggle · a all/none · enter confirm · esc cancel'
214
+ : '↑/↓ move · enter confirm · esc cancel')));
215
+ if (lastLines > 0) stdout.write(`\x1b[${lastLines}A`);
216
+ stdout.write('\x1b[0J' + lines.join('\n') + '\n');
217
+ lastLines = lines.length;
218
+ }
202
219
 
203
- Skills:
204
- ${availableSkills().map((s) => ' - ' + s).join('\n') || ' (none found)'}
220
+ function cleanup() {
221
+ try { stdin.setRawMode(false); } catch (_) {}
222
+ stdin.pause();
223
+ stdin.removeListener('data', onData);
224
+ }
225
+ const finish = (r) => { cleanup(); resolve(r); };
226
+
227
+ function onData(s) {
228
+ if (s === '\x03' || s === '\x1b' || s === 'q') return finish(null); // ctrl-c / esc / q
229
+ if (s === '\r' || s === '\n') {
230
+ return finish(multi ? items.filter((_, i) => selected.has(i)).map((it) => it.value) : items[index].value);
231
+ }
232
+ if (s === '\x1b[A' || s === 'k') { index = (index - 1 + items.length) % items.length; return render(); }
233
+ if (s === '\x1b[B' || s === 'j') { index = (index + 1) % items.length; return render(); }
234
+ if (multi && s === ' ') { selected.has(index) ? selected.delete(index) : selected.add(index); return render(); }
235
+ if (multi && (s === 'a' || s === 'A')) {
236
+ if (selected.size === items.length) selected.clear(); else items.forEach((_, i) => selected.add(i));
237
+ return render();
238
+ }
239
+ if (!multi && s === ' ') return finish(items[index].value);
240
+ }
205
241
 
206
- Examples:
207
- npx spirewise install --both
208
- npx spirewise install --project --agent claude,cursor
209
- npx spirewise install f6s-copywriting --global
210
- `);
242
+ stdin.setRawMode(true); stdin.resume(); stdin.setEncoding('utf8');
243
+ stdin.on('data', onData);
244
+ render();
245
+ });
211
246
  }
212
247
 
248
+ // --- args -------------------------------------------------------------------
249
+ function splitList(v) { return String(v).split(',').map((s) => s.trim()).filter(Boolean); }
250
+
213
251
  function parseArgs(argv) {
214
- const o = { scope: null, agents: null, skills: [] };
252
+ const o = { scope: null, agents: null, skills: null, positional: [] };
253
+ const norm = (s) => (s === 'project' || s === 'workspace' || s === 'local') ? 'project' : s;
215
254
  for (let i = 0; i < argv.length; i++) {
216
- const a = argv[i];
217
- if (a === '--project' || a === '--workspace' || a === '--local') o.scope = 'project';
218
- else if (a === '--global') o.scope = 'global';
219
- else if (a === '--both') o.scope = 'both';
220
- else if (a === '--scope') o.scope = (argv[++i] || die('--scope needs a value'));
221
- else if (a === '--agent' || a === '--agents') o.agents = (argv[++i] || die('--agent needs a value')).split(',').map((s) => s.trim()).filter(Boolean);
222
- else if (a === '-h' || a === '--help') { usage(); process.exit(0); }
223
- else if (a.startsWith('-')) die(`Unknown option: ${a}`);
224
- else o.skills.push(a);
255
+ let a = argv[i], val = null;
256
+ if (a.startsWith('--') && a.includes('=')) { const idx = a.indexOf('='); val = a.slice(idx + 1); a = a.slice(0, idx); }
257
+ const next = () => (val !== null ? val : argv[++i]);
258
+ switch (a) {
259
+ case '-s': case '--skills': o.skills = splitList(next() || die('--skills needs a value')); break;
260
+ case '-a': case '--agents': case '--agent': o.agents = splitList(next() || die('--agents needs a value')); break;
261
+ case '-sc': case '--scope': o.scope = norm((next() || die('--scope needs a value')).toLowerCase()); break;
262
+ case '--workspace': case '--project': case '--local': o.scope = 'project'; break;
263
+ case '--global': o.scope = 'global'; break;
264
+ case '--both': o.scope = 'both'; break;
265
+ case '-h': case '--help': usage(); process.exit(0); break;
266
+ default:
267
+ if (a.startsWith('-')) die(`Unknown option: ${a}`);
268
+ o.positional.push(a);
269
+ }
225
270
  }
271
+ if (!o.skills && o.positional.length) o.skills = o.positional;
226
272
  return o;
227
273
  }
228
274
 
229
- function ask(question) {
230
- return new Promise((resolve) => {
231
- const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
232
- rl.question(question, (ans) => { rl.close(); resolve(ans.trim()); });
233
- });
234
- }
275
+ function usage() {
276
+ console.log(`spirewise install copywriting Agent Skills into your AI agents
235
277
 
236
- async function promptScope() {
237
- if (!process.stdin.isTTY) {
238
- warn('No terminal detected; defaulting scope to "project". Use --global or --both to change.');
239
- return 'project';
240
- }
241
- info('Where should the skills be installed?');
242
- console.log(' 1) Workspace (this folder only — the current project directory)');
243
- console.log(' 2) Global (your home folders applies to all projects)');
244
- console.log(' 3) Both');
245
- const ans = (await ask(' Choose 1/2/3 [1]: ')).toLowerCase();
246
- if (ans === '2' || ans === 'global') return 'global';
247
- if (ans === '3' || ans === 'both') return 'both';
248
- return 'project';
278
+ Usage:
279
+ spirewise [install] [options] run interactive picker for anything not set
280
+ spirewise remove [options] uninstall skills (alias: uninstall)
281
+ spirewise list list available skills
282
+ spirewise agents list supported agents + folders
283
+
284
+ Options:
285
+ -s, --skills <a,b> skills to install/remove (default: all / pick)
286
+ -a, --agents <a,b> agents to target (default: all / pick) alias: --agent
287
+ -sc, --scope <s> workspace | global | both (default: pick)
288
+ --workspace | --global | --both scope shortcuts
289
+ -h, --help
290
+
291
+ Agents:
292
+ ${Object.entries(AGENTS).map(([k, a]) => ` - ${k} (${a.label})`).join('\n')}
293
+
294
+ Skills:
295
+ ${availableSkills().map((s) => ' - ' + s).join('\n') || ' (none found)'}
296
+
297
+ Examples:
298
+ npx spirewise # full interactive picker
299
+ npx spirewise -sc both # pick skills+agents, scope=both
300
+ npx spirewise -s f6s-copywriting -a claude,cursor -sc workspace
301
+ npx spirewise remove -sc both # uninstall (pick skills+agents)
302
+ `);
249
303
  }
250
304
 
251
305
  async function main() {
252
306
  let argv = process.argv.slice(2);
253
307
  let command = 'install';
254
- if (['list', 'install', 'agents'].includes(argv[0])) { command = argv[0]; argv = argv.slice(1); }
308
+ if (['list', 'install', 'agents', 'remove', 'uninstall'].includes(argv[0])) { command = argv[0]; argv = argv.slice(1); }
255
309
  else if (argv[0] === '-h' || argv[0] === '--help') { usage(); return; }
256
310
 
311
+ const action = (command === 'remove' || command === 'uninstall') ? 'remove' : 'install';
312
+
257
313
  const available = availableSkills();
258
314
  if (available.length === 0) die('No skills found in package.');
259
315
 
260
316
  if (command === 'list') {
261
- info('Available skills:');
262
- available.forEach((s) => console.log(' - ' + s));
263
- return;
317
+ info('Available skills:'); available.forEach((s) => console.log(' - ' + s)); return;
264
318
  }
265
319
  if (command === 'agents') {
266
320
  info('Supported agents and their folders:');
267
321
  for (const [k, a] of Object.entries(AGENTS)) {
268
- console.log(` ${k} (${a.label}) [${a.format}]`);
269
- console.log(` project: ${a.project}`);
270
- console.log(` global: ${a.global}`);
322
+ console.log(` ${paint(RAW.bold, k)} (${a.label}) [${a.format}]`);
323
+ console.log(` workspace: ${a.project}`);
324
+ console.log(` global: ${a.global}`);
271
325
  }
272
326
  return;
273
327
  }
274
328
 
275
329
  const o = parseArgs(argv);
276
-
330
+ const tty = process.stdin.isTTY;
331
+ const verb = action === 'remove' ? 'remove' : 'install';
332
+ const Verb = action === 'remove' ? 'Remove' : 'Install';
277
333
  banner();
278
334
 
279
- step(1, 'Scanning available skills');
280
- let skills = o.skills.length ? o.skills : available.slice();
281
- for (const s of skills) if (!available.includes(s)) die(`Unknown skill '${s}'. Run "spirewise list".`);
282
- substep(`${skills.length} skill(s): ${skills.join(', ')}`);
283
-
284
- step(2, 'Selecting target agents');
285
- let agentKeys = Object.keys(AGENTS);
286
- if (o.agents) {
287
- for (const k of o.agents) if (!AGENTS[k]) die(`Unknown agent '${k}'. Run "spirewise agents".`);
288
- agentKeys = o.agents;
289
- }
290
- substep(`${agentKeys.length} agent(s): ${agentKeys.join(', ')}`);
291
-
292
- step(3, 'Choosing install scope');
293
- let scope = o.scope || await promptScope();
335
+ // 1) SKILLS
336
+ let skills = o.skills;
337
+ if (skills) {
338
+ for (const s of skills) if (!available.includes(s)) die(`Unknown skill '${s}'. Run "spirewise list".`);
339
+ } else if (tty) {
340
+ skills = await interactiveSelect({
341
+ title: `Step 1/3 · Select skills to ${verb}`,
342
+ subtitle: action === 'remove' ? 'These will be deleted from the chosen agents' : 'Copy templates to install into your agents',
343
+ items: available.map((s) => ({ value: s, label: s, hint: skillHint(s) })),
344
+ multi: true, preselected: available,
345
+ });
346
+ if (skills === null) die('Cancelled.');
347
+ if (!skills.length) die('No skills selected.');
348
+ } else { warn('No terminal; defaulting to all skills.'); skills = available.slice(); }
349
+
350
+ // 2) AGENTS
351
+ let agentKeys = o.agents;
352
+ if (agentKeys) {
353
+ for (const k of agentKeys) if (!AGENTS[k]) die(`Unknown agent '${k}'. Run "spirewise agents".`);
354
+ } else if (tty) {
355
+ agentKeys = await interactiveSelect({
356
+ title: `Step 2/3 · Select agents`,
357
+ subtitle: action === 'remove' ? 'Skills are removed from each agent’s folder' : 'Each agent gets the skills in its own folder + format',
358
+ items: Object.entries(AGENTS).map(([k, a]) => ({ value: k, label: a.label, hint: `(${k}) · ${a.format}` })),
359
+ multi: true, preselected: Object.keys(AGENTS),
360
+ });
361
+ if (agentKeys === null) die('Cancelled.');
362
+ if (!agentKeys.length) die('No agents selected.');
363
+ } else { warn('No terminal; defaulting to all agents.'); agentKeys = Object.keys(AGENTS); }
364
+
365
+ // 3) SCOPE
366
+ let scope = o.scope;
367
+ if (!scope && tty) {
368
+ scope = await interactiveSelect({
369
+ title: 'Step 3/3 · Select scope',
370
+ subtitle: action === 'remove' ? 'Where to remove the skills from?' : 'Where should the skills live?',
371
+ items: [
372
+ { value: 'project', label: 'Workspace', hint: 'this folder only — the current project' },
373
+ { value: 'global', label: 'Global', hint: 'your home folders — applies to all projects' },
374
+ { value: 'both', label: 'Both', hint: 'workspace + global' },
375
+ ],
376
+ multi: false, preselected: ['project'],
377
+ });
378
+ if (scope === null) die('Cancelled.');
379
+ } else if (!scope) { warn('No terminal; defaulting scope to "workspace".'); scope = 'project'; }
294
380
  if (!['project', 'global', 'both'].includes(scope)) die(`Invalid scope '${scope}'.`);
295
381
  const scopes = scope === 'both' ? ['project', 'global'] : [scope];
296
- substep(`scope: ${paint(RAW.bold, scope === 'project' ? 'workspace' : scope)}`);
297
382
 
298
- step(4, 'Installing');
383
+ // ACTION
384
+ console.log('');
385
+ info(`${Verb}ing ${paint(RAW.bold, String(skills.length))} skill(s) ${action === 'remove' ? 'from' : 'into'} ${paint(RAW.bold, String(agentKeys.length))} agent(s) · scope ${paint(RAW.bold, scope === 'project' ? 'workspace' : scope)}`);
386
+
299
387
  let count = 0;
300
- for (const sc of scopes) {
301
- for (const k of agentKeys) { installToAgent(k, AGENTS[k], sc, skills); count += skills.length; }
388
+ for (const sc of scopes) for (const k of agentKeys) {
389
+ if (action === 'remove') count += removeFromAgent(k, AGENTS[k], sc, skills);
390
+ else { installToAgent(k, AGENTS[k], sc, skills); count += skills.length; }
302
391
  }
303
392
 
304
393
  console.log('');
305
- box([
306
- `Installed ${paint(RAW.bold, String(skills.length))} skill(s) into ${paint(RAW.bold, String(agentKeys.length))} agent(s)`,
307
- `scope: ${scopes.map((s) => (s === 'project' ? 'workspace' : s)).join(' + ')} · ${count} item(s) written`,
308
- `next: open your agent and say "write our F6S profile copy"`,
309
- ]);
394
+ if (action === 'remove') {
395
+ box([
396
+ `Removed ${paint(RAW.bold, String(count))} item(s) from ${paint(RAW.bold, String(agentKeys.length))} agent(s)`,
397
+ `scope: ${scopes.map((s) => (s === 'project' ? 'workspace' : s)).join(' + ')}`,
398
+ count === 0 ? `nothing matched — already clean` : `done — skills removed`,
399
+ ]);
400
+ } else {
401
+ box([
402
+ `Installed ${paint(RAW.bold, String(skills.length))} skill(s) into ${paint(RAW.bold, String(agentKeys.length))} agent(s)`,
403
+ `scope: ${scopes.map((s) => (s === 'project' ? 'workspace' : s)).join(' + ')} · ${count} item(s) written`,
404
+ `next: open your agent and say "write our F6S profile copy"`,
405
+ ]);
406
+ }
310
407
  console.log('');
311
408
  }
312
409
 
313
- main().catch((e) => die(e && e.message ? e.message : String(e)));
410
+ if (require.main === module) {
411
+ main().catch((e) => die(e && e.message ? e.message : String(e)));
412
+ } else {
413
+ module.exports = { interactiveSelect, AGENTS, availableSkills, parseArgs };
414
+ }
415
+