@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 +183 -0
- package/builtin-skills/creating-or-updating-a-new-skill/SKILL.md +88 -0
- package/builtin-skills/skill-sharer-help/SKILL.md +85 -0
- package/lib/api.js +78 -0
- package/lib/config.js +96 -0
- package/lib/skills.js +725 -0
- package/package.json +17 -0
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
|
+
}
|