skillsforest 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/README.md +45 -0
- package/bin/skillsforest.js +42 -0
- package/package.json +28 -0
- package/src/commands/add.js +55 -0
- package/src/commands/list.js +36 -0
- package/src/commands/login.js +29 -0
- package/src/commands/logout.js +11 -0
- package/src/commands/search.js +31 -0
- package/src/lib/auth.js +56 -0
- package/src/lib/config.js +10 -0
- package/src/lib/detector.js +28 -0
- package/src/lib/installer.js +27 -0
- package/src/lib/registry.js +63 -0
package/README.md
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# skillsforest-cli
|
|
2
|
+
|
|
3
|
+
Install and manage agent skills from [SkillsForest](https://skillsforest.ai).
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npx skillsforest add <slug>
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Or install globally:
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
npm install -g skillsforest
|
|
15
|
+
skillsforest add <slug>
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## Commands
|
|
19
|
+
|
|
20
|
+
- **`add <slug>`** – Install a skill by slug. Auto-detects agent (Cursor, Claude Code, Codex) from your project, or use `--agent cursor|claude|codex|generic` and optional `--path ./custom`.
|
|
21
|
+
- **`search <query>`** – Search the registry.
|
|
22
|
+
- **`login`** – Store your API token for private skill access.
|
|
23
|
+
- **`logout`** – Remove stored token.
|
|
24
|
+
- **`list`** – List installed skills in the current project.
|
|
25
|
+
|
|
26
|
+
## Private skills
|
|
27
|
+
|
|
28
|
+
Create a token with `skill:read` at your SkillsForest API Tokens page, then:
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
skillsforest login
|
|
32
|
+
# paste token when prompted
|
|
33
|
+
skillsforest add my-private-skill
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Or set `SKILLSFOREST_TOKEN` in the environment.
|
|
37
|
+
|
|
38
|
+
## Development
|
|
39
|
+
|
|
40
|
+
- `SKILLSFOREST_API_URL` – Registry base URL (default: `https://skillsforest.ai/api/v1/registry`).
|
|
41
|
+
- E2E: run the Laravel app with a public skill, then:
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
SKILLSFOREST_API_URL=http://skillsforest.test/api/v1/registry E2E_SLUG=your-slug ./scripts/e2e.sh
|
|
45
|
+
```
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
const { program } = require('commander');
|
|
4
|
+
const { addCommand } = require('../src/commands/add');
|
|
5
|
+
const { searchCommand } = require('../src/commands/search');
|
|
6
|
+
const { loginCommand } = require('../src/commands/login');
|
|
7
|
+
const { logoutCommand } = require('../src/commands/logout');
|
|
8
|
+
const { listCommand } = require('../src/commands/list');
|
|
9
|
+
|
|
10
|
+
program
|
|
11
|
+
.name('skillsforest')
|
|
12
|
+
.description('Install and manage agent skills from SkillsForest')
|
|
13
|
+
.version('0.1.0');
|
|
14
|
+
|
|
15
|
+
program
|
|
16
|
+
.command('add <slug>')
|
|
17
|
+
.description('Install a skill by slug from the registry')
|
|
18
|
+
.option('--agent <type>', 'Agent type: cursor, claude, codex, generic')
|
|
19
|
+
.option('--path <dir>', 'Custom install path (relative to cwd)')
|
|
20
|
+
.action(addCommand);
|
|
21
|
+
|
|
22
|
+
program
|
|
23
|
+
.command('search <query>')
|
|
24
|
+
.description('Search the registry for skills')
|
|
25
|
+
.action(searchCommand);
|
|
26
|
+
|
|
27
|
+
program
|
|
28
|
+
.command('login')
|
|
29
|
+
.description('Store your API token for private skill access')
|
|
30
|
+
.action(loginCommand);
|
|
31
|
+
|
|
32
|
+
program
|
|
33
|
+
.command('logout')
|
|
34
|
+
.description('Remove stored API token')
|
|
35
|
+
.action(logoutCommand);
|
|
36
|
+
|
|
37
|
+
program
|
|
38
|
+
.command('list')
|
|
39
|
+
.description('List installed skills in the current project')
|
|
40
|
+
.action(listCommand);
|
|
41
|
+
|
|
42
|
+
program.parse();
|
package/package.json
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "skillsforest",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Install agent skills from SkillsForest",
|
|
5
|
+
"main": "src/index.js",
|
|
6
|
+
"bin": {
|
|
7
|
+
"skillsforest": "bin/skillsforest.js"
|
|
8
|
+
},
|
|
9
|
+
"engines": {
|
|
10
|
+
"node": ">=18.0.0"
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"bin/",
|
|
14
|
+
"src/"
|
|
15
|
+
],
|
|
16
|
+
"keywords": [
|
|
17
|
+
"ai",
|
|
18
|
+
"agent",
|
|
19
|
+
"skills",
|
|
20
|
+
"cursor",
|
|
21
|
+
"claude",
|
|
22
|
+
"codex"
|
|
23
|
+
],
|
|
24
|
+
"license": "MIT",
|
|
25
|
+
"dependencies": {
|
|
26
|
+
"commander": "^12.0.0"
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
const readline = require('readline');
|
|
2
|
+
const { download } = require('../lib/registry');
|
|
3
|
+
const { detectAgent, getSkillsDir } = require('../lib/detector');
|
|
4
|
+
const { install } = require('../lib/installer');
|
|
5
|
+
|
|
6
|
+
function promptAgent() {
|
|
7
|
+
return new Promise((resolve) => {
|
|
8
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
9
|
+
rl.question('No agent directory found. Choose agent (cursor/claude/codex/generic) [cursor]: ', (answer) => {
|
|
10
|
+
rl.close();
|
|
11
|
+
const v = (answer || 'cursor').trim().toLowerCase();
|
|
12
|
+
resolve(['cursor', 'claude', 'codex', 'generic'].includes(v) ? v : 'cursor');
|
|
13
|
+
});
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async function addCommand(slug, opts) {
|
|
18
|
+
let agent = opts.agent;
|
|
19
|
+
const cwd = process.cwd();
|
|
20
|
+
|
|
21
|
+
if (!agent) {
|
|
22
|
+
agent = detectAgent(cwd);
|
|
23
|
+
if (!agent) {
|
|
24
|
+
agent = await promptAgent();
|
|
25
|
+
}
|
|
26
|
+
} else {
|
|
27
|
+
agent = agent.toLowerCase();
|
|
28
|
+
if (!['cursor', 'claude', 'codex', 'generic'].includes(agent)) {
|
|
29
|
+
console.error('Invalid --agent. Use: cursor, claude, codex, generic');
|
|
30
|
+
process.exit(1);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const targetDir = opts.path ? require('path').resolve(cwd, opts.path) : null;
|
|
35
|
+
|
|
36
|
+
try {
|
|
37
|
+
const data = await download(slug);
|
|
38
|
+
if (!data.files || !Array.isArray(data.files)) {
|
|
39
|
+
throw new Error('Invalid response: missing files');
|
|
40
|
+
}
|
|
41
|
+
const skillDir = install(slug, data.files, agent, targetDir);
|
|
42
|
+
console.log(`Installed "${data.skill}" (v${data.version || '1.0.0'}) to ${skillDir}`);
|
|
43
|
+
} catch (err) {
|
|
44
|
+
if (err.status === 404) {
|
|
45
|
+
console.error(`Skill "${slug}" not found. It may be private (use "skillsforest login") or the slug may be wrong.`);
|
|
46
|
+
} else if (err.status === 403) {
|
|
47
|
+
console.error('Access denied. Token may be missing or invalid (try "skillsforest login").');
|
|
48
|
+
} else {
|
|
49
|
+
console.error(err.message || err);
|
|
50
|
+
}
|
|
51
|
+
process.exit(1);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
module.exports = { addCommand };
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const { getSkillsDir, AGENT_DIRS } = require('../lib/detector');
|
|
4
|
+
|
|
5
|
+
function listCommand() {
|
|
6
|
+
const cwd = process.cwd();
|
|
7
|
+
const found = [];
|
|
8
|
+
|
|
9
|
+
for (const [agent, dirName] of Object.entries(AGENT_DIRS)) {
|
|
10
|
+
const skillsDir = path.join(cwd, dirName, 'skills');
|
|
11
|
+
if (!fs.existsSync(skillsDir)) continue;
|
|
12
|
+
const entries = fs.readdirSync(skillsDir, { withFileTypes: true });
|
|
13
|
+
for (const e of entries) {
|
|
14
|
+
if (e.isDirectory()) {
|
|
15
|
+
const skillPath = path.join(skillsDir, e.name);
|
|
16
|
+
const hasSkillMd = fs.existsSync(path.join(skillPath, 'SKILL.md'));
|
|
17
|
+
if (hasSkillMd) {
|
|
18
|
+
found.push({ agent, slug: e.name });
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
if (found.length === 0) {
|
|
25
|
+
console.log('No installed skills found in this project.');
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
console.log('AGENT'.padEnd(12) + 'SLUG');
|
|
30
|
+
console.log('-'.repeat(40));
|
|
31
|
+
for (const { agent, slug } of found) {
|
|
32
|
+
console.log(agent.padEnd(12) + slug);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
module.exports = { listCommand };
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
const readline = require('readline');
|
|
2
|
+
const { saveToken } = require('../lib/auth');
|
|
3
|
+
const { getRegistryBaseUrl } = require('../lib/config');
|
|
4
|
+
|
|
5
|
+
async function loginCommand() {
|
|
6
|
+
const base = getRegistryBaseUrl();
|
|
7
|
+
const baseUrl = base.replace(/\/api\/v1\/registry\/?$/, '');
|
|
8
|
+
const tokenUrl = baseUrl ? `${baseUrl}/user/api-tokens` : 'https://skillsforest.ai/user/api-tokens';
|
|
9
|
+
console.log(`Create a token at: ${tokenUrl}`);
|
|
10
|
+
console.log('Paste your token (with skill:read for private skills):');
|
|
11
|
+
|
|
12
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
13
|
+
const token = await new Promise((resolve) => {
|
|
14
|
+
rl.question('Token: ', (answer) => {
|
|
15
|
+
rl.close();
|
|
16
|
+
resolve((answer || '').trim());
|
|
17
|
+
});
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
if (!token) {
|
|
21
|
+
console.error('No token provided.');
|
|
22
|
+
process.exit(1);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
saveToken(token);
|
|
26
|
+
console.log('Token saved. You can now install private skills.');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
module.exports = { loginCommand };
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
const { removeCredentials } = require('../lib/auth');
|
|
2
|
+
|
|
3
|
+
function logoutCommand() {
|
|
4
|
+
if (removeCredentials()) {
|
|
5
|
+
console.log('Logged out. Stored token removed.');
|
|
6
|
+
} else {
|
|
7
|
+
console.log('No stored token found.');
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
module.exports = { logoutCommand };
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
const { search } = require('../lib/registry');
|
|
2
|
+
|
|
3
|
+
function formatRow(name, description, author, maxDesc = 60) {
|
|
4
|
+
const desc = (description || '').slice(0, maxDesc) + ((description || '').length > maxDesc ? '…' : '');
|
|
5
|
+
return `${name.padEnd(24)} ${desc.padEnd(maxDesc + 1)} ${(author || '').padEnd(16)}`;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
async function searchCommand(query) {
|
|
9
|
+
try {
|
|
10
|
+
const data = await search(query);
|
|
11
|
+
const items = data.data || [];
|
|
12
|
+
if (items.length === 0) {
|
|
13
|
+
console.log('No skills found.');
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
console.log(formatRow('NAME', 'DESCRIPTION', 'AUTHOR'));
|
|
17
|
+
console.log('-'.repeat(24 + 62 + 17));
|
|
18
|
+
for (const s of items) {
|
|
19
|
+
console.log(formatRow(s.slug || s.name, s.description, s.author));
|
|
20
|
+
}
|
|
21
|
+
const total = data.meta?.total ?? items.length;
|
|
22
|
+
if (total > items.length) {
|
|
23
|
+
console.log(`\n... and ${total - items.length} more.`);
|
|
24
|
+
}
|
|
25
|
+
} catch (err) {
|
|
26
|
+
console.error(err.message || err);
|
|
27
|
+
process.exit(1);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
module.exports = { searchCommand };
|
package/src/lib/auth.js
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const os = require('os');
|
|
4
|
+
|
|
5
|
+
const CREDENTIALS_DIR = path.join(os.homedir(), '.skillsforest');
|
|
6
|
+
const CREDENTIALS_FILE = path.join(CREDENTIALS_DIR, 'credentials');
|
|
7
|
+
|
|
8
|
+
function getToken() {
|
|
9
|
+
if (process.env.SKILLSFOREST_TOKEN) {
|
|
10
|
+
return process.env.SKILLSFOREST_TOKEN.trim();
|
|
11
|
+
}
|
|
12
|
+
try {
|
|
13
|
+
if (fs.existsSync(CREDENTIALS_FILE)) {
|
|
14
|
+
const data = JSON.parse(fs.readFileSync(CREDENTIALS_FILE, 'utf8'));
|
|
15
|
+
return data.token || null;
|
|
16
|
+
}
|
|
17
|
+
} catch {
|
|
18
|
+
// ignore
|
|
19
|
+
}
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function saveToken(token) {
|
|
24
|
+
if (!fs.existsSync(CREDENTIALS_DIR)) {
|
|
25
|
+
fs.mkdirSync(CREDENTIALS_DIR, { mode: 0o700 });
|
|
26
|
+
}
|
|
27
|
+
fs.writeFileSync(
|
|
28
|
+
CREDENTIALS_FILE,
|
|
29
|
+
JSON.stringify({ token: token.trim() }, null, 2),
|
|
30
|
+
{ mode: 0o600 }
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function removeCredentials() {
|
|
35
|
+
try {
|
|
36
|
+
if (fs.existsSync(CREDENTIALS_FILE)) {
|
|
37
|
+
fs.unlinkSync(CREDENTIALS_FILE);
|
|
38
|
+
return true;
|
|
39
|
+
}
|
|
40
|
+
} catch {
|
|
41
|
+
// ignore
|
|
42
|
+
}
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function isLoggedIn() {
|
|
47
|
+
return !!getToken();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
module.exports = {
|
|
51
|
+
getToken,
|
|
52
|
+
saveToken,
|
|
53
|
+
removeCredentials,
|
|
54
|
+
isLoggedIn,
|
|
55
|
+
CREDENTIALS_FILE,
|
|
56
|
+
};
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
|
|
4
|
+
const AGENT_DIRS = {
|
|
5
|
+
cursor: '.cursor',
|
|
6
|
+
claude: '.claude',
|
|
7
|
+
codex: '.codex',
|
|
8
|
+
generic: '.agent',
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
function detectAgent(cwd = process.cwd()) {
|
|
12
|
+
if (fs.existsSync(path.join(cwd, '.cursor'))) return 'cursor';
|
|
13
|
+
if (fs.existsSync(path.join(cwd, '.claude'))) return 'claude';
|
|
14
|
+
if (fs.existsSync(path.join(cwd, '.codex'))) return 'codex';
|
|
15
|
+
if (fs.existsSync(path.join(cwd, 'AGENTS.md'))) return 'codex';
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function getSkillsDir(agent, cwd = process.cwd()) {
|
|
20
|
+
const dir = AGENT_DIRS[agent] || AGENT_DIRS.generic;
|
|
21
|
+
return path.join(cwd, dir, 'skills');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
module.exports = {
|
|
25
|
+
detectAgent,
|
|
26
|
+
getSkillsDir,
|
|
27
|
+
AGENT_DIRS,
|
|
28
|
+
};
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const { getSkillsDir } = require('./detector');
|
|
4
|
+
|
|
5
|
+
function install(slug, files, agent, targetDir) {
|
|
6
|
+
const baseDir = targetDir || getSkillsDir(agent);
|
|
7
|
+
const skillDir = path.join(baseDir, slug);
|
|
8
|
+
if (!fs.existsSync(baseDir)) {
|
|
9
|
+
fs.mkdirSync(baseDir, { recursive: true });
|
|
10
|
+
}
|
|
11
|
+
if (!fs.existsSync(skillDir)) {
|
|
12
|
+
fs.mkdirSync(skillDir, { recursive: true });
|
|
13
|
+
}
|
|
14
|
+
for (const { path: filePath, content } of files) {
|
|
15
|
+
const fullPath = path.join(skillDir, filePath);
|
|
16
|
+
const dir = path.dirname(fullPath);
|
|
17
|
+
if (!fs.existsSync(dir)) {
|
|
18
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
19
|
+
}
|
|
20
|
+
fs.writeFileSync(fullPath, content, 'utf8');
|
|
21
|
+
}
|
|
22
|
+
return skillDir;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
module.exports = {
|
|
26
|
+
install,
|
|
27
|
+
};
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
const { getRegistryBaseUrl } = require('./config');
|
|
2
|
+
const { getToken } = require('./auth');
|
|
3
|
+
|
|
4
|
+
async function fetchRegistry(pathname, options = {}) {
|
|
5
|
+
const base = getRegistryBaseUrl().replace(/\/$/, '');
|
|
6
|
+
const url = `${base}${pathname.startsWith('/') ? pathname : `/${pathname}`}`;
|
|
7
|
+
const headers = { Accept: 'application/json', ...options.headers };
|
|
8
|
+
const token = getToken();
|
|
9
|
+
if (token) {
|
|
10
|
+
headers.Authorization = `Bearer ${token}`;
|
|
11
|
+
}
|
|
12
|
+
const res = await fetch(url, { ...options, headers });
|
|
13
|
+
const body = await res.text();
|
|
14
|
+
|
|
15
|
+
if (!res.ok) {
|
|
16
|
+
let msg = `Registry request failed: ${res.status} ${res.statusText}`;
|
|
17
|
+
try {
|
|
18
|
+
const j = JSON.parse(body);
|
|
19
|
+
if (j.message) msg = j.message;
|
|
20
|
+
} catch {
|
|
21
|
+
if (body) msg = body;
|
|
22
|
+
}
|
|
23
|
+
const err = new Error(msg);
|
|
24
|
+
err.status = res.status;
|
|
25
|
+
throw err;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const contentType = res.headers.get('content-type') || '';
|
|
29
|
+
if (!contentType.includes('application/json')) {
|
|
30
|
+
const preview = body.trimStart().slice(0, 80);
|
|
31
|
+
throw new Error(
|
|
32
|
+
`Registry returned non-JSON (${contentType || 'unknown type'}). ` +
|
|
33
|
+
(body.trimStart().startsWith('<')
|
|
34
|
+
? 'Got HTML — the API URL may be wrong or the service may be returning an error page.'
|
|
35
|
+
: `Body preview: ${preview}`)
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
try {
|
|
39
|
+
return JSON.parse(body);
|
|
40
|
+
} catch (e) {
|
|
41
|
+
const preview = body.trimStart().slice(0, 80);
|
|
42
|
+
throw new Error(`Invalid JSON from registry. Body preview: ${preview}`);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function search(query, params = {}) {
|
|
47
|
+
const sp = new URLSearchParams({ q: query, ...params });
|
|
48
|
+
return fetchRegistry(`/?${sp}`);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function show(slug) {
|
|
52
|
+
return fetchRegistry(`/${encodeURIComponent(slug)}`);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function download(slug) {
|
|
56
|
+
return fetchRegistry(`/${encodeURIComponent(slug)}/download`);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
module.exports = {
|
|
60
|
+
search,
|
|
61
|
+
show,
|
|
62
|
+
download,
|
|
63
|
+
};
|