@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 +13 -4
- package/README.md +3 -2
- package/package.json +1 -1
- package/src/cli.js +4 -1
- package/src/commands/remove.js +113 -0
- package/src/commands/trigger.js +48 -22
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.
|
|
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
|
|
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.
|
|
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
|
|
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
|
+
}
|
package/src/commands/trigger.js
CHANGED
|
@@ -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
|
-
//
|
|
8
|
-
//
|
|
9
|
-
|
|
10
|
-
|
|
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
|
-
|
|
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
|
|
24
|
-
console.log(c.yellow('
|
|
25
|
-
console.log(
|
|
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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
console.log('
|
|
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
|
+
}
|