skill-os 0.1.2 → 0.1.5
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/lib/auth.js +114 -0
- package/lib/config.js +95 -0
- package/lib/local.js +173 -0
- package/lib/remote.js +304 -0
- package/lib/sync.js +190 -0
- package/lib/utils.js +139 -0
- package/package.json +2 -2
- package/skill-os.js +180 -621
package/lib/auth.js
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 认证命令
|
|
3
|
+
*
|
|
4
|
+
* BUC SSO 登录/注销,凭据保存到 ~/.skill-os/credentials.json。
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const chalk = require('chalk');
|
|
8
|
+
const { execSync } = require('child_process');
|
|
9
|
+
|
|
10
|
+
const {
|
|
11
|
+
CREDENTIALS_FILE,
|
|
12
|
+
getApiBase,
|
|
13
|
+
loadCredentials,
|
|
14
|
+
saveCredentials,
|
|
15
|
+
} = require('./config');
|
|
16
|
+
|
|
17
|
+
// ── login ────────────────────────────────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* skill-os login [--url <server>]
|
|
21
|
+
*
|
|
22
|
+
* 流程:
|
|
23
|
+
* 1. CLI 启动本地临时 HTTP 服务器监听随机端口
|
|
24
|
+
* 2. 打开浏览器访问 <server>/api/v1/auth/cli-login?redirect=http://127.0.0.1:<port>/cli-callback
|
|
25
|
+
* 3. 服务端完成 BUC SSO 认证后,将 PAT 重定向到本地回调地址
|
|
26
|
+
* 4. 本地服务器接收 token,保存到 ~/.skill-os/credentials.json
|
|
27
|
+
*/
|
|
28
|
+
async function cmdLogin(options) {
|
|
29
|
+
const http = require('http');
|
|
30
|
+
const serverUrl = getApiBase(options);
|
|
31
|
+
const authBase = serverUrl.replace(/\/api\/v1$/, '');
|
|
32
|
+
|
|
33
|
+
console.log(`\n${chalk.bold('🔐 skill-os login')}`);
|
|
34
|
+
console.log(`${chalk.dim('─'.repeat(50))}`);
|
|
35
|
+
console.log(` Server: ${chalk.cyan(authBase)}`);
|
|
36
|
+
console.log(` ${chalk.dim('等待浏览器认证,请稍候...')}`);
|
|
37
|
+
|
|
38
|
+
let token = null;
|
|
39
|
+
await new Promise((resolve, reject) => {
|
|
40
|
+
const server = http.createServer((req, res) => {
|
|
41
|
+
const fullUrl = new URL(req.url, `http://127.0.0.1:${server.address().port}`);
|
|
42
|
+
if (fullUrl.pathname !== '/cli-callback') {
|
|
43
|
+
res.writeHead(404); res.end(); return;
|
|
44
|
+
}
|
|
45
|
+
token = fullUrl.searchParams.get('token');
|
|
46
|
+
const html = token
|
|
47
|
+
? '<html><body style="font-family:sans-serif;text-align:center;padding:60px">'
|
|
48
|
+
+ '<h2>✅ 登录成功!</h2><p>请返回终端继续操作。</p>'
|
|
49
|
+
+ '<script>setTimeout(()=>window.close(),2000)</script></body></html>'
|
|
50
|
+
: '<html><body style="font-family:sans-serif;text-align:center;padding:60px">'
|
|
51
|
+
+ '<h2>❌ 登录失败</h2><p>未收到 token,请重试。</p></body></html>';
|
|
52
|
+
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
53
|
+
res.end(html);
|
|
54
|
+
setTimeout(() => { server.close(); resolve(); }, 500);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
server.on('error', reject);
|
|
58
|
+
|
|
59
|
+
server.listen(0, '127.0.0.1', () => {
|
|
60
|
+
const port = server.address().port;
|
|
61
|
+
const localCallbackUrl = `http://127.0.0.1:${port}/cli-callback`;
|
|
62
|
+
const loginUrl = `${authBase}/api/v1/auth/cli-login?redirect=${encodeURIComponent(localCallbackUrl)}`;
|
|
63
|
+
|
|
64
|
+
console.log(`\n ${chalk.dim('如果浏览器未自动打开,请手动访问:')}`);
|
|
65
|
+
console.log(` ${chalk.cyan(loginUrl)}\n`);
|
|
66
|
+
|
|
67
|
+
try {
|
|
68
|
+
const platform = process.platform;
|
|
69
|
+
if (platform === 'darwin') execSync(`open "${loginUrl}"`);
|
|
70
|
+
else if (platform === 'win32') execSync(`start "" "${loginUrl}"`);
|
|
71
|
+
else execSync(`xdg-open "${loginUrl}"`);
|
|
72
|
+
} catch { /* 忽略,用户可手动打开 */ }
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
const timeout = setTimeout(() => {
|
|
76
|
+
server.close();
|
|
77
|
+
reject(new Error('Login timeout (90s). Please try again.'));
|
|
78
|
+
}, 90_000);
|
|
79
|
+
server.on('close', () => clearTimeout(timeout));
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
if (!token) {
|
|
83
|
+
console.error(chalk.red('\n✗ Login failed: no token received.'));
|
|
84
|
+
process.exit(1);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// 保存凭据
|
|
88
|
+
const creds = loadCredentials();
|
|
89
|
+
creds[serverUrl] = token;
|
|
90
|
+
saveCredentials(creds);
|
|
91
|
+
|
|
92
|
+
console.log(chalk.green(`\n✅ Login successful!`));
|
|
93
|
+
console.log(` Token saved to: ${chalk.dim(CREDENTIALS_FILE)}`);
|
|
94
|
+
console.log(` Server: ${chalk.cyan(serverUrl)}\n`);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// ── logout ───────────────────────────────────────────────────────────────
|
|
98
|
+
|
|
99
|
+
async function cmdLogout(options) {
|
|
100
|
+
const serverUrl = getApiBase(options);
|
|
101
|
+
const creds = loadCredentials();
|
|
102
|
+
|
|
103
|
+
if (!creds[serverUrl]) {
|
|
104
|
+
console.log(chalk.yellow(`\n⚠ No token found for: ${serverUrl}`));
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
delete creds[serverUrl];
|
|
109
|
+
saveCredentials(creds);
|
|
110
|
+
console.log(chalk.green(`\n✅ Logged out from: ${serverUrl}`));
|
|
111
|
+
console.log(` Credentials file: ${chalk.dim(CREDENTIALS_FILE)}\n`);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
module.exports = { cmdLogin, cmdLogout };
|
package/lib/config.js
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* API 配置 & 凭据管理
|
|
3
|
+
*
|
|
4
|
+
* 管理远程注册中心地址、本地凭据存储和通用 API 请求逻辑。
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const fs = require('fs');
|
|
8
|
+
const path = require('path');
|
|
9
|
+
const os = require('os');
|
|
10
|
+
const chalk = require('chalk');
|
|
11
|
+
|
|
12
|
+
// ── API 地址 ─────────────────────────────────────────────────────────────
|
|
13
|
+
|
|
14
|
+
const DEFAULT_API_BASE = "https://oscopilot.alibaba-inc.com/skills/api/v1";
|
|
15
|
+
const DEFAULT_DAILY_API_BASE = "https://oscopilot.alibaba-inc.com/skills/api/v1";
|
|
16
|
+
|
|
17
|
+
function getApiBase(options) {
|
|
18
|
+
let url = options?.url || process.env.SKILL_OS_REGISTRY || DEFAULT_API_BASE;
|
|
19
|
+
return url.replace(/\/$/, "");
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// ── 凭据管理 (~/.skill-os/credentials.json) ─────────────────────────────
|
|
23
|
+
|
|
24
|
+
const CREDENTIALS_DIR = path.join(os.homedir(), '.skill-os');
|
|
25
|
+
const CREDENTIALS_FILE = path.join(CREDENTIALS_DIR, 'credentials.json');
|
|
26
|
+
|
|
27
|
+
function loadCredentials() {
|
|
28
|
+
try {
|
|
29
|
+
if (!fs.existsSync(CREDENTIALS_FILE)) return {};
|
|
30
|
+
return JSON.parse(fs.readFileSync(CREDENTIALS_FILE, 'utf-8'));
|
|
31
|
+
} catch {
|
|
32
|
+
return {};
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function saveCredentials(data) {
|
|
37
|
+
fs.mkdirSync(CREDENTIALS_DIR, { recursive: true, mode: 0o700 });
|
|
38
|
+
fs.writeFileSync(CREDENTIALS_FILE, JSON.stringify(data, null, 2), { encoding: 'utf-8', mode: 0o600 });
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function getSavedToken(serverUrl) {
|
|
42
|
+
const creds = loadCredentials();
|
|
43
|
+
return creds[serverUrl] || creds[serverUrl.replace(/\/api\/v1$/, '')] || null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// ── 通用 API 请求 ───────────────────────────────────────────────────────
|
|
47
|
+
|
|
48
|
+
async function fetchFromApi(endpoint, options = {}) {
|
|
49
|
+
let fetchFn = require('node-fetch');
|
|
50
|
+
if (typeof fetchFn !== 'function' && fetchFn.default) {
|
|
51
|
+
fetchFn = fetchFn.default;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
let baseUrl;
|
|
55
|
+
if (options && options.url) {
|
|
56
|
+
baseUrl = getApiBase({ url: options.url });
|
|
57
|
+
delete options.url;
|
|
58
|
+
} else {
|
|
59
|
+
baseUrl = getApiBase({});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const url = `${baseUrl}${endpoint}`;
|
|
63
|
+
|
|
64
|
+
// 自动携带本地保存的 Token
|
|
65
|
+
const savedToken = getSavedToken(baseUrl);
|
|
66
|
+
if (savedToken) {
|
|
67
|
+
options.headers = options.headers || {};
|
|
68
|
+
if (!options.headers['Authorization']) {
|
|
69
|
+
options.headers['Authorization'] = `Bearer ${savedToken}`;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
try {
|
|
74
|
+
const response = await fetchFn(url, options);
|
|
75
|
+
if (!response.ok) {
|
|
76
|
+
throw new Error(`HTTP Error: ${response.status} ${response.statusText}`);
|
|
77
|
+
}
|
|
78
|
+
return await response.json();
|
|
79
|
+
} catch (error) {
|
|
80
|
+
console.error(chalk.red(`\n✗ Error connecting to remote registry at ${url}`));
|
|
81
|
+
console.error(chalk.dim(error.message));
|
|
82
|
+
process.exit(1);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
module.exports = {
|
|
87
|
+
DEFAULT_API_BASE,
|
|
88
|
+
DEFAULT_DAILY_API_BASE,
|
|
89
|
+
CREDENTIALS_FILE,
|
|
90
|
+
getApiBase,
|
|
91
|
+
loadCredentials,
|
|
92
|
+
saveCredentials,
|
|
93
|
+
getSavedToken,
|
|
94
|
+
fetchFromApi,
|
|
95
|
+
};
|
package/lib/local.js
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 本地开发命令
|
|
3
|
+
*
|
|
4
|
+
* 不需要网络的本地命令:创建技能脚手架、本地校验。
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const fs = require('fs');
|
|
8
|
+
const path = require('path');
|
|
9
|
+
const chalk = require('chalk');
|
|
10
|
+
|
|
11
|
+
const {
|
|
12
|
+
parseSkillMdFrontmatter,
|
|
13
|
+
skillSchema,
|
|
14
|
+
validateDirectoryStructure,
|
|
15
|
+
Validator,
|
|
16
|
+
} = require('./utils');
|
|
17
|
+
|
|
18
|
+
// ── create ──────────────────────────────────────────────────────────────
|
|
19
|
+
|
|
20
|
+
function cmdCreate(skillPath, options) {
|
|
21
|
+
const targetDir = path.resolve(skillPath);
|
|
22
|
+
|
|
23
|
+
if (fs.existsSync(targetDir)) {
|
|
24
|
+
if (!options.force) {
|
|
25
|
+
console.error(chalk.red(`Error: Directory already exists: ${targetDir}`));
|
|
26
|
+
console.log(chalk.dim("Use --force to overwrite"));
|
|
27
|
+
process.exit(1);
|
|
28
|
+
}
|
|
29
|
+
fs.rmSync(targetDir, { recursive: true, force: true });
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
fs.mkdirSync(targetDir, { recursive: true });
|
|
33
|
+
|
|
34
|
+
const parts = skillPath.split('/');
|
|
35
|
+
if (parts.length < 3) {
|
|
36
|
+
console.error(chalk.red("Error: Invalid path format"));
|
|
37
|
+
console.log(chalk.dim("Expected: <layer>/<category>/<skill>"));
|
|
38
|
+
console.log(chalk.dim("Example: system/security/cve-repair"));
|
|
39
|
+
process.exit(1);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const layer = parts[0];
|
|
43
|
+
const category = parts[1];
|
|
44
|
+
const skillName = parts[parts.length - 1];
|
|
45
|
+
|
|
46
|
+
const validLayers = ["core", "system", "runtime", "application"];
|
|
47
|
+
if (!validLayers.includes(layer)) {
|
|
48
|
+
console.log(chalk.yellow(`Warning: Layer '${layer}' not in spec`));
|
|
49
|
+
console.log(chalk.dim(`Valid layers: ${validLayers.join(', ')}`));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const titleCaseName = skillName.split('-').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' ');
|
|
53
|
+
|
|
54
|
+
const skillMdContent = `---
|
|
55
|
+
name: ${skillName}
|
|
56
|
+
version: 0.1.0
|
|
57
|
+
description: TODO: Add skill description here
|
|
58
|
+
author: Your Name
|
|
59
|
+
|
|
60
|
+
layer: ${layer}
|
|
61
|
+
category: ${category}
|
|
62
|
+
lifecycle: usage # production | maintenance | operations | usage | meta
|
|
63
|
+
|
|
64
|
+
# Tags: first tag MUST be the category name
|
|
65
|
+
tags:
|
|
66
|
+
- ${category}
|
|
67
|
+
- TODO
|
|
68
|
+
|
|
69
|
+
status: placeholder
|
|
70
|
+
dependencies: []
|
|
71
|
+
|
|
72
|
+
# Optional: Permissions (for security classification)
|
|
73
|
+
# permissions:
|
|
74
|
+
# requires_root: false
|
|
75
|
+
# dangerous_operations: []
|
|
76
|
+
---
|
|
77
|
+
|
|
78
|
+
# ${titleCaseName}
|
|
79
|
+
|
|
80
|
+
TODO: Add skill documentation here.
|
|
81
|
+
|
|
82
|
+
## 能力概览
|
|
83
|
+
|
|
84
|
+
- TODO: List capabilities
|
|
85
|
+
|
|
86
|
+
## 使用示例
|
|
87
|
+
|
|
88
|
+
\`\`\`bash
|
|
89
|
+
# TODO: Add usage examples
|
|
90
|
+
\`\`\`
|
|
91
|
+
|
|
92
|
+
## TODO
|
|
93
|
+
|
|
94
|
+
- [ ] Implement core functionality
|
|
95
|
+
- [ ] Add scripts if needed
|
|
96
|
+
- [ ] Run: skill-os validate ${skillPath}
|
|
97
|
+
- [ ] Run: skill-os sync ${skillPath}
|
|
98
|
+
`;
|
|
99
|
+
|
|
100
|
+
const skillMdPath = path.join(targetDir, "SKILL.md");
|
|
101
|
+
fs.writeFileSync(skillMdPath, skillMdContent, 'utf-8');
|
|
102
|
+
|
|
103
|
+
console.log(`\n${chalk.green('✓ Created skill scaffold:')}`);
|
|
104
|
+
console.log(` ${chalk.cyan(targetDir)}`);
|
|
105
|
+
console.log(" └── SKILL.md");
|
|
106
|
+
|
|
107
|
+
console.log(`\n${chalk.bold('📋 Next Steps:')}`);
|
|
108
|
+
console.log(` 1. Edit ${chalk.cyan(skillMdPath)}`);
|
|
109
|
+
console.log(` 2. Validate: ${chalk.dim(`skill-os validate ${skillPath}`)}`);
|
|
110
|
+
console.log(` 3. Sync: ${chalk.dim(`skill-os sync ${skillPath}`)}`);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// ── validate ────────────────────────────────────────────────────────────
|
|
114
|
+
|
|
115
|
+
function cmdValidate(skillPath) {
|
|
116
|
+
const skillDir = path.resolve(skillPath);
|
|
117
|
+
const skillMdPath = path.join(skillDir, 'SKILL.md');
|
|
118
|
+
|
|
119
|
+
console.log(`\n${chalk.bold(`🔍 Validating Skill: ${skillPath}`)}`);
|
|
120
|
+
console.log(`${chalk.dim('─'.repeat(50))}\n`);
|
|
121
|
+
|
|
122
|
+
const { isValid: isDirValid, errors: dirErrors } = validateDirectoryStructure(skillDir);
|
|
123
|
+
if (isDirValid) {
|
|
124
|
+
console.log(`${chalk.green('✓ Directory structure valid')}`);
|
|
125
|
+
} else {
|
|
126
|
+
console.log(`${chalk.red('✗ Directory structure errors:')}`);
|
|
127
|
+
dirErrors.forEach(err => console.log(` - ${err}`));
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (!fs.existsSync(skillMdPath)) {
|
|
131
|
+
console.log(`${chalk.red('✗ Fatal: SKILL.md not found')}`);
|
|
132
|
+
process.exit(1);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const fm = parseSkillMdFrontmatter(skillMdPath);
|
|
136
|
+
|
|
137
|
+
if (!fm || Object.keys(fm).length === 0) {
|
|
138
|
+
console.log(`${chalk.red('✗ SKILL.md missing or invalid frontmatter')}`);
|
|
139
|
+
process.exit(1);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const v = new Validator();
|
|
143
|
+
const result = v.validate(fm, skillSchema);
|
|
144
|
+
|
|
145
|
+
// Custom tag validation: first tag MUST be category
|
|
146
|
+
if (fm.category && fm.tags && fm.tags.length > 0) {
|
|
147
|
+
if (fm.tags[0] !== fm.category) {
|
|
148
|
+
result.errors.push({ stack: `tags[0] must equal category name '${fm.category}'` });
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (result.valid && result.errors.length === 0) {
|
|
153
|
+
console.log(`${chalk.green('✓ SKILL.md is spec compliant')}`);
|
|
154
|
+
console.log(`\n${chalk.bold('📋 Parsed Metadata:')}`);
|
|
155
|
+
console.log(` name: ${fm.name}`);
|
|
156
|
+
console.log(` version: ${fm.version}`);
|
|
157
|
+
console.log(` layer: ${fm.layer}`);
|
|
158
|
+
console.log(` lifecycle: ${fm.lifecycle}`);
|
|
159
|
+
console.log(` category: ${fm.category || '(missing)'}`);
|
|
160
|
+
console.log(` tags: ${JSON.stringify(fm.tags || [])}`);
|
|
161
|
+
if (fm.permissions) {
|
|
162
|
+
console.log(` requires_root: ${fm.permissions.requires_root}`);
|
|
163
|
+
}
|
|
164
|
+
} else {
|
|
165
|
+
console.log(`${chalk.red('✗ SKILL.md validation errors:')}`);
|
|
166
|
+
result.errors.forEach(err => console.log(` - ${err.stack.replace('instance.', '')}`));
|
|
167
|
+
process.exit(1);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
console.log(`\n${chalk.green('✅ Validation passed!')}\n`);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
module.exports = { cmdCreate, cmdValidate };
|
package/lib/remote.js
ADDED
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 远程注册中心命令
|
|
3
|
+
*
|
|
4
|
+
* 需要与 Server API 交互的命令:列表、搜索、详情、下载、上传。
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const fs = require('fs');
|
|
8
|
+
const path = require('path');
|
|
9
|
+
const chalk = require('chalk');
|
|
10
|
+
const { execSync } = require('child_process');
|
|
11
|
+
const FormData = require('form-data');
|
|
12
|
+
|
|
13
|
+
const { formatLayerIcon, formatStatus, layerIcons } = require('./utils');
|
|
14
|
+
const {
|
|
15
|
+
parseSkillMdFrontmatter,
|
|
16
|
+
skillSchema,
|
|
17
|
+
validateDirectoryStructure,
|
|
18
|
+
Validator,
|
|
19
|
+
} = require('./utils');
|
|
20
|
+
const { fetchFromApi, getApiBase, getSavedToken } = require('./config');
|
|
21
|
+
|
|
22
|
+
// ── list ─────────────────────────────────────────────────────────────────
|
|
23
|
+
|
|
24
|
+
async function cmdList(options) {
|
|
25
|
+
console.log(`\n${chalk.dim('Fetching skills from external registry...')}`);
|
|
26
|
+
const index = await fetchFromApi('/skills', options);
|
|
27
|
+
|
|
28
|
+
let skillsList = [];
|
|
29
|
+
if (Array.isArray(index)) {
|
|
30
|
+
skillsList = index;
|
|
31
|
+
} else if (Array.isArray(index.data)) {
|
|
32
|
+
skillsList = index.data;
|
|
33
|
+
} else if (Array.isArray(index.skills)) {
|
|
34
|
+
skillsList = index.skills;
|
|
35
|
+
} else if (typeof index === 'object') {
|
|
36
|
+
skillsList = Object.entries(index.skills || index).map(([k, v]) => ({ path: k, ...v }));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
console.log(`\n${chalk.bold('📚 Skill-OS Available Skills')}`);
|
|
40
|
+
console.log(`${chalk.dim('─'.repeat(60))}\n`);
|
|
41
|
+
|
|
42
|
+
const layers = {};
|
|
43
|
+
for (const info of skillsList) {
|
|
44
|
+
const skillPath = info.path || (info.layer ? `${info.layer}/${info.category || 'misc'}/${info.name}` : info.name || 'unknown');
|
|
45
|
+
const layer = info.layer || (skillPath.includes('/') ? skillPath.split('/')[0] : 'misc');
|
|
46
|
+
|
|
47
|
+
if (!layers[layer]) layers[layer] = [];
|
|
48
|
+
layers[layer].push({ path: skillPath, info });
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
for (const layer of Object.keys(layers).sort()) {
|
|
52
|
+
const icon = layerIcons[layer] || "📄";
|
|
53
|
+
console.log(`${chalk.bold(icon + ' ' + layer.toUpperCase())}`);
|
|
54
|
+
|
|
55
|
+
const sortedSkills = layers[layer].sort((a, b) => a.path.localeCompare(b.path));
|
|
56
|
+
|
|
57
|
+
for (const { path: skillPath, info } of sortedSkills) {
|
|
58
|
+
const status = formatStatus(info.status);
|
|
59
|
+
const name = info.name || skillPath;
|
|
60
|
+
const version = info.version || '?';
|
|
61
|
+
const desc = (info.description || '').substring(0, 50);
|
|
62
|
+
|
|
63
|
+
console.log(` ${chalk.cyan(skillPath)}`);
|
|
64
|
+
console.log(` ${name} (${version}) [${status}]`);
|
|
65
|
+
console.log(` ${chalk.dim(desc + '...')}`);
|
|
66
|
+
}
|
|
67
|
+
console.log();
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ── search ───────────────────────────────────────────────────────────────
|
|
72
|
+
|
|
73
|
+
async function cmdSearch(query, options) {
|
|
74
|
+
console.log(`\n${chalk.dim(`Searching remote registry for '${query}'...`)}`);
|
|
75
|
+
|
|
76
|
+
const encodedQuery = encodeURIComponent(query);
|
|
77
|
+
const response = await fetchFromApi(`/search?q=${encodedQuery}`, options);
|
|
78
|
+
|
|
79
|
+
let matches = [];
|
|
80
|
+
const rawResults = response.data || response.results || response.skills || response || [];
|
|
81
|
+
|
|
82
|
+
if (Array.isArray(rawResults)) {
|
|
83
|
+
matches = rawResults;
|
|
84
|
+
} else if (typeof rawResults === 'object') {
|
|
85
|
+
for (const [skillPath, info] of Object.entries(rawResults)) {
|
|
86
|
+
matches.push({ path: skillPath, ...info });
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (matches.length === 0) {
|
|
91
|
+
console.log(chalk.yellow(`No skills found matching '${query}'`));
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
console.log(`\n${chalk.bold(`🔍 Search Results for '${query}'`)}`);
|
|
96
|
+
console.log(`${chalk.dim(`Found ${matches.length} skill(s)`)}\n`);
|
|
97
|
+
|
|
98
|
+
for (const info of matches) {
|
|
99
|
+
const skillPath = info.path || info.name;
|
|
100
|
+
const icon = formatLayerIcon(skillPath);
|
|
101
|
+
const status = formatStatus(info.status);
|
|
102
|
+
const version = info.version || '?';
|
|
103
|
+
const desc = info.description || '';
|
|
104
|
+
|
|
105
|
+
console.log(`${icon} ${chalk.cyan(skillPath)}`);
|
|
106
|
+
console.log(` ${chalk.bold(info.name)} (${version}) [${status}]`);
|
|
107
|
+
console.log(` ${desc}\n`);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// ── info ─────────────────────────────────────────────────────────────────
|
|
112
|
+
|
|
113
|
+
async function cmdInfo(skillPath, options) {
|
|
114
|
+
console.log(`\n${chalk.dim(`Fetching details from remote registry...`)}`);
|
|
115
|
+
|
|
116
|
+
const info = await fetchFromApi(`/skills/${skillPath}/content`, options);
|
|
117
|
+
|
|
118
|
+
const icon = formatLayerIcon(skillPath);
|
|
119
|
+
const status = formatStatus(info.status || info.metadata?.status);
|
|
120
|
+
const metadata = info.metadata || info || {};
|
|
121
|
+
|
|
122
|
+
console.log(`\n${icon} ${chalk.bold(metadata.name || skillPath)}`);
|
|
123
|
+
console.log(chalk.dim('─'.repeat(40)));
|
|
124
|
+
console.log(` Path: ${chalk.cyan(skillPath)}`);
|
|
125
|
+
console.log(` Version: ${metadata.version || 'unknown'}`);
|
|
126
|
+
console.log(` Status: ${status}`);
|
|
127
|
+
console.log(` Description: ${metadata.description || 'No description'}`);
|
|
128
|
+
|
|
129
|
+
if (metadata.dependencies && metadata.dependencies.length > 0) {
|
|
130
|
+
console.log(` Dependencies: ${metadata.dependencies.join(', ')}`);
|
|
131
|
+
} else {
|
|
132
|
+
console.log(` Dependencies: ${chalk.dim('None')}`);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (info.content || info.markdown) {
|
|
136
|
+
console.log(`\n ${chalk.dim('[Remote Document Content Available]')}`);
|
|
137
|
+
}
|
|
138
|
+
console.log();
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// ── download ─────────────────────────────────────────────────────────────
|
|
142
|
+
|
|
143
|
+
function cmdDownload(skillPath, options) {
|
|
144
|
+
const homeDir = require('os').homedir();
|
|
145
|
+
let rawTarget = process.cwd();
|
|
146
|
+
|
|
147
|
+
if (options.target) {
|
|
148
|
+
rawTarget = options.target.startsWith('~/') ?
|
|
149
|
+
path.join(homeDir, options.target.slice(2)) :
|
|
150
|
+
path.resolve(options.target);
|
|
151
|
+
} else if (options.platform) {
|
|
152
|
+
rawTarget = path.join(process.cwd(), `.${options.platform}`, "skills");
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const targetDir = path.resolve(rawTarget);
|
|
156
|
+
fs.mkdirSync(targetDir, { recursive: true });
|
|
157
|
+
|
|
158
|
+
const serverUrl = getApiBase(options);
|
|
159
|
+
const downloadUrl = `${serverUrl}/skills/${skillPath}/download`;
|
|
160
|
+
|
|
161
|
+
console.log(`\n${chalk.bold(`📥 Downloading and extracting ${skillPath}...`)}`);
|
|
162
|
+
console.log(` Source: ${chalk.cyan(downloadUrl)}`);
|
|
163
|
+
console.log(` Target: ${chalk.cyan(targetDir)}\n`);
|
|
164
|
+
|
|
165
|
+
try {
|
|
166
|
+
const tmpZip = path.join(require('os').tmpdir(), `skill-${Date.now()}.zip`);
|
|
167
|
+
|
|
168
|
+
execSync(`curl -s -L -o "${tmpZip}" "${downloadUrl}"`);
|
|
169
|
+
execSync(`unzip -q -o "${tmpZip}" -d "${targetDir}"`);
|
|
170
|
+
|
|
171
|
+
if (fs.existsSync(tmpZip)) {
|
|
172
|
+
fs.unlinkSync(tmpZip);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
console.log(`\n${chalk.green(`✓ Successfully downloaded and extracted to: ${targetDir}`)}`);
|
|
176
|
+
} catch (e) {
|
|
177
|
+
console.error(`\n${chalk.red(`✗ Failed to download. The API may have returned an error, or 'unzip' is not installed.`)}`);
|
|
178
|
+
process.exit(1);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// ── upload / publish ─────────────────────────────────────────────────────
|
|
183
|
+
|
|
184
|
+
async function cmdUpload(skillPath, options) {
|
|
185
|
+
const skillDir = path.resolve(skillPath);
|
|
186
|
+
const skillMdPath = path.join(skillDir, 'SKILL.md');
|
|
187
|
+
|
|
188
|
+
console.log(`\n${chalk.bold(`🚀 Publishing Skill: ${skillPath}`)}`);
|
|
189
|
+
console.log(`${chalk.dim('─'.repeat(50))}\n`);
|
|
190
|
+
|
|
191
|
+
// 1. Validation
|
|
192
|
+
const { isValid: isDirValid, errors: dirErrors } = validateDirectoryStructure(skillDir);
|
|
193
|
+
if (!isDirValid) {
|
|
194
|
+
console.error(chalk.red('✗ Validation failed: Directory structure errors:'));
|
|
195
|
+
dirErrors.forEach(err => console.error(` - ${err}`));
|
|
196
|
+
process.exit(1);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
if (!fs.existsSync(skillMdPath)) {
|
|
200
|
+
console.log(`${chalk.red('✗ Fatal: SKILL.md not found')}`);
|
|
201
|
+
process.exit(1);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const fm = parseSkillMdFrontmatter(skillMdPath);
|
|
205
|
+
if (!fm || Object.keys(fm).length === 0) {
|
|
206
|
+
console.log(`${chalk.red('✗ SKILL.md missing or invalid frontmatter')}`);
|
|
207
|
+
process.exit(1);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const v = new Validator();
|
|
211
|
+
const result = v.validate(fm, skillSchema);
|
|
212
|
+
if (fm.category && fm.tags && fm.tags.length > 0 && fm.tags[0] !== fm.category) {
|
|
213
|
+
result.errors.push({ stack: `tags[0] must equal category name '${fm.category}'` });
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
if (!result.valid || result.errors.length > 0) {
|
|
217
|
+
console.error(chalk.red('✗ Validation failed: SKILL.md has errors:'));
|
|
218
|
+
result.errors.forEach(err => console.error(` - ${err.stack.replace('instance.', '')}`));
|
|
219
|
+
process.exit(1);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
console.log(chalk.green('✓ Local validation passed.'));
|
|
223
|
+
|
|
224
|
+
// 2. Prepare tarball
|
|
225
|
+
const tarFilename = `${fm.name}-${fm.version}.tar.gz`;
|
|
226
|
+
const tmpDir = require('os').tmpdir();
|
|
227
|
+
const tarPath = path.join(tmpDir, tarFilename);
|
|
228
|
+
|
|
229
|
+
console.log(chalk.dim(`📦 Creating package archive...`));
|
|
230
|
+
try {
|
|
231
|
+
execSync(`tar -czf "${tarPath}" -C "${skillDir}" .`, {
|
|
232
|
+
env: { ...process.env, COPYFILE_DISABLE: '1' }
|
|
233
|
+
});
|
|
234
|
+
} catch (e) {
|
|
235
|
+
console.error(chalk.red('✗ Failed to create tar archive.'));
|
|
236
|
+
process.exit(1);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// 3. Prepare Form Data
|
|
240
|
+
const form = new FormData();
|
|
241
|
+
form.append('package', fs.createReadStream(tarPath), {
|
|
242
|
+
filename: tarFilename,
|
|
243
|
+
contentType: 'application/gzip'
|
|
244
|
+
});
|
|
245
|
+
form.append('metadata', JSON.stringify(fm));
|
|
246
|
+
|
|
247
|
+
if (options.update) {
|
|
248
|
+
form.append('is_update', 'true');
|
|
249
|
+
console.log(chalk.yellow(`⚠ Uploading as an UPDATE (Version: ${fm.version}). Ensure the version number has been bumped!`));
|
|
250
|
+
} else {
|
|
251
|
+
console.log(chalk.cyan(`✨ Uploading as a NEW skill (Version: ${fm.version}).`));
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const serverUrl = getApiBase(options);
|
|
255
|
+
const uploadUrl = `${serverUrl}/skills/upload`;
|
|
256
|
+
|
|
257
|
+
const authToken = options.token || getSavedToken(serverUrl);
|
|
258
|
+
|
|
259
|
+
const fetchOptions = {
|
|
260
|
+
method: 'POST',
|
|
261
|
+
body: form,
|
|
262
|
+
headers: form.getHeaders()
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
if (authToken) {
|
|
266
|
+
fetchOptions.headers['Authorization'] = `Bearer ${authToken}`;
|
|
267
|
+
console.log(chalk.dim(` 🔑 Authenticated as saved user`));
|
|
268
|
+
} else {
|
|
269
|
+
console.log(chalk.yellow(` ⚠ No auth token found. Run 'skill-os login' first if the server requires authentication.`));
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
console.log(chalk.dim(`\n📡 Uploading to ${uploadUrl}...`));
|
|
273
|
+
|
|
274
|
+
let fetchFn = require('node-fetch');
|
|
275
|
+
if (typeof fetchFn !== 'function' && fetchFn.default) {
|
|
276
|
+
fetchFn = fetchFn.default;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
try {
|
|
280
|
+
const response = await fetchFn(uploadUrl, fetchOptions);
|
|
281
|
+
if (!response.ok) {
|
|
282
|
+
let errorMsg = response.statusText;
|
|
283
|
+
try {
|
|
284
|
+
const errBody = await response.json();
|
|
285
|
+
if (errBody.error || errBody.message) errorMsg = errBody.error || errBody.message;
|
|
286
|
+
} catch (e) { }
|
|
287
|
+
throw new Error(`HTTP ${response.status}: ${errorMsg}`);
|
|
288
|
+
}
|
|
289
|
+
console.log(`\n${chalk.green('✅ Publish successful!')}`);
|
|
290
|
+
} catch (error) {
|
|
291
|
+
console.error(chalk.red(`\n✗ Publish failed.`));
|
|
292
|
+
console.error(chalk.red(` ${error.message}`));
|
|
293
|
+
if (!options.update && error.message.includes('already exists')) {
|
|
294
|
+
console.log(chalk.yellow(`\n💡 If you intended to update an existing skill, use the --update flag.`));
|
|
295
|
+
}
|
|
296
|
+
process.exit(1);
|
|
297
|
+
} finally {
|
|
298
|
+
if (fs.existsSync(tarPath)) {
|
|
299
|
+
fs.unlinkSync(tarPath);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
module.exports = { cmdList, cmdSearch, cmdInfo, cmdDownload, cmdUpload };
|