@victortomaili/skill-cli 0.1.1 → 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/CHANGELOG.md CHANGED
@@ -11,6 +11,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
11
11
  - Cursor adapter (`.cursor/rules` format) for `init -g` bootstrap.
12
12
  - Per-agent hook adapters for automatic `/X` triggering (push model).
13
13
 
14
+ ## [0.3.0] - 2026-07-07
15
+
16
+ ### Added
17
+ - **`skill search` / `skill browse`** — interactive search & multi-install TUI (TTY only). Type a query → browse live `npx skills find` results → mark one or more with `space` → `enter` installs all marked → loops back so you can search again ("research" → pick → "deploy" → pick). `skill install` with no source also opens it on a terminal. Agents/CI (non-TTY) and explicit `skill install <source>` stay fully non-interactive.
18
+ - **`@skill`-pinned sources** (`owner/repo@skill`) now install just that one skill instead of the whole repo (`--skill '*'` is dropped when the source is pinned).
19
+ - **`skill` (no args) / `skill manager` / `skill ui`** — interactive skill-manager TUI (TTY only). Manage every installed skill from the keyboard: ↑↓ navigate · `space` toggle active in the current project · `d` delete (with confirm) · `enter` view the `SKILL.md` · `q` quit. Agents/CI (non-TTY) never enter it; `skill` with no args on a non-TTY still prints help. Toggle follows the allow/deny model (allow wins; a project deny overrides a global enable).
20
+ - **Default skills — auto-load on session start.** New commands: `skill default <name> [-g]` / `skill undefault <name> [-g]` to mark/unmark, and `skill defaults` to list them (this is the command the agent runs on startup). The AGENTS.md bootstrap block now instructs the agent to run `skill defaults` then `skill cat <name>` for each. Defaults are a flag **independent** of enable/disable (a skill can be a default without being active), stored in `skill.config` `defaults:` (project) and `config.yaml` `defaults_global:` (global). Markable from the CLI, or in the manager via the `a` key (★ marker); `skill list` shows ★ too.
21
+
22
+ ### Fixed
23
+ - `skill search` and the manager now exit on `ESC` (previously only Enter / Ctrl-C worked — the prebuilt `@inquirer` prompts ignore ESC). ESC aborts the active prompt via its `AbortSignal`; the terminal/raw-mode is restored on abort. The manager also quits on `ESC` from the main list.
24
+ - **225 tests** (unit + CLI), network-free by default.
25
+
26
+ ## [0.2.0] - 2026-07-07
27
+
28
+ ### Added
29
+ - `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).
30
+
14
31
  ## [0.1.1] - 2026-07-07
15
32
 
16
33
  ### Fixed
@@ -48,6 +65,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
48
65
  - Windows `npx` spawn runs with `shell:false` + an args array and rejects sources
49
66
  containing shell metacharacters (`& | < > ^`).
50
67
 
51
- [Unreleased]: https://github.com/victortomaili/skill-cli/compare/v0.1.1...HEAD
68
+ [Unreleased]: https://github.com/victortomaili/skill-cli/compare/v0.2.0...HEAD
69
+ [0.2.0]: https://github.com/victortomaili/skill-cli/compare/v0.1.1...v0.2.0
52
70
  [0.1.1]: https://github.com/victortomaili/skill-cli/compare/v0.1.0...v0.1.1
53
71
  [0.1.0]: https://github.com/victortomaili/skill-cli/releases/tag/v0.1.0
package/README.md CHANGED
@@ -6,7 +6,7 @@
6
6
  [![npm version](https://img.shields.io/npm/v/@victortomaili/skill-cli.svg)](https://www.npmjs.com/package/@victortomaili/skill-cli)
7
7
  [![license](https://img.shields.io/badge/license-MIT-blue.svg)](./LICENSE)
8
8
  [![node](https://img.shields.io/badge/node-%3E%3D22-green.svg)](https://nodejs.org)
9
- [![tests](https://img.shields.io/badge/tests-157%20passing-brightgreen.svg)](#development)
9
+ [![tests](https://img.shields.io/badge/tests-225%20passing-brightgreen.svg)](#development)
10
10
 
11
11
  **skill-cli** is a cross-agent skill manager for AI coding agents (Claude Code,
12
12
  Codex, Cursor, Gemini, …). Skills live in a single global store — your agent
@@ -18,8 +18,10 @@ config file.
18
18
  - 🎛️ **Three-layer activation** — installed → enabled globally → enabled per project. `allow` always wins over `deny`.
19
19
  - 🔌 **Universal sources** — `owner/repo`, GitHub/GitLab URL, git URL, local path, npm package (via `npx skills`).
20
20
  - ⚡ **Pull-based** — agents pull skill content into context on demand via `skill trigger /X`. No hooks required.
21
+ - 🔍 **Interactive TUIs** — `skill search` to discover & multi-install from the skills registry; `skill` (no args) to manage every installed skill with the keyboard. TTY-only; agents & CI stay non-interactive.
22
+ - ⭐ **Default skills** — mark skills to auto-load on every agent session start.
21
23
  - 🖥️ **Cross-platform** — Windows, macOS, Linux (handles the Windows `npx` spawn quirk for you).
22
- - 🧪 **Well tested** — 157 unit + CLI tests, network-free by default.
24
+ - 🧪 **Well tested** — 225 unit + CLI tests, network-free by default.
23
25
 
24
26
  ## ⭐ Found this useful?
25
27
 
@@ -40,10 +42,11 @@ Requires Node.js 22+.
40
42
  # 1) one-time global setup (creates the store + bootstraps detected agents)
41
43
  skill init -g
42
44
 
43
- # 2) install a skill from any source
45
+ # 2) install a skill from any source (or `skill search` to browse interactively)
44
46
  skill install owner/repo
45
47
 
46
- # 3) use it type /<trigger> in your agent, or load directly
48
+ # 3) manage everything with the keyboard (terminal only)
49
+ skill # ↑↓ move · space toggle · a default · d delete · enter view
47
50
  skill list
48
51
  skill cat <name>
49
52
  ```
@@ -55,9 +58,9 @@ That's it. Your agent now knows: *when the user types `/X`, run `skill trigger X
55
58
  ```
56
59
  ~/.skill-cli/
57
60
  store/<skill>/SKILL.md ← one canonical store (skills live here)
58
- config.yaml ← global config (enabled_global)
61
+ config.yaml ← global config (enabled_global, defaults_global)
59
62
 
60
- <project>/skill.config ← per-project activation (inherit / deny / allow)
63
+ <project>/skill.config ← per-project activation + defaults (inherit / deny / allow / defaults)
61
64
  ~/.claude/CLAUDE.md ← bootstrap block injected by `skill init -g`
62
65
  ~/.codex/AGENTS.md (idempotent — preserves your existing content)
63
66
  ~/.gemini/GEMINI.md
@@ -73,15 +76,21 @@ short bootstrap block telling it to run `skill` on `/X`.
73
76
  | `skill init -g` | Global setup: create store + inject bootstrap into agent files |
74
77
  | `skill init` | Create a `skill.config` for the current project |
75
78
  | `skill install <source>` | Fetch skill(s) to the store (agent dirs untouched) |
79
+ | `skill search` | Interactive search & multi-install from the skills registry (TTY) |
80
+ | `skill` / `skill manager` | Interactive manager: toggle, default, delete, view (TTY) |
76
81
  | `skill enable <name> [-g]` | Enable in project, or globally with `-g` |
77
82
  | `skill disable <name> [-g]` | Disable |
78
- | `skill list` | Show installed + active skills (cwd-aware) |
83
+ | `skill default <name> [-g]` | Mark a skill to auto-load on session start |
84
+ | `skill undefault <name> [-g]` | Remove the auto-load flag |
85
+ | `skill defaults` | List default skills (the command your agent runs on start) |
86
+ | `skill list` | Show installed + active skills (cwd-aware, ★ = default) |
79
87
  | `skill show <name>` | Skill metadata (path, triggers, version) |
80
88
  | `skill cat <name>` | Dump skill content into context |
81
- | `skill trigger <keyword>` | `/X` trigger: single→content, multi→candidates, none→info |
89
+ | `skill trigger <keyword\|name>` | `/X` trigger or skill name content (single), candidates (multi) |
82
90
  | `skill update [name…\|--all]` | Re-fetch from source, update changed skills |
91
+ | `skill remove <name> [-y]` | Remove from store (prompts on TTY; agents / CI / `-y` skip) |
83
92
 
84
- Aliases: `ls` (list), `add` (install), `info` (show).
93
+ Aliases: `ls` (list), `add` (install), `info` (show), `rm`/`uninstall` (remove), `browse` (search), `ui` (manager), `def` (default), `undef` (undefault).
85
94
 
86
95
  ## Install sources
87
96
 
@@ -138,11 +147,47 @@ allow: [react-best-practices] # then open them one by one
138
147
  > `allow` always wins over `deny`, so `deny: ["*"]` + `allow: [X]` = "only X".
139
148
  > Matching is case-insensitive throughout.
140
149
 
150
+ ## Interactive mode (terminal only)
151
+
152
+ When run in a real terminal, skill-cli offers two keyboard UIs. **Agents and CI
153
+ never enter them** (a non-TTY stdin is detected) — they use the plain commands.
154
+
155
+ ### `skill` — manage installed skills
156
+
157
+ Opens a full-screen manager over your store:
158
+
159
+ | Key | Action |
160
+ |---|---|
161
+ | `↑` / `↓` | move |
162
+ | `space` | toggle active in the current project |
163
+ | `a` | toggle the default (auto-load) flag |
164
+ | `d` | delete (asks `y/N`) |
165
+ | `enter` | view the `SKILL.md` |
166
+ | `q` / `esc` | quit |
167
+
168
+ ### `skill search` — discover & install
169
+
170
+ Type a query → browse live results from the [skills registry](https://skills.sh)
171
+ (`npx skills find`) → mark one or more with `space` → `enter` installs all marked
172
+ → loops back so you can search again. `esc` or an empty query quits. `skill install`
173
+ with no source also opens it.
174
+
175
+ ## Default skills (auto-load)
176
+
177
+ A "default" is a skill your agent loads automatically on every session start.
178
+ It's a flag **independent** of enable/disable (a skill can be a default without
179
+ being active). Mark one with `skill default <name>` (or `-g` for every project),
180
+ list them with `skill defaults`. The AGENTS.md bootstrap block tells your agent to
181
+ run `skill defaults` then `skill cat <name>` for each.
182
+
183
+ Defaults live in `skill.config` (`defaults:`) and `config.yaml` (`defaults_global:`).
184
+
141
185
  ## Bootstrap
142
186
 
143
187
  `skill init -g` injects a short, idempotent block into each detected agent's global
144
188
  instruction file (`CLAUDE.md`, `AGENTS.md`, `GEMINI.md`):
145
189
 
190
+ > On session start, run `skill defaults` and load each with `skill cat <name>`.
146
191
  > When the user types `/X`, run `skill trigger X`. Single match → apply the output;
147
192
  > multiple → show candidates; load each skill only once per session.
148
193
 
@@ -166,7 +211,7 @@ git clone https://github.com/victortomaili/skill-cli
166
211
  cd skill-cli
167
212
  npm install
168
213
  npm link # makes `skill` point at your checkout
169
- npm test # 157 tests, network-free (~3s)
214
+ npm test # 225 tests, network-free (~3s)
170
215
  npm run test:e2e # opt-in: real npx fetch over the network
171
216
  ```
172
217
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@victortomaili/skill-cli",
3
- "version": "0.1.1",
3
+ "version": "0.3.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>",
@@ -30,6 +30,8 @@
30
30
  "test:e2e": "node scripts/e2e.mjs"
31
31
  },
32
32
  "dependencies": {
33
+ "@inquirer/core": "^11.2.1",
34
+ "@inquirer/prompts": "^8.5.2",
33
35
  "picocolors": "^1.1.1",
34
36
  "yaml": "^2.6.0"
35
37
  },
package/src/cli.js CHANGED
@@ -9,23 +9,36 @@ 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'
13
+ import { cmdSearch } from './commands/search.js'
14
+ import { cmdManager } from './commands/manager.js'
15
+ import { cmdDefaults, cmdDefault, cmdUndefault } from './commands/defaults.js'
16
+ import { isInteractive } from './lib/interactive.js'
12
17
  import { VERSION } from './lib/version.js'
13
18
 
14
19
  const HELP = `${c.bold('skill')} ${c.gray('v' + VERSION)} — cross-agent skill manager
15
20
  ${c.gray('Single global store + activation (skill.config) + bootstrap (AGENTS.md).')}
16
21
 
22
+ ${c.bold('skill')} ${c.gray('interactive manager (↑↓ space d enter — TTY only)')}
23
+
17
24
  ${c.bold('Setup')}
18
25
  ${c.cyan('skill init -g')} global setup (store + AGENTS.md injection)
19
26
  ${c.cyan('skill init')} create skill.config for this project
20
27
 
21
28
  ${c.bold('Acquire skills')}
22
29
  ${c.cyan('skill install')} ${c.gray('<source>')} fetch to store via npx skills (agent dirs untouched)
30
+ ${c.cyan('skill search')} interactive search & multi-install (TTY only)
23
31
 
24
32
  ${c.bold('Activation')}
25
33
  ${c.cyan('skill enable')} ${c.gray('<name> [-g]')} enable in project, or globally (-g)
26
34
  ${c.cyan('skill disable')} ${c.gray('<name> [-g]')} disable
27
35
  ${c.cyan('skill list')} installed + active skills (cwd-aware)
28
36
 
37
+ ${c.bold('Defaults (auto-load)')}
38
+ ${c.cyan('skill default')} ${c.gray('<name> [-g]')} mark a skill for auto-load on session start
39
+ ${c.cyan('skill undefault')} ${c.gray('<name> [-g]')} remove the default flag
40
+ ${c.cyan('skill defaults')} list default skills (agent runs this on start)
41
+
29
42
  ${c.bold('Usage (agent)')}
30
43
  ${c.cyan('skill show')} ${c.gray('<name>')} metadata + path + triggers
31
44
  ${c.cyan('skill cat')} ${c.gray('<name>')} dump content to context
@@ -33,6 +46,7 @@ ${c.bold('Usage (agent)')}
33
46
 
34
47
  ${c.bold('Maintenance')}
35
48
  ${c.cyan('skill update')} ${c.gray('[name|--all]')} refresh store from source
49
+ ${c.cyan('skill remove')} ${c.gray('<name> [-y]')} remove from store (prompt unless -y / non-TTY)
36
50
 
37
51
  ${c.gray('Source formats (install): owner/repo | github/gitlab URL | git URL | local path | npm package')}
38
52
  ${c.gray('Test (no real ~ touched): SKILL_CLI_HOME=/tmp/sktest skill init -g')}
@@ -44,20 +58,46 @@ ${c.bold('★ Enjoying skill-cli?')} ${c.gray('Star the repo — it helps others
44
58
 
45
59
  const [, , cmd, ...rest] = process.argv
46
60
 
47
- try {
61
+ async function main() {
48
62
  switch (cmd) {
63
+ case 'manager': case 'ui':
64
+ if (!isInteractive()) {
65
+ console.error(c.red("'skill manager' is interactive — run it in a terminal, or use 'skill list'."))
66
+ process.exit(1)
67
+ }
68
+ await cmdManager()
69
+ break
49
70
  case undefined:
71
+ if (isInteractive()) { await cmdManager() }
72
+ else { console.log(HELP) }
73
+ break
50
74
  case '-h': case '--help': case 'help':
51
75
  console.log(HELP); break
52
76
  case 'init': cmdInit(rest); break
53
- case 'install': case 'add': cmdInstall(rest); break
77
+ case 'install': case 'add':
78
+ // No source + a real terminal → interactive search TUI. Agents/CI (non-TTY)
79
+ // and explicit sources stay non-interactive.
80
+ if (rest.length === 0 && isInteractive()) { await cmdSearch(rest) }
81
+ else { cmdInstall(rest) }
82
+ break
83
+ case 'search': case 'browse':
84
+ if (!isInteractive()) {
85
+ console.error(c.red("'skill search' is interactive — run it in a terminal, or use 'skill install <source>'."))
86
+ process.exit(1)
87
+ }
88
+ await cmdSearch(rest)
89
+ break
54
90
  case 'enable': cmdEnable(rest); break
55
91
  case 'disable': cmdDisable(rest); break
56
92
  case 'list': case 'ls': cmdList(rest); break
93
+ case 'defaults': cmdDefaults(rest); break
94
+ case 'default': case 'def': cmdDefault(rest); break
95
+ case 'undefault': case 'undef': cmdUndefault(rest); break
57
96
  case 'show': case 'info': cmdShow(rest); break
58
97
  case 'cat': cmdCat(rest); break
59
98
  case 'trigger': cmdTrigger(rest); break
60
99
  case 'update': cmdUpdate(rest); break
100
+ case 'remove': case 'rm': case 'uninstall': cmdRemove(rest); break
61
101
  case '-v': case '--version':
62
102
  console.log('skill-cli ' + VERSION); break
63
103
  default:
@@ -65,7 +105,9 @@ try {
65
105
  console.error(c.gray(' skill --help'))
66
106
  process.exit(1)
67
107
  }
68
- } catch (e) {
108
+ }
109
+
110
+ main().catch(e => {
69
111
  console.error(c.red('Error: ') + e.message)
70
112
  process.exit(1)
71
- }
113
+ })
@@ -0,0 +1,92 @@
1
+ import c from 'picocolors'
2
+ import { listStore } from '../lib/store.js'
3
+ import { readGlobalConfig, writeGlobalConfig, readProjectConfig, writeProjectConfig, computeDefaults } from '../lib/config.js'
4
+ import { trunc } from '../lib/format.js'
5
+
6
+ // `skill defaults` — list the skills marked as defaults (auto-loaded on agent
7
+ // session start). This is the agent-facing command the AGENTS.md block tells the
8
+ // agent to run, then `skill cat <name>` for each. cwd-aware (global + project).
9
+ export function cmdDefaults() {
10
+ const installed = listStore()
11
+ const globalCfg = readGlobalConfig()
12
+ const projCfg = readProjectConfig()
13
+ const eff = computeDefaults(installed, globalCfg, projCfg)
14
+
15
+ const scope = projCfg ? c.magenta(process.cwd()) : c.gray('(global)')
16
+ console.log(c.bold('skill defaults') + c.gray(' — auto-load on session start · project: ') + scope)
17
+ console.log()
18
+
19
+ if (eff.length === 0) {
20
+ console.log(c.gray(' No default skills yet. Mark with: ') + c.cyan('skill default <name>'))
21
+ return
22
+ }
23
+
24
+ for (const name of eff) {
25
+ const s = installed.find(x => x.name === name)
26
+ const trg = s && s.triggers.length ? ' ' + c.gray('/' + s.triggers.join(', /')) : ''
27
+ console.log(` ${c.yellow('★')} ${c.bold(name.padEnd(22))}${trg}`)
28
+ if (s && s.description) console.log(c.gray(' ' + trunc(s.description, 66)))
29
+ }
30
+ console.log()
31
+ console.log(c.gray('Load each into context: ') + c.cyan('skill cat <name>'))
32
+ }
33
+
34
+ // `skill default <name> [-g]` — mark a skill as a default (auto-load). Requires
35
+ // the skill to be installed (a typo would silently "succeed" but never resolve).
36
+ export function cmdDefault(args) {
37
+ const global = args.includes('-g') || args.includes('--global')
38
+ const name = args.find(a => !a.startsWith('-'))
39
+ if (!name) {
40
+ console.error(c.red('Usage: skill default <name> [-g]'))
41
+ console.error(c.gray(' Marks a skill as a default (auto-loaded on agent session start).'))
42
+ process.exit(1)
43
+ }
44
+ if (!listStore().some(s => s.name.toLowerCase() === name.toLowerCase())) {
45
+ console.error(c.red('Not installed: ' + name))
46
+ console.error(c.gray(' Install first: skill install <source>'))
47
+ process.exit(1)
48
+ }
49
+ if (global) {
50
+ const cfg = readGlobalConfig()
51
+ if (!(cfg.defaults_global || []).some(d => d.toLowerCase() === name.toLowerCase())) {
52
+ cfg.defaults_global.push(name.toLowerCase())
53
+ cfg.defaults_global.sort()
54
+ }
55
+ writeGlobalConfig(cfg)
56
+ console.log(c.green('✓') + ' default globally: ' + c.bold(name) + c.gray(' (auto-load in every project)'))
57
+ } else {
58
+ const cwd = process.cwd()
59
+ const cfg = readProjectConfig(cwd) || { inherit: true, deny: [], allow: [], defaults: [] }
60
+ if (!(cfg.defaults || []).some(d => d.toLowerCase() === name.toLowerCase())) cfg.defaults.push(name.toLowerCase())
61
+ writeProjectConfig(cwd, cfg)
62
+ console.log(c.green('✓') + ' default in project: ' + c.bold(name) + c.gray(' (' + cwd + ')'))
63
+ }
64
+ }
65
+
66
+ // `skill undefault <name> [-g]` — remove the default flag.
67
+ export function cmdUndefault(args) {
68
+ const global = args.includes('-g') || args.includes('--global')
69
+ const name = args.find(a => !a.startsWith('-'))
70
+ if (!name) {
71
+ console.error(c.red('Usage: skill undefault <name> [-g]'))
72
+ process.exit(1)
73
+ }
74
+ if (global) {
75
+ const cfg = readGlobalConfig()
76
+ const had = (cfg.defaults_global || []).some(d => d.toLowerCase() === name.toLowerCase())
77
+ cfg.defaults_global = (cfg.defaults_global || []).filter(d => d.toLowerCase() !== name.toLowerCase())
78
+ writeGlobalConfig(cfg)
79
+ console.log(had
80
+ ? (c.green('✓') + ' removed global default: ' + c.bold(name))
81
+ : (c.gray('·') + ' not a global default: ' + c.bold(name) + c.gray(' (nothing to do)')))
82
+ } else {
83
+ const cwd = process.cwd()
84
+ const cfg = readProjectConfig(cwd) || { inherit: true, deny: [], allow: [], defaults: [] }
85
+ const had = (cfg.defaults || []).some(d => d.toLowerCase() === name.toLowerCase())
86
+ cfg.defaults = (cfg.defaults || []).filter(d => d.toLowerCase() !== name.toLowerCase())
87
+ writeProjectConfig(cwd, cfg)
88
+ console.log(had
89
+ ? (c.green('✓') + ' removed project default: ' + c.bold(name))
90
+ : (c.gray('·') + ' not a project default: ' + c.bold(name) + c.gray(' (nothing to do)')))
91
+ }
92
+ }
@@ -14,17 +14,10 @@ function resolveSource(source) {
14
14
  return source
15
15
  }
16
16
 
17
- // Fetch skill(s) from ANY source `npx skills` understands github owner/repo,
18
- // full GitHub/GitLab URL, git URL, local path, npm package into a TEMP cwd,
19
- // then move the files into our store. Agent folders stay untouched.
20
- export function cmdInstall(args) {
21
- const source = args[0]
22
- if (!source) {
23
- console.error(c.red('Usage: skill install <source>'))
24
- console.error(c.gray(' source: owner/repo | github/gitlab URL | git URL | local path | npm package'))
25
- process.exit(1)
26
- }
27
-
17
+ // Fetch skill(s) from `source` into the store. Throws on failure (does NOT
18
+ // process.exit) so callers the `install` command AND the `search` TUI loop
19
+ // can decide how to handle errors. Returns the list of moved skill names.
20
+ export function installSource(source) {
28
21
  const resolved = resolveSource(source)
29
22
  console.log(c.cyan('Fetching: ') + resolved)
30
23
  console.log(c.gray(' via npx skills add (temp cwd; agent folders untouched)'))
@@ -33,9 +26,7 @@ export function cmdInstall(args) {
33
26
  try {
34
27
  ({ tmp, fetchedDir } = fetchSkillsToTemp(resolved))
35
28
  } catch (e) {
36
- console.error(c.red(e.message))
37
- console.error(c.gray('Check the source (owner/repo, URL, path, npm package).'))
38
- process.exit(1)
29
+ throw new Error(e.message)
39
30
  }
40
31
 
41
32
  try {
@@ -84,7 +75,27 @@ export function cmdInstall(args) {
84
75
  }
85
76
  console.log()
86
77
  console.log(c.gray('Skills are passive until enabled. Activate with: ') + c.cyan('skill enable <name>') + c.gray(' or ') + c.cyan('-g'))
78
+ return moved
87
79
  } finally {
88
80
  if (tmp) fs.rmSync(tmp, { recursive: true, force: true })
89
81
  }
90
82
  }
83
+
84
+ // `skill install <source>` — non-interactive. The TTY/no-args route to the search
85
+ // TUI is handled in cli.js (avoids a circular import with search.js).
86
+ export function cmdInstall(args) {
87
+ const source = args[0]
88
+ if (!source) {
89
+ console.error(c.red('Usage: skill install <source>'))
90
+ console.error(c.gray(' source: owner/repo | owner/repo@skill | github/gitlab URL | git URL | local path | npm package'))
91
+ console.error(c.gray(' (in a terminal with no source → interactive search: skill search)'))
92
+ process.exit(1)
93
+ }
94
+ try {
95
+ installSource(source)
96
+ } catch (e) {
97
+ console.error(c.red(e.message))
98
+ console.error(c.gray('Check the source (owner/repo, URL, path, npm package).'))
99
+ process.exit(1)
100
+ }
101
+ }
@@ -1,6 +1,6 @@
1
1
  import c from 'picocolors'
2
2
  import { listStore } from '../lib/store.js'
3
- import { readGlobalConfig, readProjectConfig, computeEffective } from '../lib/config.js'
3
+ import { readGlobalConfig, readProjectConfig, computeEffective, computeDefaults } from '../lib/config.js'
4
4
  import { trunc } from '../lib/format.js'
5
5
 
6
6
  export function cmdList(_args = []) {
@@ -8,6 +8,7 @@ export function cmdList(_args = []) {
8
8
  const globalCfg = readGlobalConfig()
9
9
  const projCfg = readProjectConfig()
10
10
  const effective = computeEffective(installed, globalCfg, projCfg)
11
+ const defaults = computeDefaults(installed, globalCfg, projCfg)
11
12
 
12
13
  const scope = projCfg ? c.magenta(process.cwd()) : c.gray('(global)')
13
14
  console.log(c.bold('skill list') + c.gray(' — project: ') + scope)
@@ -21,13 +22,14 @@ export function cmdList(_args = []) {
21
22
  for (const s of installed) {
22
23
  const active = effective.includes(s.name)
23
24
  const mark = active ? c.green('●') : c.gray('○')
25
+ const star = defaults.includes(s.name) ? c.yellow('★') : c.gray('·')
24
26
  const sc = labelScope(s.name, globalCfg, projCfg)
25
27
  const trg = s.triggers.length ? ' ' + c.gray('/' + s.triggers.join(', /')) : ''
26
- console.log(` ${mark} ${c.bold(s.name.padEnd(22))} ${c.gray(String(s.version).padEnd(8))} ${sc}${trg}`)
28
+ console.log(` ${mark} ${star} ${c.bold(s.name.padEnd(22))} ${c.gray(String(s.version).padEnd(8))} ${sc}${trg}`)
27
29
  if (s.description) console.log(c.gray(' ' + trunc(s.description, 68)))
28
30
  }
29
31
  console.log()
30
- console.log(c.green('● active ') + c.gray('○ passive (installed but not enabled in this project)'))
32
+ console.log(c.green('● active ') + c.gray('○ passive ') + c.yellow('★ default (auto-load on session start)'))
31
33
  console.log(c.cyan('Details: ') + 'skill show <name> ' + c.cyan('Load: ') + 'skill cat <name>')
32
34
  }
33
35
 
@@ -0,0 +1,191 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+ import { createPrompt, useState, useKeypress, usePrefix, isEnterKey, isUpKey, isDownKey } from '@inquirer/core'
4
+ import c from 'picocolors'
5
+ import { STORE_DIR } from '../lib/paths.js'
6
+ import { listStore } from '../lib/store.js'
7
+ import { readGlobalConfig, readProjectConfig, writeProjectConfig, computeEffective, computeDefaults } from '../lib/config.js'
8
+ import { cleanConfig } from './remove.js'
9
+ import { trunc } from '../lib/format.js'
10
+
11
+ const KEYS = c.gray('↑↓ move · space toggle · a default · d delete · enter view · q/esc quit')
12
+
13
+ // where a skill's activation currently comes from (mirrors `skill list`)
14
+ function labelScope(name, g, p) {
15
+ if (p && (p.allow || []).some(a => a.toLowerCase() === name.toLowerCase())) return c.magenta('project')
16
+ if ((g.enabled_global || []).some(a => a.toLowerCase() === name.toLowerCase())) return c.blue('global ')
17
+ return c.gray('- ')
18
+ }
19
+
20
+ // Pure decision: given the current configs, compute the new {allow, deny} arrays
21
+ // that flip `name`'s active state in THIS project. No I/O → unit-testable without
22
+ // SKILL_CLI_HOME. enabled_global is untouched (the manager toggle is project-scoped).
23
+ // passive → add to project allow (active here)
24
+ // active via project allow → remove from allow
25
+ // active via global only → add a local deny so it goes passive HERE
26
+ export function computeToggle(installed, globalCfg, projCfg, name) {
27
+ const lc = String(name).toLowerCase()
28
+ const p = {
29
+ inherit: projCfg?.inherit !== false,
30
+ deny: [...(projCfg?.deny || [])],
31
+ allow: [...(projCfg?.allow || [])],
32
+ }
33
+ const active = computeEffective(installed, globalCfg, p).includes(name)
34
+ const isGlobal = (globalCfg.enabled_global || []).some(a => String(a).toLowerCase() === lc)
35
+ const isProjectAllow = p.allow.some(a => String(a).toLowerCase() === lc)
36
+ if (active) {
37
+ p.allow = p.allow.filter(a => String(a).toLowerCase() !== lc)
38
+ if (isGlobal && !isProjectAllow) {
39
+ if (!p.deny.some(d => String(d).toLowerCase() === lc)) p.deny.push(lc)
40
+ }
41
+ } else {
42
+ p.deny = p.deny.filter(d => String(d).toLowerCase() !== lc)
43
+ if (!p.allow.some(a => String(a).toLowerCase() === lc)) p.allow.push(lc)
44
+ }
45
+ return { allow: p.allow, deny: p.deny }
46
+ }
47
+
48
+ // Apply computeToggle's decision to disk and return a status line for the UI.
49
+ export function toggleActive(name, cwd = process.cwd()) {
50
+ const installed = listStore()
51
+ const g = readGlobalConfig()
52
+ const p = readProjectConfig(cwd) || { inherit: true, deny: [], allow: [] }
53
+ const wasActive = computeEffective(installed, g, p).includes(name)
54
+ const next = computeToggle(installed, g, p, name)
55
+ writeProjectConfig(cwd, { ...p, ...next })
56
+ return wasActive ? (c.gray('· disabled in project: ') + name) : (c.green('✓ enabled in project: ') + name)
57
+ }
58
+
59
+ function removeOne(name) {
60
+ fs.rmSync(path.join(STORE_DIR, name), { recursive: true, force: true })
61
+ cleanConfig(name)
62
+ }
63
+
64
+ // Bounded view of SKILL.md — the full file can be large and inquirer does not scroll.
65
+ function viewBody(s) {
66
+ const file = path.join(STORE_DIR, s.name, 'SKILL.md')
67
+ let content
68
+ try { content = fs.readFileSync(file, 'utf8') } catch { return c.gray(' (no SKILL.md)') }
69
+ const lines = content.split(/\r?\n/)
70
+ const max = 22
71
+ const shown = lines.slice(0, max).map(l => (l ? ' ' + l : ''))
72
+ if (lines.length > max) shown.push(c.gray(' … ' + (lines.length - max) + ' more lines — `skill cat ' + s.name + '`'))
73
+ return shown.join('\n')
74
+ }
75
+
76
+ // Pure decision: the new project `defaults` array after toggling `name`'s default
77
+ // flag. (The manager `a` key is project-scoped; global defaults use the CLI.)
78
+ export function computeToggleDefault(projCfg, name) {
79
+ const lc = String(name).toLowerCase()
80
+ const defs = [...(projCfg?.defaults || [])]
81
+ return defs.some(d => String(d).toLowerCase() === lc)
82
+ ? defs.filter(d => String(d).toLowerCase() !== lc)
83
+ : [...defs, lc]
84
+ }
85
+
86
+ // Apply the default toggle to disk and return a status line for the UI.
87
+ function toggleDefault(name, cwd = process.cwd()) {
88
+ const installed = listStore()
89
+ const g = readGlobalConfig()
90
+ const p = readProjectConfig(cwd) || { inherit: true, deny: [], allow: [], defaults: [] }
91
+ const wasDefault = computeDefaults(installed, g, p).includes(name)
92
+ const next = computeToggleDefault(p, name)
93
+ writeProjectConfig(cwd, { ...p, defaults: next })
94
+ return wasDefault ? (c.gray('· un-defaulted: ') + name) : (c.yellow('★ default: ') + name + c.gray(' (auto-load)'))
95
+ }
96
+
97
+ // Clamp a cursor move so it can't leave the list bounds (pure → unit-tested).
98
+ export function moveCursor(cur, length, delta) {
99
+ if (length <= 0) return 0
100
+ return Math.max(0, Math.min(length - 1, cur + delta))
101
+ }
102
+
103
+ // One custom prompt owns the whole keyboard loop (select/checkbox can't do `d`/view).
104
+ // ↑↓ move · space toggle active in this project · d delete (confirm) ·
105
+ // enter view the SKILL.md · q quit. Re-reads the store/config every render so
106
+ // toggles & deletes are reflected immediately.
107
+ const managerPrompt = createPrompt((_config, done) => {
108
+ const [cursor, setCursor] = useState(0)
109
+ const [mode, setMode] = useState('list') // 'list' | 'confirm' | 'view'
110
+ const [tick, setTick] = useState(0) // bump (a VALUE, not a fn) to force a re-render
111
+ const [status, setStatus] = useState('')
112
+ const prefix = usePrefix({ theme: { prefix: { idle: c.cyan('?'), done: c.cyan('✓') } } })
113
+
114
+ const installed = listStore()
115
+ const g = readGlobalConfig()
116
+ const p = readProjectConfig()
117
+ const eff = computeEffective(installed, g, p)
118
+ const defs = computeDefaults(installed, g, p)
119
+ const cur = installed.length ? Math.max(0, Math.min(cursor, installed.length - 1)) : 0
120
+ const s = installed[cur]
121
+
122
+ useKeypress((key) => {
123
+ if (mode === 'view') { setMode('list'); return } // any key → back to list
124
+ if (mode === 'confirm') {
125
+ if (key.name === 'y') {
126
+ if (s) { removeOne(s.name); setStatus(c.red('✓ removed ') + s.name) }
127
+ setMode('list'); setTick(tick + 1)
128
+ } else if (key.name === 'escape' || key.name === 'n' || (key.ctrl && key.name === 'c')) {
129
+ setMode('list'); setStatus(c.gray('cancelled'))
130
+ }
131
+ return
132
+ }
133
+ // list mode
134
+ // NB: @inquirer/core's useState setter takes a VALUE (there is NO functional
135
+ // updater like React's). We read the clamped `cur` from THIS render's closure —
136
+ // useKeypress keeps the handler fresh on every render, so it's always current.
137
+ if (isUpKey(key)) { setCursor(moveCursor(cur, installed.length, -1)); setStatus('') }
138
+ else if (isDownKey(key)) { setCursor(moveCursor(cur, installed.length, 1)); setStatus('') }
139
+ else if (key.name === 'space') { if (s) { setStatus(toggleActive(s.name)); setTick(tick + 1) } }
140
+ else if (key.name === 'a') { if (s) { setStatus(toggleDefault(s.name)); setTick(tick + 1) } }
141
+ else if (key.name === 'd') { if (s) { setMode('confirm'); setStatus('') } }
142
+ else if (isEnterKey(key)) { if (s) { setMode('view'); setStatus('') } }
143
+ else if (key.name === 'q' || key.name === 'escape' || (key.ctrl && key.name === 'c')) done()
144
+ })
145
+
146
+ // ── render ──
147
+ if (mode === 'view' && s) {
148
+ return prefix + ' ' + c.bold(s.name) + c.gray(' (any key to go back)') + '\n' + viewBody(s)
149
+ }
150
+ const lines = [prefix + ' ' + c.bold('skill manager') + c.gray(' — ' + installed.length + ' installed · ' + eff.length + ' active')]
151
+ for (let i = 0; i < installed.length; i++) {
152
+ const sk = installed[i]
153
+ const active = eff.includes(sk.name)
154
+ const arrow = i === cur ? c.cyan('❯') : ' '
155
+ const mark = active ? c.green('●') : c.gray('○')
156
+ const star = defs.includes(sk.name) ? c.yellow('★') : c.gray('·')
157
+ const sc = labelScope(sk.name, g, p)
158
+ const trg = sk.triggers.length ? ' ' + c.gray('/' + sk.triggers.join(', /')) : ''
159
+ const nameStr = sk.name.padEnd(24)
160
+ const nameCol = i === cur ? c.bold(nameStr) : nameStr
161
+ lines.push(`${arrow} ${mark} ${star} ${nameCol} ${c.gray(String(sk.version || '').padEnd(8))} ${sc}${trg}`)
162
+ if (i === cur && sk.description) lines.push(c.gray(' ' + trunc(sk.description, 62)))
163
+ }
164
+ lines.push(c.gray(' ' + '─'.repeat(58)))
165
+ lines.push(' ' + KEYS)
166
+ if (mode === 'confirm') {
167
+ lines.push('')
168
+ lines.push(' ' + c.yellow('Delete ' + c.bold(s ? s.name : '') + '? [y/N] ') + c.gray('(N keeps it)'))
169
+ } else if (status) {
170
+ lines.push(' ' + status)
171
+ }
172
+ return lines.join('\n')
173
+ })
174
+
175
+ // `skill` (no args) / `skill manager` / `skill ui` on a terminal. Agents/CI never
176
+ // reach here (cli.js gates on isInteractive).
177
+ export async function cmdManager() {
178
+ const installed = listStore()
179
+ if (installed.length === 0) {
180
+ console.log(c.yellow('Store empty.') + c.gray(' Install a skill first: ') + c.cyan('skill install <source>'))
181
+ return
182
+ }
183
+ try {
184
+ await managerPrompt({})
185
+ } catch { /* Ctrl-C / abort — fall through to the footer */ }
186
+ const g = readGlobalConfig()
187
+ const p = readProjectConfig()
188
+ const eff = computeEffective(listStore(), g, p)
189
+ console.log()
190
+ console.log(c.gray('Closed. ' + listStore().length + ' installed · ' + eff.length + ' active in ' + process.cwd()))
191
+ }
@@ -0,0 +1,107 @@
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
+ import { isInteractive } from '../lib/interactive.js'
9
+
10
+ // Read one line from stdin (fd 0) synchronously. Only ever called when stdin is
11
+ // a TTY (or SKILL_CLI_FORCE_TTY is set), so it blocks for the user's keystrokes.
12
+ function readLine() {
13
+ let line = ''
14
+ const buf = Buffer.alloc(1)
15
+ while (true) {
16
+ let n
17
+ try { n = fs.readSync(0, buf, 0, 1, null) } catch { return line }
18
+ if (n === 0) break // EOF
19
+ const ch = buf[0]
20
+ if (ch === 0x0A || ch === 0x0D) break // \n or \r — end of line
21
+ if (ch === 0x03) process.exit(130) // Ctrl-C
22
+ line += String.fromCharCode(ch)
23
+ }
24
+ return line.trim()
25
+ }
26
+
27
+ function confirm(question) {
28
+ process.stdout.write(question)
29
+ const a = readLine().toLowerCase()
30
+ return a === 'y' || a === 'yes'
31
+ }
32
+
33
+ // `skill remove <name>... [-y|--yes|-f|--force]`
34
+ //
35
+ // TTY + no --yes → y/N confirmation prompt (default keeps the skill)
36
+ // non-TTY (agent/CI/pipe) OR --yes → remove without prompting
37
+ //
38
+ // The destination name is canonicalized via listStore (case-insensitive, and an
39
+ // unknown/`../x` name can never reach path.join(STORE_DIR, name)). Also drops the
40
+ // skill from the global enabled list and the current project's allow-list; other
41
+ // projects may keep a now-dangling allow entry, but computeEffective filters
42
+ // those out (harmless).
43
+ export function cmdRemove(args) {
44
+ const assumeYes = args.some(a => ['-y', '--yes', '-f', '--force'].includes(a))
45
+ const requested = args.filter(a => !a.startsWith('-'))
46
+
47
+ if (requested.length === 0) {
48
+ console.error(c.red('Usage: skill remove <name>... [-y]'))
49
+ console.error(c.gray(' Removes skill(s) from the store. Agents/CI auto-skip the prompt, or pass -y.'))
50
+ process.exit(1)
51
+ }
52
+
53
+ const installed = listStore()
54
+ const byLower = new Map(installed.map(s => [s.name.toLowerCase(), s.name]))
55
+ const targets = []
56
+ const seen = new Set()
57
+ for (const a of requested) {
58
+ const canon = byLower.get(a.toLowerCase())
59
+ if (!canon) { console.log(c.yellow(' · ' + pad(a)) + c.yellow('not installed — skipping')); continue }
60
+ // dedupe exact + case-variant duplicates (foo, FOO, foo → a single removal,
61
+ // not three, so the count and prompt list stay accurate)
62
+ const key = canon.toLowerCase()
63
+ if (seen.has(key)) continue
64
+ seen.add(key)
65
+ targets.push(canon)
66
+ }
67
+
68
+ if (targets.length === 0) {
69
+ console.log(c.gray('Nothing to remove.'))
70
+ return
71
+ }
72
+
73
+ if (isInteractive() && !assumeYes) {
74
+ const what = targets.length === 1 ? `skill ${c.bold(targets[0])}` : `${targets.length} skills`
75
+ const list = targets.map(t => ' • ' + t).join('\n')
76
+ if (!confirm(c.yellow(`About to remove ${what}:\n${list}\n`) + c.bold('Remove? [y/N] '))) {
77
+ console.log(c.gray('Aborted. Nothing removed.'))
78
+ return
79
+ }
80
+ }
81
+
82
+ let removed = 0
83
+ for (const name of targets) {
84
+ fs.rmSync(path.join(STORE_DIR, name), { recursive: true, force: true })
85
+ cleanConfig(name)
86
+ console.log(c.green(' ✓ ') + c.bold(pad(name)) + c.gray('removed'))
87
+ removed++
88
+ }
89
+ console.log()
90
+ console.log(c.green(`✓ ${removed} skill(s) removed`))
91
+ }
92
+
93
+ // Drop the skill from the global enabled list and the current project allow-list.
94
+ // Exported so the interactive manager can reuse it on its `d` (delete) action.
95
+ export function cleanConfig(name) {
96
+ const g = readGlobalConfig()
97
+ if ((g.enabled_global || []).some(a => a.toLowerCase() === name.toLowerCase())) {
98
+ g.enabled_global = (g.enabled_global || []).filter(a => a.toLowerCase() !== name.toLowerCase())
99
+ writeGlobalConfig(g)
100
+ }
101
+ const cwd = process.cwd()
102
+ const p = readProjectConfig(cwd)
103
+ if (p && (p.allow || []).some(a => a.toLowerCase() === name.toLowerCase())) {
104
+ p.allow = (p.allow || []).filter(a => a.toLowerCase() !== name.toLowerCase())
105
+ writeProjectConfig(cwd, p)
106
+ }
107
+ }
@@ -0,0 +1,92 @@
1
+ import { input, checkbox } from '@inquirer/prompts'
2
+ import c from 'picocolors'
3
+ import { searchSkills } from '../lib/skills-find.js'
4
+ import { installSource } from './install.js'
5
+
6
+ // Run an @inquirer prompt that ALSO exits on ESC. The prebuilt input/checkbox
7
+ // prompts only respond to Enter / Ctrl-C; we wire ESC to abort via the `signal`
8
+ // (passed as the prompt's 2nd context arg → createPrompt's abort path, which
9
+ // rejects with AbortPromptError). Ctrl-C still works (ExitPromptError). Tested
10
+ // without a TTY by pre-aborting the signal (see test/cli/search.test.mjs).
11
+ //
12
+ // During a prompt createPrompt's readline (terminal:true) emits `keypress` on
13
+ // process.stdin, so our listener receives the ESC and aborts. Exported so the
14
+ // error-handling path is unit-testable.
15
+ export async function withEscExit(run) {
16
+ const controller = new AbortController()
17
+ const onKey = (_ch, key) => { if (key && key.name === 'escape') controller.abort() }
18
+ process.stdin.on('keypress', onKey)
19
+ try {
20
+ return { aborted: false, value: await run(controller.signal) }
21
+ } catch (e) {
22
+ if (e && /^(Abort|Exit|Cancel)PromptError$/.test(e.name)) return { aborted: true }
23
+ throw e
24
+ } finally {
25
+ process.stdin.removeListener('keypress', onKey)
26
+ }
27
+ }
28
+
29
+ // Interactive search & multi-install (TTY only — cli.js gates on isInteractive).
30
+ //
31
+ // type a query → `npx skills find <query>` → multi-select (space to mark,
32
+ // enter to install all marked) → loops back so you can search again.
33
+ // ESC (or an empty query) at any prompt quits.
34
+ //
35
+ // Non-interactive (agents/CI) never reaches here; they use `skill install <source>`.
36
+ export async function cmdSearch() {
37
+ console.log(c.bold('skill search') + c.gray(' — discover & install from the skills ecosystem (skills.sh)'))
38
+ console.log(c.gray(' type a query · space to mark · enter to install marked · esc or empty query to quit'))
39
+ console.log()
40
+
41
+ let totalInstalled = 0
42
+ while (true) {
43
+ const q = await withEscExit(signal => input({ message: 'Search skills:', default: '' }, { signal }))
44
+ if (q.aborted) break // ESC / Ctrl-C → exit
45
+ const query = (q.value || '').trim()
46
+ if (!query) break // empty Enter → exit
47
+
48
+ let results
49
+ try {
50
+ results = searchSkills(query)
51
+ } catch (e) {
52
+ console.error(c.red(' ' + e.message))
53
+ continue
54
+ }
55
+ if (results.length === 0) {
56
+ console.log(c.gray(' no skills matched "' + query + '".'))
57
+ continue
58
+ }
59
+
60
+ const choices = results.map(r => ({
61
+ name: `${r.skill} ${c.gray(r.owner + '/' + r.repo)} ${c.gray('(' + (r.installs || '?') + ' installs)')}`,
62
+ value: r.source,
63
+ short: r.skill,
64
+ }))
65
+
66
+ const sel = await withEscExit(signal => checkbox({
67
+ message: `Results for "${query}" — space to mark, enter to install (esc to quit):`,
68
+ choices,
69
+ pageSize: 12,
70
+ }, { signal }))
71
+ if (sel.aborted) break // ESC / Ctrl-C → exit
72
+ const selected = sel.value || []
73
+ if (selected.length === 0) {
74
+ console.log(c.gray(' nothing marked — search again, or esc / empty query to quit.'))
75
+ continue
76
+ }
77
+
78
+ for (const src of selected) {
79
+ console.log()
80
+ try {
81
+ installSource(src)
82
+ totalInstalled++
83
+ } catch (e) {
84
+ console.error(c.red(' install failed for ' + src + ': ' + (e.message || e)))
85
+ }
86
+ }
87
+ console.log()
88
+ console.log(c.green(`✓ ${selected.length} selected this round.`) + c.gray(' Search again, or esc / empty query to finish.'))
89
+ console.log()
90
+ }
91
+ console.log(c.gray(`\nDone. ${totalInstalled} skill(s) installed this session.`))
92
+ }
@@ -19,6 +19,10 @@ Usage:
19
19
  - \`skill show <name>\` — skill summary (path, triggers, version)
20
20
  - \`skill cat <name>\` — load skill content into context
21
21
 
22
+ Defaults: on session start, run \`skill defaults\` to list skills marked for
23
+ auto-load, then \`skill cat <name>\` for each. Mark your own with
24
+ \`skill default <name>\` (or \`-g\` for every project).
25
+
22
26
  Triggers: when the user types \`/X\`, run \`skill trigger X\`.
23
27
  - Single match → apply the output directly.
24
28
  - Multiple matches → show the candidate list; load the right one with \`skill cat <name>\`.
package/src/lib/config.js CHANGED
@@ -7,6 +7,7 @@ const DEFAULT_GLOBAL = {
7
7
  version: 1,
8
8
  store: STORE_DIR,
9
9
  enabled_global: [],
10
+ defaults_global: [],
10
11
  }
11
12
 
12
13
  export function readGlobalConfig() {
@@ -29,6 +30,7 @@ export function writeGlobalConfig(cfg) {
29
30
  version: cfg.version ?? 1,
30
31
  store: cfg.store ?? STORE_DIR,
31
32
  enabled_global: cfg.enabled_global || [],
33
+ defaults_global: cfg.defaults_global || [],
32
34
  }
33
35
  fs.writeFileSync(GLOBAL_CONFIG, yaml.stringify(out), 'utf8')
34
36
  }
@@ -47,7 +49,7 @@ export function readProjectConfig(cwd = process.cwd()) {
47
49
  process.stderr.write('skill.config: parse error (' + (e.message || e) + ') — using global behavior\n')
48
50
  return null
49
51
  }
50
- return { inherit: true, deny: [], allow: [], ...(parsed || {}) }
52
+ return { inherit: true, deny: [], allow: [], defaults: [], ...(parsed || {}) }
51
53
  }
52
54
 
53
55
  export function writeProjectConfig(cwd, cfg) {
@@ -56,6 +58,7 @@ export function writeProjectConfig(cwd, cfg) {
56
58
  inherit: cfg.inherit !== false,
57
59
  deny: cfg.deny || [],
58
60
  allow: cfg.allow || [],
61
+ defaults: cfg.defaults || [],
59
62
  }
60
63
  fs.writeFileSync(projectConfigPath(cwd), yaml.stringify(out), 'utf8')
61
64
  }
@@ -98,3 +101,17 @@ export function computeEffective(installed, globalCfg, projCfg) {
98
101
  .map(n => canonByLower.get(n))
99
102
  .sort()
100
103
  }
104
+
105
+ // Effective DEFAULT skills (auto-loaded on agent session start), as CANONICAL
106
+ // names. Independent of enable/disable: a skill may be active-but-not-default or
107
+ // default-but-not-active. Inherits global defaults unless the project sets
108
+ // inherit:false. Case-insensitive throughout.
109
+ export function computeDefaults(installed, globalCfg, projCfg) {
110
+ const canonByLower = new Map(installed.map(s => [String(s.name).toLowerCase(), s.name]))
111
+ const set = new Set()
112
+ if (!projCfg || projCfg.inherit !== false) {
113
+ for (const d of (globalCfg.defaults_global || [])) set.add(String(d).toLowerCase())
114
+ }
115
+ if (projCfg) for (const d of (projCfg.defaults || [])) set.add(String(d).toLowerCase())
116
+ return [...set].filter(n => canonByLower.has(n)).map(n => canonByLower.get(n)).sort()
117
+ }
@@ -0,0 +1,8 @@
1
+ // Are we talking to a human at a terminal? Agents, CI, and pipes have a non-TTY
2
+ // stdin and must NOT be shown an interactive prompt (they would hang waiting for
3
+ // input that never arrives). Shared by `remove` (y/N confirm) and the TUI commands
4
+ // (`search`, and the forthcoming manager). SKILL_CLI_FORCE_TTY lets tests exercise
5
+ // the prompt branch on a piped stdin.
6
+ export function isInteractive() {
7
+ return process.stdin.isTTY === true || process.env.SKILL_CLI_FORCE_TTY === '1'
8
+ }
package/src/lib/npx.js CHANGED
@@ -22,8 +22,26 @@ import os from 'node:os'
22
22
  // as a single argv element — no shell injection — and `--skill *` stays literal
23
23
  // (cmd.exe does no globbing and no POSIX shell is ever involved). npx must be on
24
24
  // PATH; it ships with node/npm on every platform.
25
+ // Detect a `@skill` pin on the source (skills ecosystem: owner/repo@skill or a
26
+ // URL@skill). SSH git URLs (`git@host:owner/repo`) start with git@ and are NOT a
27
+ // pin. When the source is pinned we must NOT pass `--skill '*'` — that flag
28
+ // overrides the pin and fetches every skill in the repo (verified), so a search
29
+ // result like owner/repo@research would otherwise dump the whole repo.
30
+ export function skillPin(source) {
31
+ if (typeof source !== 'string') return null
32
+ if (source.startsWith('git@')) return null
33
+ const at = source.lastIndexOf('@')
34
+ if (at <= 0) return null
35
+ const skill = source.slice(at + 1)
36
+ if (!skill || skill.includes('/') || skill.includes(':')) return null
37
+ return skill
38
+ }
39
+
25
40
  export function buildNpxSpawn(source, platform = process.platform) {
26
- const base = ['-y', 'skills', 'add', source, '--copy', '--agent', 'claude-code', '--skill', '*', '-y']
41
+ // pinned source (owner/repo@skill) let the @pin select the skill (passing
42
+ // --skill '*' here would override it and grab the whole repo). Otherwise fetch all.
43
+ const skillArgs = skillPin(source) ? [] : ['--skill', '*']
44
+ const base = ['-y', 'skills', 'add', source, '--copy', '--agent', 'claude-code', ...skillArgs, '-y']
27
45
  return platform === 'win32'
28
46
  ? { cmd: 'cmd.exe', args: ['/c', 'npx', ...base] }
29
47
  : { cmd: 'npx', args: base }
@@ -0,0 +1,58 @@
1
+ import { execFileSync } from 'node:child_process'
2
+
3
+ // Parse the output of `npx skills find <query>` into structured results.
4
+ // Output shape (after ANSI strip):
5
+ // owner/repo@skill 25.7K installs
6
+ // └ https://skills.sh/owner/repo/skill
7
+ // Pure + exported so it is unit-testable without spawning npx.
8
+ export function parseFind(raw) {
9
+ const clean = String(raw == null ? '' : raw).replace(/\x1b\[[0-9;]*m/g, '')
10
+ const lines = clean.split(/\r?\n/)
11
+ const out = []
12
+ const srcRe = /^([A-Za-z0-9._-]+)\/([A-Za-z0-9._-]+)@([A-Za-z0-9._-]+)\s+([0-9.]+[KM]?)?\s*installs\b/
13
+ for (let i = 0; i < lines.length; i++) {
14
+ const m = lines[i].match(srcRe)
15
+ if (!m) continue
16
+ const [, owner, repo, skill, installs] = m
17
+ const urlM = (lines[i + 1] || '').match(/https:\/\/\S+/)
18
+ out.push({
19
+ source: `${owner}/${repo}@${skill}`,
20
+ owner, repo, skill,
21
+ installs: installs || '',
22
+ url: urlM ? urlM[0] : '',
23
+ })
24
+ }
25
+ return out
26
+ }
27
+
28
+ // Run `npx skills find <query>` non-interactively (empty stdin, CI=1) and parse.
29
+ // Cross-platform: Windows spawns via cmd.exe (Node CVE-2024-27980 refuses npx.cmd
30
+ // with shell:false). The query is validated to a safe charset so it never reaches
31
+ // cmd.exe's metachar surface (& | < > etc.).
32
+ export function searchSkills(query) {
33
+ const q = String(query || '').trim()
34
+ if (!q) return []
35
+ if (/[^A-Za-z0-9 .\/_-]/.test(q)) {
36
+ throw new Error('search query may only contain letters, numbers, spaces, and . / _ -')
37
+ }
38
+ const isWin = process.platform === 'win32'
39
+ const cmd = isWin ? 'cmd.exe' : 'npx'
40
+ const args = isWin ? ['/c', 'npx', '-y', 'skills', 'find', q] : ['-y', 'skills', 'find', q]
41
+ let raw
42
+ try {
43
+ raw = execFileSync(cmd, args, {
44
+ input: '',
45
+ stdio: ['pipe', 'pipe', 'pipe'],
46
+ encoding: 'utf8',
47
+ shell: false,
48
+ timeout: 60000,
49
+ env: { ...process.env, CI: '1' },
50
+ })
51
+ } catch (e) {
52
+ if (e.code === 'ENOENT') throw new Error('npx not found on PATH — is Node.js installed?')
53
+ const timedOut = e.signal === 'SIGTERM' || e.code === 'ETIMEDOUT'
54
+ if (timedOut) throw new Error('search timed out after 60s (network too slow?)')
55
+ throw new Error('skills find failed: ' + (((e.stderr || e.stdout || '').toString().trim().split('\n').pop()) || e.message))
56
+ }
57
+ return parseFind(raw)
58
+ }