@npeercy/skills 0.1.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/skills.js ADDED
@@ -0,0 +1,183 @@
1
+ #!/usr/bin/env node
2
+ import { parseArgs } from 'node:util';
3
+ import {
4
+ cmdInit, cmdLogin, cmdLogout, cmdSearch, cmdInfo,
5
+ cmdInstall, cmdList, cmdUpdate, cmdUninstall,
6
+ cmdEdit, cmdValidate, cmdPublish, cmdImport, cmdDoctor,
7
+ cmdShare, cmdUnshare, cmdVisibility,
8
+ } from '../lib/skills.js';
9
+
10
+ const USAGE = `skill-sharer — CLI skill manager for coding agents
11
+
12
+ Usage: skills <command> [options]
13
+
14
+ Setup:
15
+ init [--server <url>] [--token <token>] [--no-import] Setup skill-sharer
16
+ login [--server <url>] [--token <token>] Set/refresh token (prompts if missing)
17
+ logout Remove stored token
18
+
19
+ Discovery:
20
+ search [query] Search skills (no query = browse all)
21
+ info <skill> Skill details + versions
22
+
23
+ Install:
24
+ install <skill>[@ver] [--agent <a>] [--dry-run]
25
+ list [--verify] [--outdated] [--json]
26
+ update [<skill>] Update one or all
27
+ uninstall <skill>
28
+
29
+ Import:
30
+ import [<path>] Import unmanaged skills
31
+
32
+ Authoring:
33
+ edit <skill> Print canonical path
34
+ validate <path> Check SKILL.md
35
+ publish [<path>] [--message <msg>] Publish to server
36
+
37
+ Sharing:
38
+ share <skill> --with <user> [--maintain]
39
+ unshare <skill> --from <user>
40
+ visibility <skill> [private|org|public]
41
+
42
+ Maintenance:
43
+ doctor [--dry] Diagnose + fix issues
44
+ `;
45
+
46
+ async function main() {
47
+ const args = process.argv.slice(2);
48
+ if (args.length === 0 || args[0] === '--help' || args[0] === '-h') {
49
+ console.log(USAGE);
50
+ return;
51
+ }
52
+
53
+ const cmd = args[0];
54
+ const rest = args.slice(1);
55
+
56
+ try {
57
+ switch (cmd) {
58
+ case 'init': {
59
+ const { values } = parseArgs({ args: rest, options: {
60
+ server: { type: 'string' },
61
+ token: { type: 'string' },
62
+ 'no-import': { type: 'boolean', default: false },
63
+ }, allowPositionals: false });
64
+ await cmdInit({ server: values.server, token: values.token, noImport: values['no-import'] });
65
+ break;
66
+ }
67
+ case 'login': {
68
+ const { values } = parseArgs({ args: rest, options: {
69
+ server: { type: 'string' },
70
+ token: { type: 'string' },
71
+ }, allowPositionals: false });
72
+ await cmdLogin({ server: values.server, token: values.token });
73
+ break;
74
+ }
75
+ case 'logout':
76
+ await cmdLogout();
77
+ break;
78
+ case 'search': {
79
+ const { positionals } = parseArgs({ args: rest, allowPositionals: true, options: {} });
80
+ await cmdSearch({ query: positionals[0] || '' });
81
+ break;
82
+ }
83
+ case 'info': {
84
+ const { positionals } = parseArgs({ args: rest, allowPositionals: true, options: {} });
85
+ if (!positionals[0]) throw new Error('Usage: skills info <skill>');
86
+ await cmdInfo({ skill: positionals[0] });
87
+ break;
88
+ }
89
+ case 'install': {
90
+ const { values, positionals } = parseArgs({ args: rest, options: {
91
+ agent: { type: 'string' },
92
+ 'dry-run': { type: 'boolean', default: false },
93
+ }, allowPositionals: true });
94
+ if (!positionals[0]) throw new Error('Usage: skills install <skill>[@version]');
95
+ await cmdInstall({ skill: positionals[0], agent: values.agent, dryRun: values['dry-run'] });
96
+ break;
97
+ }
98
+ case 'list': {
99
+ const { values } = parseArgs({ args: rest, options: {
100
+ verify: { type: 'boolean', default: false },
101
+ outdated: { type: 'boolean', default: false },
102
+ json: { type: 'boolean', default: false },
103
+ }, allowPositionals: false });
104
+ await cmdList(values);
105
+ break;
106
+ }
107
+ case 'update': {
108
+ const { positionals } = parseArgs({ args: rest, allowPositionals: true, options: {} });
109
+ await cmdUpdate({ skill: positionals[0] });
110
+ break;
111
+ }
112
+ case 'uninstall': {
113
+ const { positionals } = parseArgs({ args: rest, allowPositionals: true, options: {} });
114
+ if (!positionals[0]) throw new Error('Usage: skills uninstall <skill>');
115
+ await cmdUninstall({ skill: positionals[0] });
116
+ break;
117
+ }
118
+ case 'import': {
119
+ const { positionals } = parseArgs({ args: rest, allowPositionals: true, options: {} });
120
+ await cmdImport({ path: positionals[0], all: true });
121
+ break;
122
+ }
123
+ case 'edit': {
124
+ const { positionals } = parseArgs({ args: rest, allowPositionals: true, options: {} });
125
+ if (!positionals[0]) throw new Error('Usage: skills edit <skill>');
126
+ await cmdEdit({ skill: positionals[0] });
127
+ break;
128
+ }
129
+ case 'validate': {
130
+ const { positionals } = parseArgs({ args: rest, allowPositionals: true, options: {} });
131
+ if (!positionals[0]) throw new Error('Usage: skills validate <path>');
132
+ await cmdValidate({ path: positionals[0] });
133
+ break;
134
+ }
135
+ case 'publish': {
136
+ const { values, positionals } = parseArgs({ args: rest, options: {
137
+ message: { type: 'string', short: 'm' },
138
+ }, allowPositionals: true });
139
+ await cmdPublish({ path: positionals[0], message: values.message });
140
+ break;
141
+ }
142
+ case 'share': {
143
+ const { values, positionals } = parseArgs({ args: rest, options: {
144
+ with: { type: 'string' },
145
+ maintain: { type: 'boolean', default: false },
146
+ }, allowPositionals: true });
147
+ if (!positionals[0] || !values.with) throw new Error('Usage: skills share <skill> --with <user>');
148
+ await cmdShare({ skill: positionals[0], with: values.with, maintain: values.maintain });
149
+ break;
150
+ }
151
+ case 'unshare': {
152
+ const { values, positionals } = parseArgs({ args: rest, options: {
153
+ from: { type: 'string' },
154
+ }, allowPositionals: true });
155
+ if (!positionals[0] || !values.from) throw new Error('Usage: skills unshare <skill> --from <user>');
156
+ await cmdUnshare({ skill: positionals[0], from: values.from });
157
+ break;
158
+ }
159
+ case 'visibility': {
160
+ const { positionals } = parseArgs({ args: rest, allowPositionals: true, options: {} });
161
+ if (!positionals[0]) throw new Error('Usage: skills visibility <skill> [private|org|public]');
162
+ await cmdVisibility({ skill: positionals[0], value: positionals[1] });
163
+ break;
164
+ }
165
+ case 'doctor': {
166
+ const { values } = parseArgs({ args: rest, options: {
167
+ dry: { type: 'boolean', default: false },
168
+ }, allowPositionals: false });
169
+ await cmdDoctor({ dry: values.dry });
170
+ break;
171
+ }
172
+ default:
173
+ console.error(`Unknown command: ${cmd}`);
174
+ console.log(USAGE);
175
+ process.exit(1);
176
+ }
177
+ } catch (e) {
178
+ console.error(`error: ${e.message}`);
179
+ process.exit(1);
180
+ }
181
+ }
182
+
183
+ main();
@@ -0,0 +1,88 @@
1
+ ---
2
+ name: creating-or-updating-a-new-skill
3
+ description: Use when creating a new skill, editing an existing skill's SKILL.md, or publishing changes to the skill-sharer server.
4
+ ---
5
+
6
+ # Creating or updating a skill
7
+
8
+ ## SKILL.md structure
9
+
10
+ Every skill needs a `SKILL.md` file with frontmatter:
11
+
12
+ ```yaml
13
+ ---
14
+ name: my-skill-name
15
+ description: When this skill should be activated by the agent
16
+ ---
17
+
18
+ # Title
19
+
20
+ Instructions, examples, and decision logic in markdown.
21
+ ```
22
+
23
+ - `name`: lowercase letters, digits, hyphens only
24
+ - `description`: tells the agent WHEN to use this skill (this is how agents decide to activate it)
25
+
26
+ ## Creating a new skill
27
+
28
+ 1. Create a directory with a `SKILL.md`:
29
+ ```bash
30
+ mkdir my-skill
31
+ # Write the SKILL.md with frontmatter + instructions
32
+ ```
33
+
34
+ 2. Validate:
35
+ ```bash
36
+ skills validate ./my-skill
37
+ ```
38
+
39
+ 3. Publish:
40
+ ```bash
41
+ skills publish ./my-skill --message "Initial version"
42
+ ```
43
+ The server assigns `v1` automatically. Org and user are inferred from your login.
44
+
45
+ 4. Install locally:
46
+ ```bash
47
+ skills install my-skill
48
+ ```
49
+
50
+ ## Editing an existing skill
51
+
52
+ 1. Get the canonical path:
53
+ ```bash
54
+ skills edit <skill>
55
+ # Prints: ~/.local/share/skill-sharer/skills/org/user/name/vN/SKILL.md
56
+ ```
57
+
58
+ 2. Read and edit the SKILL.md at that path using normal file tools.
59
+
60
+ 3. Validate:
61
+ ```bash
62
+ skills validate <path-to-skill-dir>
63
+ ```
64
+
65
+ 4. Publish the update:
66
+ ```bash
67
+ skills publish --message "Description of changes"
68
+ ```
69
+ No path or flags needed — infers from the last edited managed skill.
70
+
71
+ **Decision logic:**
72
+ - Always validate before publishing.
73
+ - The edit is live locally immediately (symlinks point to managed store).
74
+ - Publishing pushes to the server so others can install the new version.
75
+ - If the skill isn't installed locally yet, install it after publishing.
76
+
77
+ ## Sharing
78
+
79
+ ```bash
80
+ # Make a skill visible to all org members (default)
81
+ skills visibility <skill> org
82
+
83
+ # Make public (anyone can install, no auth needed)
84
+ skills visibility <skill> public
85
+
86
+ # Grant access to a specific user on a private skill
87
+ skills share <skill> --with <username>
88
+ ```
@@ -0,0 +1,85 @@
1
+ ---
2
+ name: skill-sharer-help
3
+ description: Use when the user asks to install, update, search, list, import, or manage coding agent skills via skill-sharer.
4
+ ---
5
+
6
+ # skill-sharer help
7
+
8
+ skill-sharer manages skills across coding agents (Claude Code, Pi, etc.).
9
+ Skills are installed as symlinks from a managed store into each agent's skill directory.
10
+
11
+ ## Finding and installing skills
12
+
13
+ Use short names — the CLI resolves them automatically when unambiguous.
14
+
15
+ ```bash
16
+ # Search for a skill
17
+ skills search <query>
18
+
19
+ # Get details (versions, description)
20
+ skills info <skill>
21
+
22
+ # Install (latest version, to all agents)
23
+ skills install <skill>
24
+
25
+ # Install a specific version
26
+ skills install <skill>@v2
27
+
28
+ # Install to one agent only
29
+ skills install <skill> --agent pi
30
+ ```
31
+
32
+ **Decision logic:**
33
+ - Always search first to find the full `org/user/name`.
34
+ - If search returns multiple matches, show them and ask the user which one.
35
+ - If the user says "install X", search for X, then install the match.
36
+
37
+ ## Checking installed skills
38
+
39
+ ```bash
40
+ # List all installed skills
41
+ skills list
42
+
43
+ # Check health (symlinks, checksums)
44
+ skills list --verify
45
+
46
+ # Show only outdated skills
47
+ skills list --outdated
48
+ ```
49
+
50
+ ## Updating and removing
51
+
52
+ ```bash
53
+ # Update all skills to latest
54
+ skills update
55
+
56
+ # Update one skill
57
+ skills update <skill>
58
+
59
+ # Remove a skill
60
+ skills uninstall <skill>
61
+ ```
62
+
63
+ ## Importing existing skills
64
+
65
+ If the user has skills already in `~/.claude/skills/` or `~/.pi/agent/skills/` that aren't managed by skill-sharer:
66
+
67
+ ```bash
68
+ # Scan all agent dirs and import unmanaged skills
69
+ skills import
70
+ ```
71
+
72
+ **Decision logic:**
73
+ - Use `skills doctor` to check for unmanaged skills.
74
+ - Use `skills import` to bring them under management.
75
+ - Import moves the original, creates namespaced symlinks, and publishes to the server.
76
+
77
+ ## Maintenance
78
+
79
+ ```bash
80
+ # Diagnose and fix broken symlinks, report unmanaged skills
81
+ skills doctor
82
+
83
+ # Diagnose only (no fixes)
84
+ skills doctor --dry
85
+ ```
package/lib/api.js ADDED
@@ -0,0 +1,78 @@
1
+ import { loadConfig } from './config.js';
2
+
3
+ function headers(token) {
4
+ const h = { 'Content-Type': 'application/json' };
5
+ if (token) h['Authorization'] = `Bearer ${token}`;
6
+ return h;
7
+ }
8
+
9
+ async function request(method, path, body = null) {
10
+ const cfg = loadConfig();
11
+ if (!cfg.server) throw new Error('No server configured. Run: skills init --server <url>');
12
+
13
+ const url = `${cfg.server.replace(/\/$/, '')}${path}`;
14
+ const opts = { method, headers: headers(cfg.token) };
15
+ if (body) opts.body = JSON.stringify(body);
16
+
17
+ const res = await fetch(url, opts);
18
+ const data = await res.json().catch(() => ({}));
19
+
20
+ if (!res.ok) {
21
+ const msg = data.error || `HTTP ${res.status}`;
22
+ const err = new Error(msg);
23
+ err.status = res.status;
24
+ throw err;
25
+ }
26
+ return data;
27
+ }
28
+
29
+ // --- Auth ---
30
+ export async function createToken(user) {
31
+ return request('POST', '/auth/token', { user });
32
+ }
33
+
34
+ export async function whoami() {
35
+ return request('GET', '/auth/me');
36
+ }
37
+
38
+ export async function logout() {
39
+ return request('DELETE', '/auth/token');
40
+ }
41
+
42
+ // --- Skills ---
43
+ export async function browse(query) {
44
+ const q = query ? `?q=${encodeURIComponent(query)}` : '';
45
+ return request('GET', `/skills${q}`);
46
+ }
47
+
48
+ export async function info(org, user, name) {
49
+ return request('GET', `/skills/${org}/${user}/${name}`);
50
+ }
51
+
52
+ export async function download(org, user, name, version) {
53
+ return request('GET', `/skills/${org}/${user}/${name}/@${version}`);
54
+ }
55
+
56
+ export async function publish(org, user, name, message, files) {
57
+ return request('POST', '/skills', { org, user, name, message, files });
58
+ }
59
+
60
+ export async function setVisibility(org, user, name, visibility) {
61
+ return request('PATCH', `/skills/${org}/${user}/${name}/visibility`, { visibility });
62
+ }
63
+
64
+ export async function getAcl(org, user, name) {
65
+ return request('GET', `/skills/${org}/${user}/${name}/acl`);
66
+ }
67
+
68
+ export async function grantAccess(org, user, name, grantUser, role) {
69
+ return request('POST', `/skills/${org}/${user}/${name}/acl`, { user: grantUser, role });
70
+ }
71
+
72
+ export async function revokeAccess(org, user, name, grantUser) {
73
+ return request('DELETE', `/skills/${org}/${user}/${name}/acl/${grantUser}`);
74
+ }
75
+
76
+ export async function deleteSkill(org, user, name) {
77
+ return request('DELETE', `/skills/${org}/${user}/${name}`);
78
+ }
package/lib/config.js ADDED
@@ -0,0 +1,96 @@
1
+ import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'fs';
2
+ import { join } from 'path';
3
+ import { homedir } from 'os';
4
+
5
+ // --- Paths ---
6
+ const CONFIG_HOME = process.env.SKILLS_CONFIG_HOME || join(homedir(), '.config', 'skill-sharer');
7
+ const DATA_HOME = process.env.SKILLS_DATA_HOME || join(homedir(), '.local', 'share', 'skill-sharer');
8
+
9
+ export const configDir = () => CONFIG_HOME;
10
+ export const dataDir = () => DATA_HOME;
11
+ export const configPath = () => join(CONFIG_HOME, 'config.json');
12
+ export const statePath = () => join(DATA_HOME, 'state.json');
13
+ export const skillsDir = () => join(DATA_HOME, 'skills');
14
+
15
+ // --- JSON helpers ---
16
+ function loadJson(path, fallback) {
17
+ try { return JSON.parse(readFileSync(path, 'utf8')); } catch { return fallback; }
18
+ }
19
+
20
+ function saveJson(path, data) {
21
+ mkdirSync(join(path, '..'), { recursive: true });
22
+ writeFileSync(path, JSON.stringify(data, null, 2) + '\n');
23
+ }
24
+
25
+ // --- Config ---
26
+ const DEFAULT_CONFIG = { server: 'https://skills.npeercy.com', org: '', token: '' };
27
+
28
+ export function loadConfig() {
29
+ mkdirSync(CONFIG_HOME, { recursive: true });
30
+ const cfg = { ...DEFAULT_CONFIG, ...loadJson(configPath(), {}) };
31
+ return cfg;
32
+ }
33
+
34
+ export function saveConfig(cfg) {
35
+ saveJson(configPath(), cfg);
36
+ }
37
+
38
+ // --- State ---
39
+ const DEFAULT_STATE = { installed: {} };
40
+
41
+ export function loadState() {
42
+ mkdirSync(DATA_HOME, { recursive: true });
43
+ mkdirSync(skillsDir(), { recursive: true });
44
+ const st = { ...DEFAULT_STATE, ...loadJson(statePath(), {}) };
45
+ st.installed = st.installed || {};
46
+ return st;
47
+ }
48
+
49
+ export function saveState(st) {
50
+ saveJson(statePath(), st);
51
+ }
52
+
53
+ // --- Agents ---
54
+ const KNOWN_AGENTS = {
55
+ 'claude-code': join(homedir(), '.claude', 'skills'),
56
+ 'pi': join(homedir(), '.pi', 'agent', 'skills'),
57
+ };
58
+
59
+ export function detectAgents() {
60
+ const found = {};
61
+ for (const [name, path] of Object.entries(KNOWN_AGENTS)) {
62
+ if (existsSync(path)) found[name] = path;
63
+ }
64
+ return found;
65
+ }
66
+
67
+ // --- Skill ID helpers ---
68
+ export function parseSkillId(input, config) {
69
+ const parts = input.replace(/@.*$/, '').split('/').filter(Boolean);
70
+ if (parts.length === 3) return parts.join('/');
71
+ if (parts.length === 2) {
72
+ if (!config.org) throw new Error('No org configured. Run: skills init --server <url>');
73
+ return `${config.org}/${parts.join('/')}`;
74
+ }
75
+ if (parts.length === 1) return null; // needs resolution via search
76
+ throw new Error('Invalid skill identifier');
77
+ }
78
+
79
+ export function parseVersion(input) {
80
+ const m = input.match(/@(.+)$/);
81
+ return m ? m[1] : null;
82
+ }
83
+
84
+ export function splitSkillId(id) {
85
+ const [org, user, name] = id.split('/');
86
+ return { org, user, name };
87
+ }
88
+
89
+ export function symlinkName(skillId) {
90
+ return skillId.replace(/\//g, '--');
91
+ }
92
+
93
+ export function managedPath(skillId, version) {
94
+ const { org, user, name } = splitSkillId(skillId);
95
+ return join(skillsDir(), org, user, name, version);
96
+ }
package/lib/skills.js ADDED
@@ -0,0 +1,725 @@
1
+ import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync, symlinkSync, unlinkSync, rmSync, lstatSync, readlinkSync } from 'fs';
2
+ import { join, resolve, relative, dirname } from 'path';
3
+ import { fileURLToPath } from 'url';
4
+ import { createHash } from 'crypto';
5
+ import { createInterface } from 'node:readline/promises';
6
+ import { stdin as input, stdout as output } from 'node:process';
7
+ import {
8
+ loadConfig, saveConfig, loadState, saveState,
9
+ detectAgents, parseSkillId, parseVersion, splitSkillId,
10
+ symlinkName, managedPath,
11
+ } from './config.js';
12
+ import * as api from './api.js';
13
+
14
+ // --- Frontmatter parser ---
15
+ export function parseFrontmatter(content) {
16
+ const lines = content.split('\n');
17
+ if (lines[0].trim() !== '---') return null;
18
+ const meta = {};
19
+ for (let i = 1; i < lines.length; i++) {
20
+ if (lines[i].trim() === '---') break;
21
+ const m = lines[i].match(/^(\w[\w-]*)\s*:\s*(.+)$/);
22
+ if (m) meta[m[1]] = m[2].replace(/^["']|["']$/g, '');
23
+ }
24
+ return (meta.name && meta.description) ? meta : null;
25
+ }
26
+
27
+ // --- Collect files from directory ---
28
+ function collectFiles(dir) {
29
+ const result = {};
30
+ function walk(d) {
31
+ for (const entry of readdirSync(d, { withFileTypes: true })) {
32
+ if (entry.name.startsWith('.')) continue;
33
+ const full = join(d, entry.name);
34
+ if (entry.isDirectory()) walk(full);
35
+ else result[relative(dir, full)] = readFileSync(full, 'utf8');
36
+ }
37
+ }
38
+ walk(dir);
39
+ return result;
40
+ }
41
+
42
+ // --- Checksum ---
43
+ function fileHash(path) {
44
+ return createHash('sha256').update(readFileSync(path)).digest('hex');
45
+ }
46
+
47
+ function computeChecksums(dir) {
48
+ const out = {};
49
+ function walk(d) {
50
+ for (const entry of readdirSync(d, { withFileTypes: true })) {
51
+ const full = join(d, entry.name);
52
+ if (entry.isDirectory()) walk(full);
53
+ else out[relative(dir, full)] = fileHash(full);
54
+ }
55
+ }
56
+ if (existsSync(dir)) walk(dir);
57
+ return out;
58
+ }
59
+
60
+ // --- Short name resolution ---
61
+ async function resolveSkillId(input, config) {
62
+ const exact = parseSkillId(input, config);
63
+ if (exact) return exact; // 3-part or 2-part resolved
64
+
65
+ // 1-part: search the index
66
+ const results = await api.browse(input);
67
+ const matches = results.filter(s =>
68
+ s.name === input || s.id.endsWith('/' + input)
69
+ );
70
+ if (matches.length === 1) return matches[0].id;
71
+ if (matches.length === 0) throw new Error(`No skill found matching "${input}"`);
72
+ throw new Error(
73
+ `Ambiguous: "${input}" matches multiple skills:\n` +
74
+ matches.map(m => ` ${m.id}`).join('\n') +
75
+ '\nUse the full org/user/name to be specific.'
76
+ );
77
+ }
78
+
79
+ function linkNameForSkill(skillId) {
80
+ if (skillId.startsWith('__builtin__/local/')) {
81
+ return `builtin--${splitSkillId(skillId).name}`;
82
+ }
83
+ return symlinkName(skillId);
84
+ }
85
+
86
+ async function promptForToken(serverUrl) {
87
+ console.log(`No token configured for ${serverUrl}.`);
88
+ console.log('1) Open: https://skills.npeercy.com');
89
+ console.log('2) Click Sign in');
90
+ console.log('3) Copy the auto-filled login command/token');
91
+ const rl = createInterface({ input, output });
92
+ const token = (await rl.question('Paste token: ')).trim();
93
+ rl.close();
94
+ if (!token) throw new Error('Token is required.');
95
+ return token;
96
+ }
97
+
98
+ async function installBuiltins(agents) {
99
+ const st = loadState();
100
+ const builtinsRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..', 'builtin-skills');
101
+ if (!existsSync(builtinsRoot)) return;
102
+
103
+ const installed = [];
104
+ for (const entry of readdirSync(builtinsRoot, { withFileTypes: true })) {
105
+ if (!entry.isDirectory()) continue;
106
+
107
+ const srcDir = join(builtinsRoot, entry.name);
108
+ const skillMd = join(srcDir, 'SKILL.md');
109
+ if (!existsSync(skillMd)) continue;
110
+
111
+ const meta = parseFrontmatter(readFileSync(skillMd, 'utf8'));
112
+ if (!meta) continue;
113
+
114
+ const id = `__builtin__/local/${meta.name}`;
115
+ const version = 'v1';
116
+ const dest = managedPath(id, version);
117
+ const files = collectFiles(srcDir);
118
+
119
+ // Write to managed store
120
+ rmSync(dest, { recursive: true, force: true });
121
+ mkdirSync(dest, { recursive: true });
122
+ for (const [filePath, content] of Object.entries(files)) {
123
+ const full = join(dest, filePath);
124
+ mkdirSync(dirname(full), { recursive: true });
125
+ writeFileSync(full, content);
126
+ }
127
+
128
+ // Symlink into all detected agents
129
+ const linkName = linkNameForSkill(id);
130
+ for (const agentPath of Object.values(agents)) {
131
+ mkdirSync(agentPath, { recursive: true });
132
+ const link = join(agentPath, linkName);
133
+ try {
134
+ const st = lstatSync(link);
135
+ if (st.isDirectory()) rmSync(link, { recursive: true, force: true });
136
+ else unlinkSync(link);
137
+ } catch {
138
+ // doesn't exist
139
+ }
140
+ symlinkSync(dest, link);
141
+ }
142
+
143
+ st.installed[id] = {
144
+ version,
145
+ installed_at: new Date().toISOString(),
146
+ path: dest,
147
+ agents: Object.keys(agents).sort(),
148
+ checksums: computeChecksums(dest),
149
+ builtin: true,
150
+ };
151
+
152
+ installed.push(meta.name);
153
+ }
154
+
155
+ saveState(st);
156
+ if (installed.length > 0) {
157
+ console.log('\nInstalled built-in skills:');
158
+ for (const name of installed) {
159
+ console.log(` ✓ ${name} → ${Object.keys(agents).join(', ') || '(no agents detected)'}`);
160
+ }
161
+ }
162
+ }
163
+
164
+ // --- Commands ---
165
+
166
+ export async function cmdInit(args) {
167
+ const cfg = loadConfig();
168
+
169
+ // Server
170
+ if (args.server) {
171
+ cfg.server = args.server.replace(/\/$/, '');
172
+ }
173
+ if (!cfg.server) {
174
+ cfg.server = 'https://skills.npeercy.com';
175
+ }
176
+
177
+ // Detect agents
178
+ const agents = detectAgents();
179
+ console.log('Scanning for coding agents...');
180
+ for (const [name, path] of Object.entries(agents)) {
181
+ console.log(`✓ ${name.padEnd(12)} ${path}`);
182
+ }
183
+ if (Object.keys(agents).length === 0) {
184
+ console.log(' (no agents detected)');
185
+ }
186
+
187
+ // Login
188
+ if (args.token) {
189
+ cfg.token = args.token;
190
+ saveConfig(cfg);
191
+ }
192
+ if (!cfg.token) {
193
+ cfg.token = await promptForToken(cfg.server);
194
+ saveConfig(cfg);
195
+ }
196
+
197
+ // Verify token
198
+ saveConfig(cfg);
199
+ try {
200
+ const me = await api.whoami();
201
+ cfg.org = me.org;
202
+ saveConfig(cfg);
203
+ console.log(`\n✓ Logged in as ${me.user} (org: ${me.org}, role: ${me.role})`);
204
+ } catch (e) {
205
+ throw new Error(`Token invalid: ${e.message}`);
206
+ }
207
+
208
+ // Import existing skills
209
+ if (!args.noImport) {
210
+ await cmdImport({ all: true, quiet: true });
211
+ }
212
+
213
+ // Install built-ins
214
+ await installBuiltins(agents);
215
+
216
+ console.log('\nReady! Ask your agent to "install a skill" or "create a new skill".');
217
+ }
218
+
219
+ export async function cmdLogin(args) {
220
+ const cfg = loadConfig();
221
+ if (args.server) {
222
+ cfg.server = args.server.replace(/\/$/, '');
223
+ }
224
+ if (!cfg.server) cfg.server = 'https://skills.npeercy.com';
225
+
226
+ if (args.token) {
227
+ cfg.token = args.token;
228
+ } else if (!cfg.token) {
229
+ cfg.token = await promptForToken(cfg.server);
230
+ }
231
+
232
+ saveConfig(cfg);
233
+ const me = await api.whoami();
234
+ cfg.org = me.org;
235
+ saveConfig(cfg);
236
+ console.log(`✓ Logged in as ${me.user} (org: ${me.org}, role: ${me.role})`);
237
+ }
238
+
239
+ export async function cmdLogout() {
240
+ const cfg = loadConfig();
241
+ if (cfg.token) {
242
+ try { await api.logout(); } catch { /* ignore */ }
243
+ }
244
+ cfg.token = '';
245
+ saveConfig(cfg);
246
+ console.log('Logged out.');
247
+ }
248
+
249
+ export async function cmdSearch(args) {
250
+ const results = await api.browse(args.query || '');
251
+ if (results.length === 0) {
252
+ console.log(args.query ? `No results for "${args.query}"` : 'No skills found.');
253
+ return;
254
+ }
255
+ console.log('SKILL'.padEnd(56) + 'VERSION'.padEnd(10) + 'VISIBILITY'.padEnd(12) + 'DESCRIPTION');
256
+ for (const s of results) {
257
+ console.log(
258
+ s.id.padEnd(56) +
259
+ (s.latest_version || '-').padEnd(10) +
260
+ (s.visibility || 'org').padEnd(12) +
261
+ (s.description || '').slice(0, 60)
262
+ );
263
+ }
264
+ }
265
+
266
+ export async function cmdInfo(args) {
267
+ const cfg = loadConfig();
268
+ const id = await resolveSkillId(args.skill, cfg);
269
+ const { org, user, name } = splitSkillId(id);
270
+ const data = await api.info(org, user, name);
271
+
272
+ const st = loadState();
273
+ const installed = st.installed[id];
274
+
275
+ console.log(`Skill: ${data.id}`);
276
+ console.log(`Visibility: ${data.visibility}`);
277
+ console.log(`Description: ${data.description || ''}`);
278
+ console.log('');
279
+ console.log('Versions:');
280
+ for (const v of (data.versions || []).reverse()) {
281
+ const mark = installed && installed.version === v ? ' ← installed' : '';
282
+ console.log(` ${v}${mark}`);
283
+ }
284
+ }
285
+
286
+ export async function cmdInstall(args) {
287
+ const cfg = loadConfig();
288
+ const agents = detectAgents();
289
+ const st = loadState();
290
+
291
+ const rawSkill = args.skill.replace(/@.*$/, '');
292
+ const version = parseVersion(args.skill) || 'latest';
293
+ const id = await resolveSkillId(rawSkill, cfg);
294
+ const { org, user, name } = splitSkillId(id);
295
+
296
+ const targetAgents = args.agent ? { [args.agent]: agents[args.agent] } : agents;
297
+ if (args.agent && !agents[args.agent]) {
298
+ throw new Error(`Unknown agent: ${args.agent}`);
299
+ }
300
+ if (Object.keys(targetAgents).length === 0) throw new Error('No agents detected.');
301
+
302
+ const data = await api.download(org, user, name, version);
303
+ const ver = data.version;
304
+ const dest = managedPath(id, ver);
305
+ const linkName = linkNameForSkill(id);
306
+
307
+ console.log(`Installing ${id}@${ver}`);
308
+ for (const [agentName, agentPath] of Object.entries(targetAgents)) {
309
+ console.log(` -> ${agentName}: ${join(agentPath, linkName)}`);
310
+ }
311
+
312
+ if (args.dryRun) {
313
+ console.log('Dry run — no changes written.');
314
+ return;
315
+ }
316
+
317
+ // Write files to managed store
318
+ mkdirSync(dest, { recursive: true });
319
+ for (const [filePath, content] of Object.entries(data.files)) {
320
+ const full = join(dest, filePath);
321
+ mkdirSync(join(full, '..'), { recursive: true });
322
+ writeFileSync(full, content);
323
+ }
324
+
325
+ // Create symlinks
326
+ for (const [agentName, agentPath] of Object.entries(targetAgents)) {
327
+ mkdirSync(agentPath, { recursive: true });
328
+ const link = join(agentPath, linkName);
329
+ try { unlinkSync(link); } catch { /* ok if doesn't exist */ }
330
+ symlinkSync(dest, link);
331
+ }
332
+
333
+ // Update state
334
+ st.installed[id] = {
335
+ version: ver,
336
+ installed_at: new Date().toISOString(),
337
+ path: dest,
338
+ agents: Object.keys(targetAgents).sort(),
339
+ checksums: computeChecksums(dest),
340
+ };
341
+ saveState(st);
342
+ console.log('Installed.');
343
+ }
344
+
345
+ export async function cmdList(args) {
346
+ const cfg = loadConfig();
347
+ const st = loadState();
348
+ const agents = detectAgents();
349
+
350
+ const rows = [];
351
+ for (const [id, rec] of Object.entries(st.installed).sort()) {
352
+ let status = 'ok';
353
+ if (args.verify) {
354
+ status = verifyInstall(id, rec, agents);
355
+ }
356
+
357
+ let latest = rec.version;
358
+ if (args.outdated || args.verify) {
359
+ try {
360
+ const { org, user, name } = splitSkillId(id);
361
+ const data = await api.info(org, user, name);
362
+ latest = data.latest_version || rec.version;
363
+ } catch { /* offline */ }
364
+ }
365
+
366
+ const outdated = latest !== rec.version;
367
+ if (args.outdated && !outdated) continue;
368
+ if (outdated && status === 'ok') status = 'outdated';
369
+
370
+ rows.push({ id, version: rec.version, latest, agents: rec.agents.join(','), status });
371
+ }
372
+
373
+ if (args.json) {
374
+ console.log(JSON.stringify(rows, null, 2));
375
+ return;
376
+ }
377
+
378
+ if (rows.length === 0) {
379
+ console.log('No installed skills.');
380
+ return;
381
+ }
382
+
383
+ console.log('SKILL'.padEnd(56) + 'INSTALLED'.padEnd(10) + 'LATEST'.padEnd(10) + 'AGENTS'.padEnd(20) + 'STATUS');
384
+ for (const r of rows) {
385
+ console.log(r.id.padEnd(56) + r.version.padEnd(10) + r.latest.padEnd(10) + r.agents.padEnd(20) + r.status);
386
+ }
387
+ }
388
+
389
+ function verifyInstall(id, rec, agents) {
390
+ const dest = rec.path;
391
+ if (!existsSync(dest)) return 'missing';
392
+
393
+ const linkName = linkNameForSkill(id);
394
+ for (const agentName of rec.agents) {
395
+ const agentPath = agents[agentName];
396
+ if (!agentPath) return 'missing-agent';
397
+ const link = join(agentPath, linkName);
398
+ try {
399
+ if (!lstatSync(link).isSymbolicLink()) return 'not-symlink';
400
+ if (resolve(readlinkSync(link)) !== resolve(dest)) return 'broken-link';
401
+ } catch {
402
+ return 'broken-link';
403
+ }
404
+ }
405
+
406
+ const expected = rec.checksums || {};
407
+ const actual = computeChecksums(dest);
408
+ if (JSON.stringify(expected) !== JSON.stringify(actual)) return 'modified';
409
+
410
+ return 'ok';
411
+ }
412
+
413
+ export async function cmdUpdate(args) {
414
+ const cfg = loadConfig();
415
+ const st = loadState();
416
+
417
+ const targets = args.skill
418
+ ? [await resolveSkillId(args.skill, cfg)]
419
+ : Object.keys(st.installed);
420
+
421
+ let updated = 0;
422
+ for (const id of targets) {
423
+ const rec = st.installed[id];
424
+ if (!rec) continue;
425
+ const { org, user, name } = splitSkillId(id);
426
+ try {
427
+ const data = await api.info(org, user, name);
428
+ if (data.latest_version === rec.version) continue;
429
+ await cmdInstall({ skill: `${id}@${data.latest_version}`, dryRun: false });
430
+ updated++;
431
+ } catch (e) {
432
+ console.error(` ${id}: ${e.message}`);
433
+ }
434
+ }
435
+ console.log(`Updated ${updated} skill(s).`);
436
+ }
437
+
438
+ export async function cmdUninstall(args) {
439
+ const cfg = loadConfig();
440
+ const st = loadState();
441
+ const id = await resolveSkillId(args.skill, cfg);
442
+ const rec = st.installed[id];
443
+ if (!rec) { console.log('Not installed.'); return; }
444
+
445
+ const agents = detectAgents();
446
+ const linkName = linkNameForSkill(id);
447
+ for (const agentName of rec.agents) {
448
+ const agentPath = agents[agentName];
449
+ if (!agentPath) continue;
450
+ const link = join(agentPath, linkName);
451
+ try {
452
+ const st = lstatSync(link);
453
+ if (st.isDirectory()) rmSync(link, { recursive: true, force: true });
454
+ else unlinkSync(link);
455
+ } catch { /* ok */ }
456
+ }
457
+
458
+ if (rec.path && existsSync(rec.path)) {
459
+ rmSync(rec.path, { recursive: true, force: true });
460
+ }
461
+
462
+ delete st.installed[id];
463
+ saveState(st);
464
+ console.log(`Uninstalled ${id}.`);
465
+ }
466
+
467
+ export async function cmdEdit(args) {
468
+ const cfg = loadConfig();
469
+ const st = loadState();
470
+ const id = await resolveSkillId(args.skill, cfg);
471
+ const rec = st.installed[id];
472
+ if (!rec) throw new Error(`${id} is not installed.`);
473
+ console.log(join(rec.path, 'SKILL.md'));
474
+ }
475
+
476
+ export async function cmdValidate(args) {
477
+ const dir = resolve(args.path);
478
+ const skillMd = join(dir, 'SKILL.md');
479
+ if (!existsSync(skillMd)) throw new Error('SKILL.md not found');
480
+ const content = readFileSync(skillMd, 'utf8');
481
+ const meta = parseFrontmatter(content);
482
+ if (!meta) throw new Error('Invalid frontmatter — needs name and description');
483
+
484
+ if (!/^[a-z0-9-]+$/.test(meta.name)) {
485
+ throw new Error('Skill name must be lowercase letters, digits, and hyphens only');
486
+ }
487
+
488
+ console.log('✓ SKILL.md found');
489
+ console.log('✓ frontmatter valid');
490
+ console.log(`✓ name: ${meta.name}`);
491
+ console.log('Ready to publish.');
492
+ }
493
+
494
+ export async function cmdPublish(args) {
495
+ const cfg = loadConfig();
496
+ const st = loadState();
497
+
498
+ let dir, skillOrg, skillUser, skillName;
499
+
500
+ if (args.path) {
501
+ dir = resolve(args.path);
502
+ } else {
503
+ // Find last edited managed skill
504
+ const entries = Object.entries(st.installed).sort((a, b) =>
505
+ (b[1].installed_at || '').localeCompare(a[1].installed_at || '')
506
+ );
507
+ if (entries.length === 0) throw new Error('No path provided and no installed skills.');
508
+ const [lastId, lastRec] = entries[0];
509
+ dir = lastRec.path;
510
+ const parts = splitSkillId(lastId);
511
+ skillOrg = parts.org;
512
+ skillUser = parts.user;
513
+ skillName = parts.name;
514
+ console.log(`Publishing from: ${dir}`);
515
+ }
516
+
517
+ const skillMd = join(dir, 'SKILL.md');
518
+ if (!existsSync(skillMd)) throw new Error('SKILL.md not found at ' + dir);
519
+ const content = readFileSync(skillMd, 'utf8');
520
+ const meta = parseFrontmatter(content);
521
+ if (!meta) throw new Error('Invalid SKILL.md frontmatter');
522
+
523
+ const org = skillOrg || cfg.org;
524
+ const user = skillUser || (await api.whoami()).user;
525
+ const name = skillName || meta.name;
526
+ if (!org || !user || !name) throw new Error('Cannot infer org/user/name. Provide --path or install the skill first.');
527
+
528
+ const files = collectFiles(dir);
529
+ const result = await api.publish(org, user, name, args.message || '', files);
530
+ console.log(`Published ${result.id}@${result.version}`);
531
+ }
532
+
533
+ export async function cmdImport(args) {
534
+ const cfg = loadConfig();
535
+ const st = loadState();
536
+ const agents = detectAgents();
537
+
538
+ // Find unmanaged skills in agent dirs
539
+ const unmanaged = new Map(); // name -> { sources: [{agent, path}] }
540
+ const managedLinks = new Set(Object.keys(st.installed).map(linkNameForSkill));
541
+
542
+ for (const [agentName, agentPath] of Object.entries(agents)) {
543
+ if (!existsSync(agentPath)) continue;
544
+ for (const entry of readdirSync(agentPath, { withFileTypes: true })) {
545
+ const full = join(agentPath, entry.name);
546
+ // Skip managed symlinks
547
+ if (managedLinks.has(entry.name)) continue;
548
+ // Skip if it's a symlink pointing into our managed store
549
+ try {
550
+ if (lstatSync(full).isSymbolicLink()) {
551
+ const target = readlinkSync(full);
552
+ if (target.includes('skill-sharer')) continue;
553
+ }
554
+ } catch { /* ok */ }
555
+
556
+ // Check if it has SKILL.md
557
+ const skillMd = entry.isDirectory() ? join(full, 'SKILL.md') :
558
+ (lstatSync(full).isSymbolicLink() ? join(resolve(readlinkSync(full)), 'SKILL.md') : null);
559
+ if (!skillMd || !existsSync(skillMd)) continue;
560
+
561
+ const name = entry.name;
562
+ if (!unmanaged.has(name)) unmanaged.set(name, { sources: [] });
563
+ unmanaged.get(name).sources.push({ agent: agentName, path: full, skillMdPath: skillMd });
564
+ }
565
+ }
566
+
567
+ if (args.path) {
568
+ const p = resolve(args.path);
569
+ const skillMd = join(p, 'SKILL.md');
570
+ if (!existsSync(skillMd)) throw new Error(`No SKILL.md found at ${p}`);
571
+ const meta = parseFrontmatter(readFileSync(skillMd, 'utf8'));
572
+ const name = meta?.name || p.split('/').pop();
573
+ unmanaged.clear();
574
+ unmanaged.set(name, { sources: [{ agent: 'manual', path: p, skillMdPath: skillMd }] });
575
+ }
576
+
577
+ if (unmanaged.size === 0) {
578
+ if (!args.quiet) console.log('No unmanaged skills found.');
579
+ return;
580
+ }
581
+
582
+ console.log(`Found ${unmanaged.size} unmanaged skill(s):`);
583
+ for (const [name, info] of unmanaged) {
584
+ const agentList = info.sources.map(s => s.agent).join(', ');
585
+ console.log(` ${name} (${agentList})`);
586
+ }
587
+
588
+ if (!cfg.org || !cfg.token) {
589
+ console.log('Skipping import — not logged in.');
590
+ return;
591
+ }
592
+
593
+ const me = await api.whoami();
594
+
595
+ for (const [name, info] of unmanaged) {
596
+ const source = info.sources[0]; // pick first as canonical
597
+ const dir = lstatSync(source.path).isSymbolicLink() ? resolve(readlinkSync(source.path)) : source.path;
598
+ const skillMdContent = readFileSync(join(dir, 'SKILL.md'), 'utf8');
599
+ const meta = parseFrontmatter(skillMdContent);
600
+ const skillName = meta?.name || name;
601
+ const id = `${cfg.org}/${me.user}/${skillName}`;
602
+
603
+ try {
604
+ // Publish to server
605
+ const files = collectFiles(dir);
606
+ const result = await api.publish(cfg.org, me.user, skillName, 'Imported from local agent', files);
607
+
608
+ // Write to managed store
609
+ const dest = managedPath(id, result.version);
610
+ mkdirSync(dest, { recursive: true });
611
+ for (const [fp, content] of Object.entries(files)) {
612
+ const full = join(dest, fp);
613
+ mkdirSync(join(full, '..'), { recursive: true });
614
+ writeFileSync(full, content);
615
+ }
616
+
617
+ // Remove originals and create symlinks
618
+ const linkName = linkNameForSkill(id);
619
+ for (const s of info.sources) {
620
+ try { rmSync(s.path, { recursive: true, force: true }); } catch { /* ok */ }
621
+ }
622
+ for (const [agentName, agentPath] of Object.entries(agents)) {
623
+ const link = join(agentPath, linkName);
624
+ try { unlinkSync(link); } catch { /* ok */ }
625
+ symlinkSync(dest, link);
626
+ }
627
+
628
+ // Update state
629
+ st.installed[id] = {
630
+ version: result.version,
631
+ installed_at: new Date().toISOString(),
632
+ path: dest,
633
+ agents: Object.keys(agents).sort(),
634
+ checksums: computeChecksums(dest),
635
+ };
636
+
637
+ console.log(` ✓ ${name} → ${id}@${result.version}`);
638
+ } catch (e) {
639
+ console.error(` ✗ ${name}: ${e.message}`);
640
+ }
641
+ }
642
+
643
+ saveState(st);
644
+ }
645
+
646
+ export async function cmdDoctor(args) {
647
+ const st = loadState();
648
+ const agents = detectAgents();
649
+ let issues = 0;
650
+
651
+ for (const [id, rec] of Object.entries(st.installed).sort()) {
652
+ const status = verifyInstall(id, rec, agents);
653
+ console.log(`${id}: ${status}`);
654
+
655
+ if (status !== 'ok' && !args.dry) {
656
+ // Attempt fix
657
+ if (status === 'broken-link') {
658
+ const linkName = linkNameForSkill(id);
659
+ for (const agentName of rec.agents) {
660
+ const agentPath = agents[agentName];
661
+ if (!agentPath) continue;
662
+ const link = join(agentPath, linkName);
663
+ try { unlinkSync(link); } catch { /* ok */ }
664
+ if (existsSync(rec.path)) {
665
+ symlinkSync(rec.path, link);
666
+ console.log(` Fixed: ${link}`);
667
+ }
668
+ }
669
+ }
670
+ issues++;
671
+ } else if (status !== 'ok') {
672
+ issues++;
673
+ }
674
+ }
675
+
676
+ // Check for unmanaged
677
+ const managedLinks = new Set(Object.keys(st.installed).map(linkNameForSkill));
678
+ for (const [agentName, agentPath] of Object.entries(agents)) {
679
+ if (!existsSync(agentPath)) continue;
680
+ for (const entry of readdirSync(agentPath)) {
681
+ if (!managedLinks.has(entry) && !entry.startsWith('.')) {
682
+ const full = join(agentPath, entry);
683
+ if (existsSync(join(full, 'SKILL.md')) || (lstatSync(full).isSymbolicLink())) {
684
+ console.log(`Unmanaged: ${agentName}/${entry} — use: skills import`);
685
+ issues++;
686
+ }
687
+ }
688
+ }
689
+ }
690
+
691
+ if (issues === 0) console.log('All good.');
692
+ else console.log(`\n${issues} issue(s) found.`);
693
+ }
694
+
695
+ export async function cmdShare(args) {
696
+ const cfg = loadConfig();
697
+ const id = await resolveSkillId(args.skill, cfg);
698
+ const { org, user, name } = splitSkillId(id);
699
+ const role = args.maintain ? 'maintainer' : 'reader';
700
+ await api.grantAccess(org, user, name, args.with, role);
701
+ console.log(`Granted ${role} to ${args.with} on ${id}`);
702
+ }
703
+
704
+ export async function cmdUnshare(args) {
705
+ const cfg = loadConfig();
706
+ const id = await resolveSkillId(args.skill, cfg);
707
+ const { org, user, name } = splitSkillId(id);
708
+ await api.revokeAccess(org, user, name, args.from);
709
+ console.log(`Revoked access for ${args.from} on ${id}`);
710
+ }
711
+
712
+ export async function cmdVisibility(args) {
713
+ const cfg = loadConfig();
714
+ const id = await resolveSkillId(args.skill, cfg);
715
+ const { org, user, name } = splitSkillId(id);
716
+
717
+ if (!args.value) {
718
+ const data = await api.getAcl(org, user, name);
719
+ console.log(data.visibility);
720
+ return;
721
+ }
722
+
723
+ await api.setVisibility(org, user, name, args.value);
724
+ console.log(`Set ${id} visibility to ${args.value}`);
725
+ }
package/package.json ADDED
@@ -0,0 +1,17 @@
1
+ {
2
+ "name": "@npeercy/skills",
3
+ "version": "0.1.0",
4
+ "description": "CLI-first skill marketplace for coding agents",
5
+ "type": "module",
6
+ "bin": {
7
+ "skills": "./bin/skills.js"
8
+ },
9
+ "scripts": {
10
+ "test": "node --test test/*.test.js"
11
+ },
12
+ "publishConfig": {
13
+ "access": "public"
14
+ },
15
+ "keywords": ["skills", "agents", "claude", "pi"],
16
+ "license": "MIT"
17
+ }