Skip to content

Commit 81071f4

Browse files
committed
fix: import skills cli edge cases
1 parent 816ef6c commit 81071f4

3 files changed

Lines changed: 133 additions & 27 deletions

File tree

cli.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6513,6 +6513,7 @@ async function cmdDoctor(argv = []) {
65136513
? renderDoctorMarkdown(report)
65146514
: JSON.stringify(report, null, 2);
65156515
if (options.output) {
6516+
ensureDir(path.dirname(options.output));
65166517
fs.writeFileSync(options.output, text);
65176518
} else {
65186519
process.stdout.write(text + '\n');

cli/import-skills-url.js

Lines changed: 122 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,40 @@ function resolveGithubArchiveZipUrl(inputUrl) {
3333
return `https://github.com/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/archive/refs/heads/${encodeURIComponent(ref)}.zip`;
3434
}
3535

36+
function buildGithubArchiveZipCandidates(inputUrl) {
37+
const raw = typeof inputUrl === 'string' ? inputUrl.trim() : '';
38+
if (!raw) return [];
39+
let parsed;
40+
try {
41+
parsed = new URL(raw);
42+
} catch (_) {
43+
return [];
44+
}
45+
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
46+
return [];
47+
}
48+
if (parsed.hostname !== 'github.com') {
49+
return [];
50+
}
51+
const parts = parsed.pathname.split('/').filter(Boolean);
52+
if (parts.length < 2) return [];
53+
const owner = parts[0];
54+
const repo = (parts[1] || '').endsWith('.git') ? parts[1].slice(0, -4) : parts[1];
55+
if (!owner || !repo) return [];
56+
const base = `https://github.com/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/archive/refs`;
57+
if (parts[2] === 'tree' && parts[3]) {
58+
const ref = encodeURIComponent(parts[3]);
59+
return [
60+
`${base}/heads/${ref}.zip`,
61+
`${base}/tags/${ref}.zip`
62+
];
63+
}
64+
return [
65+
`${base}/heads/main.zip`,
66+
`${base}/heads/master.zip`
67+
];
68+
}
69+
3670
function redactUrlForLog(inputUrl) {
3771
const raw = typeof inputUrl === 'string' ? inputUrl.trim() : '';
3872
if (!raw) return '';
@@ -147,19 +181,44 @@ function downloadUrlToFile(targetUrl, filePath, options = {}) {
147181
});
148182
}
149183

184+
function extractHttpStatusFromError(err) {
185+
const message = err && err.message ? String(err.message) : '';
186+
const matched = message.match(/\bHTTP\s+(\d{3})\b/);
187+
if (!matched) return 0;
188+
const value = Number(matched[1]);
189+
return Number.isFinite(value) ? value : 0;
190+
}
191+
192+
function printImportSkillsUsage() {
193+
process.stdout.write('\n用法:\n');
194+
process.stdout.write(' codexmate import-skills <URL> [--target-app codex|claude] [--name <NAME>] [--timeout-ms <MS>]\n');
195+
process.stdout.write('\n示例:\n');
196+
process.stdout.write(' codexmate import-skills https://github.com/<owner>/<repo>\n');
197+
process.stdout.write(' codexmate import-skills https://github.com/<owner>/<repo>/tree/dev\n');
198+
process.stdout.write(' codexmate import-skills https://github.com/<owner>/<repo>/archive/refs/heads/main.zip\n');
199+
}
200+
150201
function parseImportSkillsCommandArgs(argv = []) {
151202
const options = {
152203
url: '',
153204
targetApp: 'codex',
154205
name: '',
155-
timeoutMs: 30000
206+
timeoutMs: 30000,
207+
help: false
156208
};
157-
if (argv[0] && !String(argv[0]).startsWith('--')) {
158-
options.url = String(argv[0]).trim();
159-
}
160-
let cursor = 1;
209+
let cursor = 0;
161210
while (cursor < argv.length) {
162211
const token = String(argv[cursor] || '');
212+
if (token && !token.startsWith('--') && !options.url) {
213+
options.url = token.trim();
214+
cursor += 1;
215+
continue;
216+
}
217+
if (token === '--help' || token === '-h') {
218+
options.help = true;
219+
cursor += 1;
220+
continue;
221+
}
163222
if (token === '--target-app') {
164223
const value = String(argv[cursor + 1] || '').trim().toLowerCase();
165224
if (!value || value.startsWith('--')) {
@@ -189,40 +248,77 @@ function parseImportSkillsCommandArgs(argv = []) {
189248
}
190249
cursor += 1;
191250
}
192-
if (!options.url) {
193-
throw new Error('错误: 缺少 URL(例如: https://github.com/<owner>/<repo>/archive/refs/heads/main.zip)');
194-
}
195251
return options;
196252
}
197253

198254
async function cmdImportSkills(argv = []) {
199255
const options = parseImportSkillsCommandArgs(argv);
200-
const resolvedGithubUrl = resolveGithubArchiveZipUrl(options.url);
201-
const zipUrl = resolvedGithubUrl || options.url;
202-
if (!isValidHttpUrl(zipUrl)) {
256+
if (options.help) {
257+
printImportSkillsUsage();
258+
return;
259+
}
260+
if (!options.url || options.url.trim().startsWith('--')) {
261+
printImportSkillsUsage();
262+
throw new Error('错误: 缺少 URL(例如: https://github.com/<owner>/<repo>/archive/refs/heads/main.zip)');
263+
}
264+
265+
const candidates = [];
266+
const githubCandidates = buildGithubArchiveZipCandidates(options.url);
267+
if (githubCandidates.length) {
268+
candidates.push(...githubCandidates);
269+
} else {
270+
const resolvedGithubUrl = resolveGithubArchiveZipUrl(options.url);
271+
candidates.push(resolvedGithubUrl || options.url);
272+
}
273+
const uniqueCandidates = Array.from(new Set(candidates.filter(Boolean)));
274+
if (!uniqueCandidates.length || !uniqueCandidates.every(isValidHttpUrl)) {
203275
throw new Error('错误: URL 非法(仅支持 http/https)');
204276
}
205277

206278
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codexmate-skills-url-'));
207279
const zipPath = path.join(tempDir, 'skills.zip');
208-
const fallbackName = options.name || path.basename(new URL(zipUrl).pathname) || 'skills.zip';
209-
210-
console.log(`\n[Skills] Download: ${redactUrlForLog(zipUrl)}`);
211-
await downloadUrlToFile(zipUrl, zipPath, {
212-
maxBytes: MAX_SKILLS_ZIP_UPLOAD_SIZE,
213-
timeoutMs: options.timeoutMs,
214-
maxRedirects: 5
215-
});
216-
const result = await importSkillsFromZipFile(zipPath, {
217-
tempDir,
218-
targetApp: options.targetApp,
219-
fallbackName
220-
});
221-
process.stdout.write(JSON.stringify(result, null, 2) + '\n');
280+
let lastError = null;
281+
let finalUrl = uniqueCandidates[0];
282+
try {
283+
for (const candidateUrl of uniqueCandidates) {
284+
finalUrl = candidateUrl;
285+
console.log(`\n[Skills] Download: ${redactUrlForLog(candidateUrl)}`);
286+
try {
287+
await downloadUrlToFile(candidateUrl, zipPath, {
288+
maxBytes: MAX_SKILLS_ZIP_UPLOAD_SIZE,
289+
timeoutMs: options.timeoutMs,
290+
maxRedirects: 5
291+
});
292+
lastError = null;
293+
break;
294+
} catch (e) {
295+
lastError = e;
296+
const status = extractHttpStatusFromError(e);
297+
if (status === 404) {
298+
continue;
299+
}
300+
throw e;
301+
}
302+
}
303+
if (lastError) {
304+
throw lastError;
305+
}
306+
const fallbackName = options.name || path.basename(new URL(finalUrl).pathname) || 'skills.zip';
307+
const result = await importSkillsFromZipFile(zipPath, {
308+
tempDir,
309+
targetApp: options.targetApp,
310+
fallbackName
311+
});
312+
process.stdout.write(JSON.stringify(result, null, 2) + '\n');
313+
} finally {
314+
try {
315+
fs.rmSync(tempDir, { recursive: true, force: true });
316+
} catch (_) {}
317+
}
222318
}
223319

224320
module.exports = {
225321
resolveGithubArchiveZipUrl,
322+
buildGithubArchiveZipCandidates,
226323
cmdImportSkills
227324
};
228-

tests/unit/import-skills-url.test.mjs

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import assert from 'assert';
22
import { createRequire } from 'module';
33

44
const require = createRequire(import.meta.url);
5-
const { resolveGithubArchiveZipUrl } = require('../../cli/import-skills-url');
5+
const { resolveGithubArchiveZipUrl, buildGithubArchiveZipCandidates } = require('../../cli/import-skills-url');
66

77
assert.equal(
88
resolveGithubArchiveZipUrl('https://github.com/foo/bar'),
@@ -23,3 +23,12 @@ assert.equal(
2323
assert.equal(resolveGithubArchiveZipUrl('https://example.com/foo/bar.zip'), '');
2424
assert.equal(resolveGithubArchiveZipUrl('not a url'), '');
2525

26+
assert.deepEqual(buildGithubArchiveZipCandidates('https://github.com/foo/bar'), [
27+
'https://github.com/foo/bar/archive/refs/heads/main.zip',
28+
'https://github.com/foo/bar/archive/refs/heads/master.zip'
29+
]);
30+
assert.deepEqual(buildGithubArchiveZipCandidates('https://github.com/foo/bar/tree/dev'), [
31+
'https://github.com/foo/bar/archive/refs/heads/dev.zip',
32+
'https://github.com/foo/bar/archive/refs/tags/dev.zip'
33+
]);
34+
assert.deepEqual(buildGithubArchiveZipCandidates('https://example.com/foo/bar'), []);

0 commit comments

Comments
 (0)