@victortomaili/skill-cli 0.1.0 → 0.2.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/CHANGELOG.md CHANGED
@@ -7,13 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
- ### Added
11
- - "Star the repo" call to action in `skill --help` (with the `gh repo star` command) and the README.
12
-
13
10
  ### Planned
14
11
  - Cursor adapter (`.cursor/rules` format) for `init -g` bootstrap.
15
12
  - Per-agent hook adapters for automatic `/X` triggering (push model).
16
13
 
14
+ ## [0.2.0] - 2026-07-07
15
+
16
+ ### Added
17
+ - `skill remove <name>... [-y]` (aliases `rm`, `uninstall`) — removes skill(s) from the store and cleans up the global enabled list + current project allow-list. Interactive `y/N` confirmation on a TTY (default **N** = keep); non-interactive for agents, CI, and pipes (non-TTY), or when `-y`/`--yes`/`-f`/`--force` is passed. Destination names are canonicalized via the store (case-insensitive; an unknown/`../x` name can't reach the store path). Duplicate and case-variant names are de-duplicated (one removal, accurate count).
18
+
19
+ ## [0.1.1] - 2026-07-07
20
+
21
+ ### Fixed
22
+ - `skill trigger <name>` now loads a skill by exact name when no `triggers:` keyword matches. Skills without a `triggers:` field (e.g. description-triggered skills imported from the `skills` / `vercel-labs` ecosystem) were previously unreachable via `trigger`. A passive skill matched by name now shows an enable hint (`skill enable` / `skill cat`) instead of a dead-end "No active skill" message.
23
+
17
24
  ## [0.1.0] - 2026-07-07
18
25
 
19
26
  ### Added
@@ -46,5 +53,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
46
53
  - Windows `npx` spawn runs with `shell:false` + an args array and rejects sources
47
54
  containing shell metacharacters (`& | < > ^`).
48
55
 
49
- [Unreleased]: https://github.com/victortomaili/skill-cli/compare/v0.1.0...HEAD
56
+ [Unreleased]: https://github.com/victortomaili/skill-cli/compare/v0.2.0...HEAD
57
+ [0.2.0]: https://github.com/victortomaili/skill-cli/compare/v0.1.1...v0.2.0
58
+ [0.1.1]: https://github.com/victortomaili/skill-cli/compare/v0.1.0...v0.1.1
50
59
  [0.1.0]: https://github.com/victortomaili/skill-cli/releases/tag/v0.1.0
package/README.md CHANGED
@@ -78,10 +78,11 @@ short bootstrap block telling it to run `skill` on `/X`.
78
78
  | `skill list` | Show installed + active skills (cwd-aware) |
79
79
  | `skill show <name>` | Skill metadata (path, triggers, version) |
80
80
  | `skill cat <name>` | Dump skill content into context |
81
- | `skill trigger <keyword>` | `/X` trigger: single→content, multi→candidates, none→info |
81
+ | `skill trigger <keyword\|name>` | `/X` trigger or skill name content (single), candidates (multi) |
82
82
  | `skill update [name…\|--all]` | Re-fetch from source, update changed skills |
83
+ | `skill remove <name> [-y]` | Remove from store (prompts on TTY; agents / CI / `-y` skip) |
83
84
 
84
- Aliases: `ls` (list), `add` (install), `info` (show).
85
+ Aliases: `ls` (list), `add` (install), `info` (show), `rm`/`uninstall` (remove).
85
86
 
86
87
  ## Install sources
87
88
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@victortomaili/skill-cli",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Cross-agent skill manager — one global store, clean agent folders, activation via skill.config",
5
5
  "license": "MIT",
6
6
  "author": "Victor Tomaili <victor@tomaili.com>",
package/src/cli.js CHANGED
@@ -9,6 +9,7 @@ import { cmdEnable } from './commands/enable.js'
9
9
  import { cmdDisable } from './commands/disable.js'
10
10
  import { cmdInstall } from './commands/install.js'
11
11
  import { cmdUpdate } from './commands/update.js'
12
+ import { cmdRemove } from './commands/remove.js'
12
13
  import { VERSION } from './lib/version.js'
13
14
 
14
15
  const HELP = `${c.bold('skill')} ${c.gray('v' + VERSION)} — cross-agent skill manager
@@ -29,10 +30,11 @@ ${c.bold('Activation')}
29
30
  ${c.bold('Usage (agent)')}
30
31
  ${c.cyan('skill show')} ${c.gray('<name>')} metadata + path + triggers
31
32
  ${c.cyan('skill cat')} ${c.gray('<name>')} dump content to context
32
- ${c.cyan('skill trigger')} ${c.gray('<keyword>')} /X trigger: single→content, multi→candidates
33
+ ${c.cyan('skill trigger')} ${c.gray('<keyword|name>')} /X trigger or skill name content
33
34
 
34
35
  ${c.bold('Maintenance')}
35
36
  ${c.cyan('skill update')} ${c.gray('[name|--all]')} refresh store from source
37
+ ${c.cyan('skill remove')} ${c.gray('<name> [-y]')} remove from store (prompt unless -y / non-TTY)
36
38
 
37
39
  ${c.gray('Source formats (install): owner/repo | github/gitlab URL | git URL | local path | npm package')}
38
40
  ${c.gray('Test (no real ~ touched): SKILL_CLI_HOME=/tmp/sktest skill init -g')}
@@ -58,6 +60,7 @@ try {
58
60
  case 'cat': cmdCat(rest); break
59
61
  case 'trigger': cmdTrigger(rest); break
60
62
  case 'update': cmdUpdate(rest); break
63
+ case 'remove': case 'rm': case 'uninstall': cmdRemove(rest); break
61
64
  case '-v': case '--version':
62
65
  console.log('skill-cli ' + VERSION); break
63
66
  default:
@@ -0,0 +1,113 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+ import c from 'picocolors'
4
+ import { STORE_DIR } from '../lib/paths.js'
5
+ import { listStore } from '../lib/store.js'
6
+ import { readGlobalConfig, writeGlobalConfig, readProjectConfig, writeProjectConfig } from '../lib/config.js'
7
+ import { pad } from '../lib/format.js'
8
+
9
+ // Read one line from stdin (fd 0) synchronously. Only ever called when stdin is
10
+ // a TTY (or SKILL_CLI_FORCE_TTY is set), so it blocks for the user's keystrokes.
11
+ function readLine() {
12
+ let line = ''
13
+ const buf = Buffer.alloc(1)
14
+ while (true) {
15
+ let n
16
+ try { n = fs.readSync(0, buf, 0, 1, null) } catch { return line }
17
+ if (n === 0) break // EOF
18
+ const ch = buf[0]
19
+ if (ch === 0x0A || ch === 0x0D) break // \n or \r — end of line
20
+ if (ch === 0x03) process.exit(130) // Ctrl-C
21
+ line += String.fromCharCode(ch)
22
+ }
23
+ return line.trim()
24
+ }
25
+
26
+ function confirm(question) {
27
+ process.stdout.write(question)
28
+ const a = readLine().toLowerCase()
29
+ return a === 'y' || a === 'yes'
30
+ }
31
+
32
+ // Are we talking to a human at a terminal? Agents, CI, and pipes have a non-TTY
33
+ // stdin and must NOT be prompted (they would hang waiting for input that never
34
+ // arrives). SKILL_CLI_FORCE_TTY lets tests exercise the prompt branch by piping
35
+ // an answer on a non-TTY stdin.
36
+ function isInteractive() {
37
+ return process.stdin.isTTY === true || process.env.SKILL_CLI_FORCE_TTY === '1'
38
+ }
39
+
40
+ // `skill remove <name>... [-y|--yes|-f|--force]`
41
+ //
42
+ // TTY + no --yes → y/N confirmation prompt (default keeps the skill)
43
+ // non-TTY (agent/CI/pipe) OR --yes → remove without prompting
44
+ //
45
+ // The destination name is canonicalized via listStore (case-insensitive, and an
46
+ // unknown/`../x` name can never reach path.join(STORE_DIR, name)). Also drops the
47
+ // skill from the global enabled list and the current project's allow-list; other
48
+ // projects may keep a now-dangling allow entry, but computeEffective filters
49
+ // those out (harmless).
50
+ export function cmdRemove(args) {
51
+ const assumeYes = args.some(a => ['-y', '--yes', '-f', '--force'].includes(a))
52
+ const requested = args.filter(a => !a.startsWith('-'))
53
+
54
+ if (requested.length === 0) {
55
+ console.error(c.red('Usage: skill remove <name>... [-y]'))
56
+ console.error(c.gray(' Removes skill(s) from the store. Agents/CI auto-skip the prompt, or pass -y.'))
57
+ process.exit(1)
58
+ }
59
+
60
+ const installed = listStore()
61
+ const byLower = new Map(installed.map(s => [s.name.toLowerCase(), s.name]))
62
+ const targets = []
63
+ const seen = new Set()
64
+ for (const a of requested) {
65
+ const canon = byLower.get(a.toLowerCase())
66
+ if (!canon) { console.log(c.yellow(' · ' + pad(a)) + c.yellow('not installed — skipping')); continue }
67
+ // dedupe exact + case-variant duplicates (foo, FOO, foo → a single removal,
68
+ // not three, so the count and prompt list stay accurate)
69
+ const key = canon.toLowerCase()
70
+ if (seen.has(key)) continue
71
+ seen.add(key)
72
+ targets.push(canon)
73
+ }
74
+
75
+ if (targets.length === 0) {
76
+ console.log(c.gray('Nothing to remove.'))
77
+ return
78
+ }
79
+
80
+ if (isInteractive() && !assumeYes) {
81
+ const what = targets.length === 1 ? `skill ${c.bold(targets[0])}` : `${targets.length} skills`
82
+ const list = targets.map(t => ' • ' + t).join('\n')
83
+ if (!confirm(c.yellow(`About to remove ${what}:\n${list}\n`) + c.bold('Remove? [y/N] '))) {
84
+ console.log(c.gray('Aborted. Nothing removed.'))
85
+ return
86
+ }
87
+ }
88
+
89
+ let removed = 0
90
+ for (const name of targets) {
91
+ fs.rmSync(path.join(STORE_DIR, name), { recursive: true, force: true })
92
+ cleanConfig(name)
93
+ console.log(c.green(' ✓ ') + c.bold(pad(name)) + c.gray('removed'))
94
+ removed++
95
+ }
96
+ console.log()
97
+ console.log(c.green(`✓ ${removed} skill(s) removed`))
98
+ }
99
+
100
+ // Drop the skill from the global enabled list and the current project allow-list.
101
+ function cleanConfig(name) {
102
+ const g = readGlobalConfig()
103
+ if ((g.enabled_global || []).some(a => a.toLowerCase() === name.toLowerCase())) {
104
+ g.enabled_global = (g.enabled_global || []).filter(a => a.toLowerCase() !== name.toLowerCase())
105
+ writeGlobalConfig(g)
106
+ }
107
+ const cwd = process.cwd()
108
+ const p = readProjectConfig(cwd)
109
+ if (p && (p.allow || []).some(a => a.toLowerCase() === name.toLowerCase())) {
110
+ p.allow = (p.allow || []).filter(a => a.toLowerCase() !== name.toLowerCase())
111
+ writeProjectConfig(cwd, p)
112
+ }
113
+ }
@@ -4,43 +4,69 @@ import { readGlobalConfig, readProjectConfig, computeEffective } from '../lib/co
4
4
  import { normalizeTrigger } from '../lib/frontmatter.js'
5
5
  import { trunc } from '../lib/format.js'
6
6
 
7
- // /X trigger: keyword match across ACTIVE skills.
8
- // single → dump skill content (drops into context)
9
- // multi → candidate list (name + description); agent picks with `skill cat <name>`
10
- // none → informational
7
+ // Dump a single skill's body into context. Shared by the trigger-keyword and
8
+ // exact-name code paths so both render identically.
9
+ function loadSingle(skill, via) {
10
+ const s = readSkill(skill.name)
11
+ if (!s) { console.error(c.red('Skill not readable: ' + skill.name)); process.exit(1) }
12
+ console.log(c.green('▼ skill: ') + c.bold(s.name) + c.gray(' (' + via + ')'))
13
+ console.log(c.gray('━'.repeat(56)))
14
+ console.log(s.body.trim())
15
+ }
16
+
17
+ // `skill trigger <keyword|name>`:
18
+ // 1. keyword matches a trigger across ACTIVE skills
19
+ // single → dump content (drops into context)
20
+ // multi → candidate list (name + description); pick with `skill cat <name>`
21
+ // 2. no trigger match → exact NAME match among active skills. Lets you load
22
+ // description-triggered skills that have no `triggers:` field (e.g. skills
23
+ // imported from the `skills` / vercel-labs ecosystem) by name.
24
+ // 3. name matches only a PASSIVE skill → hint how to activate (or read via cat).
25
+ // 4. nothing → informational.
11
26
  export function cmdTrigger(args) {
12
27
  const keyword = normalizeTrigger(args[0] || '')
13
28
  if (!keyword) {
14
- console.error(c.red('Usage: skill trigger <keyword> (e.g. /research)'))
29
+ console.error(c.red('Usage: skill trigger <keyword|name> (e.g. /research, web-design-guidelines)'))
15
30
  process.exit(1)
16
31
  }
17
32
 
18
33
  const installed = listStore()
19
34
  const effective = computeEffective(installed, readGlobalConfig(), readProjectConfig())
20
- // Search ONLY active skills — a passive skill cannot trigger.
35
+
36
+ // 1) trigger-keyword match across ACTIVE skills — a passive skill cannot trigger.
21
37
  const candidates = installed.filter(s => effective.includes(s.name) && s.triggers.includes(keyword))
22
38
 
23
- if (candidates.length === 0) {
24
- console.log(c.yellow('No active skill has the ') + c.cyan('/' + keyword) + c.yellow(' trigger.'))
25
- console.log(c.gray(' Run skill list to see active skills and their triggers.'))
39
+ if (candidates.length >= 2) {
40
+ console.log(c.yellow('/' + keyword) + ' matched ' + c.bold(candidates.length + ' skills') + ' — pick one:')
41
+ console.log()
42
+ for (const s of candidates) {
43
+ console.log(' ' + c.cyan(s.name.padEnd(20)) + c.gray(' ' + trunc(s.description, 54)))
44
+ }
45
+ console.log()
46
+ console.log(c.gray('Pick: ') + 'skill cat <name>')
26
47
  return
27
48
  }
28
-
29
49
  if (candidates.length === 1) {
30
- const s = readSkill(candidates[0].name)
31
- if (!s) { console.error(c.red('Skill not readable: ' + candidates[0].name)); process.exit(1) }
32
- console.log(c.green('▼ skill: ') + c.bold(s.name) + c.gray(' (triggered: /' + keyword + ')'))
33
- console.log(c.gray('━'.repeat(56)))
34
- console.log(s.body.trim())
50
+ loadSingle(candidates[0], 'triggered: /' + keyword)
51
+ return
52
+ }
53
+
54
+ // 2) no trigger match → exact-name fallback among ACTIVE skills.
55
+ const byName = installed.filter(s => effective.includes(s.name) && s.name.toLowerCase() === keyword)
56
+ if (byName.length === 1) {
57
+ loadSingle(byName[0], 'name: ' + byName[0].name)
35
58
  return
36
59
  }
37
60
 
38
- console.log(c.yellow('/' + keyword) + ' matched ' + c.bold(candidates.length + ' skills') + ' — pick one:')
39
- console.log()
40
- for (const s of candidates) {
41
- console.log(' ' + c.cyan(s.name.padEnd(20)) + c.gray(' ' + trunc(s.description, 54)))
61
+ // 3) name matches only a PASSIVE skill it's installed but not active.
62
+ const passive = installed.find(s => !effective.includes(s.name) && s.name.toLowerCase() === keyword)
63
+ if (passive) {
64
+ console.log(c.yellow('Skill ') + c.bold(passive.name) + c.yellow(' is installed but not active.'))
65
+ console.log(c.gray(' Enable: ') + c.cyan('skill enable [-g] ' + passive.name) + c.gray(' · read now: ') + c.cyan('skill cat ' + passive.name))
66
+ return
42
67
  }
43
- console.log()
44
- console.log(c.gray('Pick: ') + 'skill cat <name>')
45
- }
46
68
 
69
+ // 4) nothing matched.
70
+ console.log(c.yellow('No active skill has the ') + c.cyan('/' + keyword) + c.yellow(' trigger.'))
71
+ console.log(c.gray(' Run skill list to see active skills and their triggers.'))
72
+ }