diff --git a/README.md b/README.md index 04ab6ef..d7c1641 100644 --- a/README.md +++ b/README.md @@ -67,7 +67,9 @@ Xget was invited to join the [GitCode platform](https://gitcode.com/xixu-me/xget **Pre-deployed Instance (no reliability guarantee): `xget.xi-xu.me`** -**URL Converter:** [**`xuc.xi-xu.me`**](https://xuc.xi-xu.me) - Convert any supported platform URL to Xget's acceleration format with one click! +**URL Converter:** [**`xuc.xi-xu.me`**](https://xuc.xi-xu.me) - Convert any supported platform URL to Xget's acceleration format with one click + +**Agent Skills:** in [`skill/xget/`](skill/xget/) - Designed to work as a standalone `/xget` directory in a skills installation ## 🌟 Core Advantages - Why Choose Xget? diff --git a/README.zh-Hans.md b/README.zh-Hans.md index ebb651b..86a86c6 100644 --- a/README.zh-Hans.md +++ b/README.zh-Hans.md @@ -67,7 +67,9 @@ Xget 已受邀入驻 [GitCode 平台](https://gitcode.com/xixu-me/xget),并被 **预部署实例(不保证可靠性):`xget.xi-xu.me`** -**URL 转换器:**[**`xuc.xi-xu.me`**](https://xuc.xi-xu.me) - 一键转换任意支持平台的 URL 为 Xget 的加速格式! +**URL 转换器:**[**`xuc.xi-xu.me`**](https://xuc.xi-xu.me) - 一键转换任意支持平台的 URL 为 Xget 的加速格式 + +**Agent Skills:** 位于 [`skill/xget/`](skill/xget/) - 可以作为独立的 `/xget` 目录直接安装到 skills 目录中 ## 🌟 核心优势 - 为什么选择 Xget? diff --git a/README.zh-Hant.md b/README.zh-Hant.md index e072cd1..766e41c 100644 --- a/README.zh-Hant.md +++ b/README.zh-Hant.md @@ -67,7 +67,9 @@ Xget 已受邀入駐 [GitCode 平台](https://gitcode.com/xixu-me/xget),並被 **預部署實例(不保證可靠性):`xget.xi-xu.me`** -**URL 轉換器:**[**`xuc.xi-xu.me`**](https://xuc.xi-xu.me) - 一鍵轉換任意支援平台的 URL 為 Xget 的加速格式! +**URL 轉換器:**[**`xuc.xi-xu.me`**](https://xuc.xi-xu.me) - 一鍵轉換任意支援平台的 URL 為 Xget 的加速格式 + +**Agent Skills:** 位於 [`skill/xget/`](skill/xget/) - 可作為獨立的 `/xget` 目錄直接安裝到 skills 目錄中 ## 🌟 核心優勢 - 為什麼選擇 Xget? diff --git a/skill/xget/SKILL.md b/skill/xget/SKILL.md new file mode 100644 index 0000000..eff35fd --- /dev/null +++ b/skill/xget/SKILL.md @@ -0,0 +1,97 @@ +--- +name: xget +description: + Convert upstream resource URLs and package manager, container registry, or AI + SDK settings to Xget, explain Xget platform prefixes, and help deploy or use a + self-hosted Xget instance. Use this skill when a task involves Xget URL + rewriting, registry acceleration, proxy base URLs, self-hosting, or choosing + the correct Xget prefix for Git, packages, OCI images, or inference APIs. + Prefer the user's own Xget domain; treat the public demo as a last-resort + fallback. +license: GPL-3.0-or-later +compatibility: + Requires network access to refresh the live platform map. Optional Node.js 18+ + lets the bundled script run. Designed to work as a standalone skill directory + installed at /xget. +allowed-tools: Bash(node:*) Bash(curl:*) Read +--- + +# Xget + +Use this skill for Xget-specific tasks only. Default to the user's self-hosted +Xget domain or an explicit internal instance. Do not default to +`https://xget.xi-xu.me` unless the user explicitly wants the public demo or no +self-hosted option exists. + +## Defaults + +1. Resolve the base URL in this order: + - the user explicitly gives a domain + - `XGET_BASE_URL` from the environment + - `https://xget.example.com` as a placeholder for docs or templates + - `https://xget.xi-xu.me` only as an explicitly labeled fallback +2. Keep platform data fresh. Do not hardcode the full prefix list from memory. + Run: + +```bash +node scripts/xget.mjs platforms --format json +``` + +3. For URL conversion or prefix detection, prefer the script over manual + guessing: + +```bash +node scripts/xget.mjs convert --base-url https://xget.example.com --url https://github.com/microsoft/vscode +``` + +## Workflow + +1. Identify the user's goal: + - convert one or more upstream URLs + - generate config snippets for npm, pip, Go, NuGet, Cargo, Docker, or AI SDKs + - explain which Xget prefix to use + - propose or document a self-hosted deployment +2. Refresh the live platform map with `scripts/xget.mjs` if the answer depends + on current prefixes. +3. Use the user's self-hosted domain in every generated example when possible. +4. If the user needs deployment or configuration details, read + [the reference guide](references/REFERENCE.md). +5. Before finishing, sanity-check that every example uses the right Xget path + shape: + - repo/content: `/{prefix}/...` + - inference APIs: `/ip/{provider}/...` + - OCI registries: `/cr/{registry}/...` + +## Common tasks + +### Convert URLs + +```bash +node scripts/xget.mjs convert --base-url https://xget.example.com --url https://github.com/microsoft/vscode --format json +``` + +### Emit config snippets + +```bash +node scripts/xget.mjs snippet --base-url https://xget.example.com --preset npm +node scripts/xget.mjs snippet --base-url https://xget.example.com --preset pip +node scripts/xget.mjs snippet --base-url https://xget.example.com --preset openai +``` + +### List current platforms + +```bash +node scripts/xget.mjs platforms --format table +``` + +## Edge cases + +- If the live platform fetch fails, say that the platform map could not be + refreshed and fall back to the common patterns in + [references/REFERENCE.md](references/REFERENCE.md). +- If an upstream URL does not match any known platform, do not invent a prefix. + Report that no current Xget mapping was found. +- When writing pip config for HTTPS domains, keep `trusted-host` aligned with + the actual host only if the user really needs it. +- When generating docs or templates without a real domain, prefer + `https://xget.example.com` over the public demo. diff --git a/skill/xget/references/REFERENCE.md b/skill/xget/references/REFERENCE.md new file mode 100644 index 0000000..f63e315 --- /dev/null +++ b/skill/xget/references/REFERENCE.md @@ -0,0 +1,113 @@ +# Xget Reference + +## Self-hosted first + +Use these defaults in order: + +1. User-provided Xget base URL +2. `XGET_BASE_URL` from the environment +3. `https://xget.example.com` for templates and docs +4. `https://xget.xi-xu.me` only as a clearly labeled public-demo fallback + +The Xget README explicitly labels `xget.xi-xu.me` as a pre-deployed instance with no reliability guarantee, while the self-hosting docs and DigitalOcean guide show recommended self-hosted domains such as `xget.example.com`. + +## Live platform source + +The authoritative platform list for this skill comes from: + +`https://raw.gitcode.com/xixu-me/xget/raw/main/src/config/platforms.js` + +Fetch it with: + +```bash +node scripts/xget.mjs platforms --format json +``` + +The script derives these path shapes from platform keys: + +- plain keys like `gh` become `/gh/...` +- `ip-openai` becomes `/ip/openai/...` +- `cr-ghcr` becomes `/cr/ghcr/...` + +## Common Xget patterns + +### Source code and file downloads + +- GitHub: `https://{base}/gh/...` +- GitHub Gist: `https://{base}/gist/...` +- GitLab: `https://{base}/gl/...` +- Hugging Face: `https://{base}/hf/...` + +### Package managers + +- npm registry: `https://{base}/npm/` +- pip simple index: `https://{base}/pypi/simple/` +- Go proxy: `https://{base}/golang` +- NuGet v3 index: `https://{base}/nuget/v3/index.json` +- Cargo registry: `https://{base}/crates/` + +### Container registries + +- Docker Hub: `https://{base}/cr/docker/...` +- GHCR: `https://{base}/cr/ghcr/...` +- GCR: `https://{base}/cr/gcr/...` +- MCR: `https://{base}/cr/mcr/...` + +### Inference APIs + +- OpenAI: `https://{base}/ip/openai` +- Anthropic: `https://{base}/ip/anthropic` +- Gemini: `https://{base}/ip/gemini` + +## Common snippets + +Generate the latest snippets with: + +```bash +node scripts/xget.mjs snippet --base-url https://xget.example.com --preset npm +``` + +Representative presets: + +- `npm` +- `pip` +- `go` +- `nuget` +- `cargo` +- `docker-ghcr` +- `openai` +- `anthropic` +- `gemini` + +## Deployment defaults + +For self-hosting guidance, prefer one of these paths: + +1. Docker / Docker Compose with `ghcr.io/xixu-me/xget:latest` +2. Cloudflare Workers with a bound custom domain +3. Managed hosting with a custom domain in front + +Representative Docker Compose service: + +```yaml +services: + xget: + image: ghcr.io/xixu-me/xget:latest + container_name: xget + ports: + - "127.0.0.1:8080:8080" + restart: unless-stopped +``` + +Representative reverse-proxy outcome: + +- Public HTTPS domain such as `https://xget.example.com` +- Xget container bound privately to `127.0.0.1:8080` + +## Troubleshooting heuristics + +- `404` on converted URLs often means the wrong prefix or an unmatched upstream platform. +- pip issues often come from mixing the right `index-url` with the wrong host in `trusted-host`. +- Docker examples must use `/cr/{registry}` prefixes, not plain `/{prefix}`. +- AI SDK examples usually need the Xget base URL changed but keep the original API key behavior. +- If the user asks for the “latest” supported platform, refresh the live platform map before answering. diff --git a/skill/xget/scripts/xget.mjs b/skill/xget/scripts/xget.mjs new file mode 100644 index 0000000..08abe69 --- /dev/null +++ b/skill/xget/scripts/xget.mjs @@ -0,0 +1,396 @@ +#!/usr/bin/env node + +import { get } from 'node:https'; +import process from 'node:process'; +import vm from 'node:vm'; + +const DEFAULT_SOURCE_URL = 'https://raw.gitcode.com/xixu-me/xget/raw/main/src/config/platforms.js'; + +const DEFAULT_BASE_PLACEHOLDER = 'https://xget.example.com'; + +function printHelp() { + console.log(`Usage: node scripts/xget.mjs [options] + +Commands: + platforms Fetch the live Xget platform map. + convert Convert an upstream URL to an Xget URL. + snippet Emit a config snippet preset. + help Show this message. + +Global options: + --source-url URL Override the remote platforms.js URL. + --format FORMAT json (default), text, or table when supported. + --help Show command help. + +platforms options: + --format json|table + +convert options: + --base-url URL Xget base URL. Defaults to XGET_BASE_URL. + --url URL Upstream URL to convert. + --format json|text + +snippet options: + --base-url URL Xget base URL. Defaults to XGET_BASE_URL. + --preset NAME One of: npm, pip, go, nuget, cargo, docker-ghcr, + openai, anthropic, gemini. + --format json|text + +Examples: + node scripts/xget.mjs platforms --format table + node scripts/xget.mjs convert --base-url https://xget.example.com --url https://github.com/microsoft/vscode + node scripts/xget.mjs snippet --base-url https://xget.example.com --preset npm +`); +} + +function parseArgs(argv) { + const [command = 'help', ...rest] = argv; + if (command === '--help') { + return { command: 'help', options: { help: true } }; + } + + const options = {}; + + for (let index = 0; index < rest.length; index += 1) { + const token = rest[index]; + if (!token.startsWith('--')) { + fail(`Unexpected argument "${token}". Use --help for supported options.`, 2); + } + + const key = token.slice(2); + if (key === 'help') { + options.help = true; + continue; + } + + const value = rest[index + 1]; + if (!value || value.startsWith('--')) { + fail(`Missing value for --${key}.`, 2); + } + + options[key] = value; + index += 1; + } + + return { command, options }; +} + +function fail(message, code = 1) { + console.error(`Error: ${message}`); + process.exit(code); +} + +function httpGet(url) { + return new Promise((resolve, reject) => { + get(url, response => { + if ( + response.statusCode && + response.statusCode >= 300 && + response.statusCode < 400 && + response.headers.location + ) { + resolve(httpGet(response.headers.location)); + return; + } + + if (response.statusCode !== 200) { + reject(new Error(`Unexpected HTTP status ${response.statusCode} for ${url}`)); + response.resume(); + return; + } + + let body = ''; + response.setEncoding('utf8'); + response.on('data', chunk => { + body += chunk; + }); + response.on('end', () => resolve(body)); + }).on('error', reject); + }); +} + +function extractPlatformsModule(jsSource) { + const match = jsSource.match(/export const PLATFORMS = (\{[\s\S]*?\n\});/); + + if (!match) { + fail('Could not find `export const PLATFORMS = {...}` in the remote source.'); + } + + try { + return vm.runInNewContext(`(${match[1]})`); + } catch (error) { + fail(`Could not parse remote PLATFORMS object: ${error.message}`); + } +} + +async function loadPlatforms(sourceUrl) { + const jsSource = await httpGet(sourceUrl); + const platforms = extractPlatformsModule(jsSource); + return Object.entries(platforms) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, upstream]) => ({ + key, + upstream, + pathPrefix: `/${key.replace(/-/g, '/')}/`, + category: key.startsWith('ip-') + ? 'inference' + : key.startsWith('cr-') + ? 'registry' + : 'resource' + })); +} + +function normalizeBaseUrl(value) { + if (!value) { + return null; + } + + try { + const url = new URL(value); + url.pathname = url.pathname.replace(/\/+$/, ''); + url.search = ''; + url.hash = ''; + return url.toString().replace(/\/$/, ''); + } catch { + fail(`Invalid --base-url value "${value}". Expected an absolute URL.`); + } +} + +function normalizeAbsoluteUrl(value, flagName) { + try { + return new URL(value); + } catch { + fail(`Invalid ${flagName} value "${value}". Expected an absolute URL.`); + } +} + +function findPlatformForUrl(platforms, originUrl) { + const origin = originUrl.origin; + return platforms.find(({ upstream }) => upstream === origin) ?? null; +} + +function buildConvertedUrl(baseUrl, platform, originUrl) { + const suffix = originUrl.pathname + originUrl.search + originUrl.hash; + return `${baseUrl}${platform.pathPrefix}${suffix.replace(/^\/+/, '')}`; +} + +function createSnippet(baseUrl, preset) { + const host = new URL(baseUrl).host; + + const snippets = { + npm: { + preset, + summary: 'Configure npm to use the Xget npm registry.', + commands: [`npm config set registry ${baseUrl}/npm/`, 'npm config get registry'] + }, + pip: { + preset, + summary: 'Configure pip to use the Xget PyPI simple index.', + commands: [ + `pip config set global.index-url ${baseUrl}/pypi/simple/`, + `pip config set global.trusted-host ${host}`, + 'pip config list' + ] + }, + go: { + preset, + summary: 'Configure Go modules to use Xget as GOPROXY.', + commands: [`go env -w GOPROXY=${baseUrl}/golang,direct`] + }, + nuget: { + preset, + summary: 'Add Xget as a NuGet v3 source.', + commands: [ + `dotnet nuget add source ${baseUrl}/nuget/v3/index.json -n xget`, + 'dotnet nuget list source' + ] + }, + cargo: { + preset, + summary: 'Route crates.io traffic through Xget.', + files: { + '~/.cargo/config.toml': [ + '[source.crates-io]', + 'replace-with = "xget"', + '', + '[source.xget]', + `registry = "${baseUrl}/crates/"` + ].join('\n') + } + }, + 'docker-ghcr': { + preset, + summary: 'Pull GHCR images through Xget.', + commands: [`docker pull ${new URL(baseUrl).host}/cr/ghcr/nginxinc/nginx-unprivileged:latest`] + }, + openai: { + preset, + summary: 'Point OpenAI SDKs at Xget.', + env: { + OPENAI_BASE_URL: `${baseUrl}/ip/openai` + } + }, + anthropic: { + preset, + summary: 'Point Anthropic SDKs at Xget.', + env: { + ANTHROPIC_BASE_URL: `${baseUrl}/ip/anthropic` + } + }, + gemini: { + preset, + summary: 'Point Gemini SDKs at Xget.', + env: { + GEMINI_BASE_URL: `${baseUrl}/ip/gemini` + } + } + }; + + const result = snippets[preset]; + if (!result) { + fail( + `Unknown --preset "${preset}". Supported presets: ${Object.keys(snippets).join(', ')}.`, + 2 + ); + } + + return result; +} + +function renderJson(value) { + console.log(JSON.stringify(value, null, 2)); +} + +function renderTable(rows) { + const headers = ['key', 'category', 'pathPrefix', 'upstream']; + const widths = headers.map(header => + Math.max(header.length, ...rows.map(row => String(row[header]).length)) + ); + + const formatRow = row => + headers.map((header, index) => String(row[header]).padEnd(widths[index])).join(' '); + + console.log(formatRow(Object.fromEntries(headers.map(header => [header, header])))); + console.log(widths.map(width => '-'.repeat(width)).join(' ')); + rows.forEach(row => console.log(formatRow(row))); +} + +function renderTextSnippet(snippet) { + console.log(snippet.summary); + + if (snippet.commands) { + console.log('\nCommands:'); + snippet.commands.forEach(command => console.log(command)); + } + + if (snippet.env) { + console.log('\nEnvironment:'); + Object.entries(snippet.env).forEach(([key, value]) => console.log(`${key}=${value}`)); + } + + if (snippet.files) { + console.log('\nFiles:'); + Object.entries(snippet.files).forEach(([file, content]) => { + console.log(`[${file}]`); + console.log(content); + }); + } +} + +async function main() { + const { command, options } = parseArgs(process.argv.slice(2)); + + if (options.help || command === 'help') { + printHelp(); + return; + } + + const sourceUrl = options['source-url'] ?? DEFAULT_SOURCE_URL; + const format = options.format ?? 'json'; + + if (command === 'platforms') { + const platforms = await loadPlatforms(sourceUrl); + if (format === 'json') { + renderJson({ + sourceUrl, + count: platforms.length, + platforms + }); + return; + } + + if (format === 'table') { + renderTable(platforms); + return; + } + + fail('Unsupported --format for platforms. Use json or table.', 2); + } + + if (command === 'convert') { + const baseUrl = + normalizeBaseUrl(options['base-url'] ?? process.env.XGET_BASE_URL) ?? + fail(`Missing --base-url and XGET_BASE_URL. For docs, use ${DEFAULT_BASE_PLACEHOLDER}.`, 2); + + const rawUrl = options.url; + if (!rawUrl) { + fail('Missing --url for convert.', 2); + } + + const originUrl = normalizeAbsoluteUrl(rawUrl, '--url'); + const platforms = await loadPlatforms(sourceUrl); + const platform = findPlatformForUrl(platforms, originUrl); + + if (!platform) { + fail(`No current Xget platform matched upstream origin ${originUrl.origin}.`, 3); + } + + const payload = { + sourceUrl, + baseUrl, + upstreamUrl: originUrl.toString(), + matchedPlatform: platform, + convertedUrl: buildConvertedUrl(baseUrl, platform, originUrl) + }; + + if (format === 'json') { + renderJson(payload); + return; + } + + if (format === 'text') { + console.log(payload.convertedUrl); + return; + } + + fail('Unsupported --format for convert. Use json or text.', 2); + } + + if (command === 'snippet') { + const baseUrl = + normalizeBaseUrl(options['base-url'] ?? process.env.XGET_BASE_URL) ?? + fail(`Missing --base-url and XGET_BASE_URL. For docs, use ${DEFAULT_BASE_PLACEHOLDER}.`, 2); + + const preset = options.preset; + if (!preset) { + fail('Missing --preset for snippet.', 2); + } + + const snippet = createSnippet(baseUrl, preset); + if (format === 'json') { + renderJson(snippet); + return; + } + + if (format === 'text') { + renderTextSnippet(snippet); + return; + } + + fail('Unsupported --format for snippet. Use json or text.', 2); + } + + fail(`Unknown command "${command}". Use --help for supported commands.`, 2); +} + +main().catch(error => fail(error.message));