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 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 };
@@ -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,10 @@
1
+ const DEFAULT_REGISTRY_URL = 'https://skillsforest.ai/api/v1/registry';
2
+
3
+ function getRegistryBaseUrl() {
4
+ return process.env.SKILLSFOREST_API_URL || DEFAULT_REGISTRY_URL;
5
+ }
6
+
7
+ module.exports = {
8
+ DEFAULT_REGISTRY_URL,
9
+ getRegistryBaseUrl,
10
+ };
@@ -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
+ };