From 5725f54c7878c79b8d09523053ba3b8cc5debdb1 Mon Sep 17 00:00:00 2001 From: ymkiux Date: Sun, 26 Apr 2026 04:42:26 +0000 Subject: [PATCH] feat: import skills from url --- cli.js | 12 ++ cli/import-skills-url.js | 228 ++++++++++++++++++++++++++ tests/unit/import-skills-url.test.mjs | 25 +++ 3 files changed, 265 insertions(+) create mode 100644 cli/import-skills-url.js create mode 100644 tests/unit/import-skills-url.test.mjs diff --git a/cli.js b/cli.js index 385207af..e2d8fb5f 100644 --- a/cli.js +++ b/cli.js @@ -127,6 +127,7 @@ const { deleteSkills, deleteCodexSkills } = require('./cli/skills'); +const { cmdImportSkills: cmdImportSkillsFromUrl } = require('./cli/import-skills-url'); const { getFileStatSafe, isBootstrapLikeText, @@ -6522,6 +6523,15 @@ async function cmdDoctor(argv = []) { } } +async function cmdImportSkills(argv = []) { + try { + await cmdImportSkillsFromUrl(argv); + } catch (e) { + console.error('错误:', e && e.message ? e.message : e); + process.exitCode = 1; + } +} + // 列出所有提供商 function cmdList() { const configResult = readConfigOrVirtualDefault(); @@ -13172,6 +13182,7 @@ async function main() { console.log('\n用法:'); console.log(' codexmate status 显示当前状态'); console.log(' codexmate doctor [--format json|md] [--lang zh|en] [--output ] 输出诊断报告'); + console.log(' codexmate import-skills [--target-app codex|claude] [--name ] [--timeout-ms ] 从 URL 导入 skills'); console.log(' codexmate setup 交互式配置向导'); console.log(' codexmate list 列出所有提供商'); console.log(' codexmate models 列出所有模型'); @@ -13234,6 +13245,7 @@ async function main() { case '__task-worker': await cmdTaskWorker(args.slice(1)); break; case 'status': cmdStatus(); break; case 'doctor': await cmdDoctor(args.slice(1)); break; + case 'import-skills': await cmdImportSkills(args.slice(1)); break; case 'setup': await cmdSetup(); break; case 'list': cmdList(); break; case 'models': await cmdModels(); break; diff --git a/cli/import-skills-url.js b/cli/import-skills-url.js new file mode 100644 index 00000000..41eba2c1 --- /dev/null +++ b/cli/import-skills-url.js @@ -0,0 +1,228 @@ +const fs = require('fs'); +const path = require('path'); +const os = require('os'); +const http = require('http'); +const https = require('https'); +const { isValidHttpUrl } = require('../lib/cli-utils'); +const { MAX_SKILLS_ZIP_UPLOAD_SIZE, importSkillsFromZipFile } = require('./skills'); + +function resolveGithubArchiveZipUrl(inputUrl) { + const raw = typeof inputUrl === 'string' ? inputUrl.trim() : ''; + if (!raw) return ''; + let parsed; + try { + parsed = new URL(raw); + } catch (_) { + return ''; + } + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + return ''; + } + if (parsed.hostname !== 'github.com') { + return ''; + } + const parts = parsed.pathname.split('/').filter(Boolean); + if (parts.length < 2) return ''; + const owner = parts[0]; + const repo = (parts[1] || '').endsWith('.git') ? parts[1].slice(0, -4) : parts[1]; + if (!owner || !repo) return ''; + let ref = 'main'; + if (parts[2] === 'tree' && parts[3]) { + ref = parts[3]; + } + return `https://github.com/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/archive/refs/heads/${encodeURIComponent(ref)}.zip`; +} + +function redactUrlForLog(inputUrl) { + const raw = typeof inputUrl === 'string' ? inputUrl.trim() : ''; + if (!raw) return ''; + try { + const parsed = new URL(raw); + return `${parsed.origin}${parsed.pathname}`; + } catch (_) { + return raw; + } +} + +function downloadUrlToFile(targetUrl, filePath, options = {}) { + const maxBytes = Number.isFinite(options.maxBytes) && options.maxBytes > 0 + ? Math.floor(options.maxBytes) + : MAX_SKILLS_ZIP_UPLOAD_SIZE; + const timeoutMs = Number.isFinite(options.timeoutMs) && options.timeoutMs > 0 + ? Math.floor(options.timeoutMs) + : 30000; + const maxRedirects = Number.isFinite(options.maxRedirects) && options.maxRedirects >= 0 + ? Math.floor(options.maxRedirects) + : 5; + + return new Promise((resolve, reject) => { + let parsed; + try { + parsed = new URL(targetUrl); + } catch (_) { + reject(new Error('Invalid URL')); + return; + } + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + reject(new Error(`ERR_INVALID_PROTOCOL: Protocol "${parsed.protocol}" not supported. Expected "http:" or "https:"`)); + return; + } + + const transport = parsed.protocol === 'https:' ? https : http; + const requestOptions = { + method: 'GET', + headers: { + 'User-Agent': 'codexmate-import-skills', + 'Accept': 'application/octet-stream,application/zip,*/*' + } + }; + + const req = transport.request(parsed, requestOptions, (res) => { + const status = Number(res.statusCode) || 0; + const redirectLocation = res.headers && typeof res.headers.location === 'string' ? res.headers.location : ''; + if (status >= 300 && status < 400 && redirectLocation) { + if (maxRedirects <= 0) { + reject(new Error('Too many redirects')); + return; + } + const nextUrl = redirectLocation.startsWith('http') + ? redirectLocation + : `${parsed.origin}${redirectLocation}`; + res.resume(); + downloadUrlToFile(nextUrl, filePath, { maxBytes, timeoutMs, maxRedirects: maxRedirects - 1 }) + .then(resolve) + .catch(reject); + return; + } + if (status < 200 || status >= 300) { + res.resume(); + reject(new Error(`HTTP ${status}`)); + return; + } + + const out = fs.createWriteStream(filePath, { flags: 'w' }); + let bytes = 0; + let finished = false; + + const fail = (err) => { + if (finished) return; + finished = true; + try { + out.close(); + } catch (_) {} + try { + fs.unlinkSync(filePath); + } catch (_) {} + reject(err); + }; + + res.on('data', (chunk) => { + if (!chunk || finished) return; + bytes += chunk.length; + if (bytes > maxBytes) { + req.destroy(new Error('download too large')); + res.destroy(new Error('download too large')); + fail(new Error('Download too large')); + } + }); + + res.on('error', fail); + out.on('error', fail); + out.on('finish', () => { + if (finished) return; + finished = true; + resolve({ bytes }); + }); + + res.pipe(out); + }); + + req.on('error', (err) => { + reject(new Error(err && err.message ? err.message : 'request failed')); + }); + req.setTimeout(timeoutMs, () => { + req.destroy(new Error('timeout')); + }); + req.end(); + }); +} + +function parseImportSkillsCommandArgs(argv = []) { + const options = { + url: '', + targetApp: 'codex', + name: '', + timeoutMs: 30000 + }; + if (argv[0] && !String(argv[0]).startsWith('--')) { + options.url = String(argv[0]).trim(); + } + let cursor = 1; + while (cursor < argv.length) { + const token = String(argv[cursor] || ''); + if (token === '--target-app') { + const value = String(argv[cursor + 1] || '').trim().toLowerCase(); + if (!value || value.startsWith('--')) { + throw new Error('错误: --target-app 需要一个值(codex/claude)'); + } + options.targetApp = value === 'claude' ? 'claude' : 'codex'; + cursor += 2; + continue; + } + if (token === '--name') { + const value = String(argv[cursor + 1] || '').trim(); + if (!value || value.startsWith('--')) { + throw new Error('错误: --name 需要一个值'); + } + options.name = value; + cursor += 2; + continue; + } + if (token === '--timeout-ms') { + const value = Number(argv[cursor + 1]); + if (!Number.isFinite(value) || value <= 0) { + throw new Error('错误: --timeout-ms 需要一个正整数'); + } + options.timeoutMs = Math.floor(value); + cursor += 2; + continue; + } + cursor += 1; + } + if (!options.url) { + throw new Error('错误: 缺少 URL(例如: https://github.com///archive/refs/heads/main.zip)'); + } + return options; +} + +async function cmdImportSkills(argv = []) { + const options = parseImportSkillsCommandArgs(argv); + const resolvedGithubUrl = resolveGithubArchiveZipUrl(options.url); + const zipUrl = resolvedGithubUrl || options.url; + if (!isValidHttpUrl(zipUrl)) { + throw new Error('错误: URL 非法(仅支持 http/https)'); + } + + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codexmate-skills-url-')); + const zipPath = path.join(tempDir, 'skills.zip'); + const fallbackName = options.name || path.basename(new URL(zipUrl).pathname) || 'skills.zip'; + + console.log(`\n[Skills] Download: ${redactUrlForLog(zipUrl)}`); + await downloadUrlToFile(zipUrl, zipPath, { + maxBytes: MAX_SKILLS_ZIP_UPLOAD_SIZE, + timeoutMs: options.timeoutMs, + maxRedirects: 5 + }); + const result = await importSkillsFromZipFile(zipPath, { + tempDir, + targetApp: options.targetApp, + fallbackName + }); + process.stdout.write(JSON.stringify(result, null, 2) + '\n'); +} + +module.exports = { + resolveGithubArchiveZipUrl, + cmdImportSkills +}; + diff --git a/tests/unit/import-skills-url.test.mjs b/tests/unit/import-skills-url.test.mjs new file mode 100644 index 00000000..339f276c --- /dev/null +++ b/tests/unit/import-skills-url.test.mjs @@ -0,0 +1,25 @@ +import assert from 'assert'; +import { createRequire } from 'module'; + +const require = createRequire(import.meta.url); +const { resolveGithubArchiveZipUrl } = require('../../cli/import-skills-url'); + +assert.equal( + resolveGithubArchiveZipUrl('https://github.com/foo/bar'), + 'https://github.com/foo/bar/archive/refs/heads/main.zip' +); +assert.equal( + resolveGithubArchiveZipUrl('https://github.com/foo/bar/'), + 'https://github.com/foo/bar/archive/refs/heads/main.zip' +); +assert.equal( + resolveGithubArchiveZipUrl('https://github.com/foo/bar.git'), + 'https://github.com/foo/bar/archive/refs/heads/main.zip' +); +assert.equal( + resolveGithubArchiveZipUrl('https://github.com/foo/bar/tree/dev'), + 'https://github.com/foo/bar/archive/refs/heads/dev.zip' +); +assert.equal(resolveGithubArchiveZipUrl('https://example.com/foo/bar.zip'), ''); +assert.equal(resolveGithubArchiveZipUrl('not a url'), ''); +