skillfree 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/SKILL.md ADDED
@@ -0,0 +1,37 @@
1
+ # SkillFree Skill
2
+
3
+ ## 功能
4
+
5
+ SkillFree 是一个统一的 AI 能力调用层。通过一个 API Key,调用图像生成、语音合成、视频生成、智能对话等多种 AI 能力。
6
+
7
+ ## 使用方式
8
+
9
+ ```bash
10
+ # 对话(1 积分)
11
+ skillfree pilot --type chat --prompt "帮我写一首诗"
12
+
13
+ # 图像生成(5 积分)
14
+ skillfree pilot --type image --prompt "赛博朋克的上海" --output ./out.png
15
+
16
+ # 语音合成(2 积分)
17
+ skillfree pilot --type tts --text "你好,龙虾" --output hello.mp3
18
+
19
+ # 视频生成(20 积分)
20
+ skillfree pilot --type video --prompt "海浪拍打礁石" --output ./video.mp4
21
+ ```
22
+
23
+ ## 配置
24
+
25
+ ```bash
26
+ export SKILLFREE_API_KEY=sk-sf-xxxxxxxx
27
+ ```
28
+
29
+ 或通过登录:
30
+
31
+ ```bash
32
+ skillfree auth login
33
+ ```
34
+
35
+ ## 充值
36
+
37
+ 充多少用多少,积分永不过期:https://skillfree.ai/billing
@@ -0,0 +1,66 @@
1
+ #!/usr/bin/env node
2
+ // skillfree CLI 入口
3
+
4
+ const { program } = require('commander')
5
+ const pkg = require('../package.json')
6
+
7
+ program
8
+ .name('skillfree')
9
+ .description('🦞 一个 API,满足所有龙虾技能需求')
10
+ .version(pkg.version)
11
+
12
+ // ── auth ──────────────────────────────────────────────────────────────────────
13
+ const auth = program.command('auth').description('账号管理')
14
+ auth
15
+ .command('login')
16
+ .description('登录并保存 API Key')
17
+ .action(async () => {
18
+ const { authLogin } = require('../scripts/commands/auth')
19
+ await authLogin()
20
+ })
21
+ auth
22
+ .command('status')
23
+ .description('查看当前登录状态和积分')
24
+ .action(async () => {
25
+ const { authStatus } = require('../scripts/commands/auth')
26
+ await authStatus()
27
+ })
28
+
29
+ // ── pilot ─────────────────────────────────────────────────────────────────────
30
+ program
31
+ .command('pilot')
32
+ .description('AI 智能调度,支持 chat / image / tts / stt / video / music')
33
+ .option('--type <type>', '调用类型: chat | image | tts | stt | video | music', 'chat')
34
+ .option('--prompt <text>', '输入提示词')
35
+ .option('--text <text>', '文字内容(tts 用)')
36
+ .option('--file <path>', '输入文件路径(stt 用)')
37
+ .option('--output <path>', '输出文件路径')
38
+ .option('--model <model>', '指定模型(可选)')
39
+ .option('--voice <voice>', 'TTS 声音(默认 nova)')
40
+ .option('--size <size>', '图像尺寸(默认 1024x1024)')
41
+ .option('--duration <sec>', '视频/音乐时长(秒)')
42
+ .action(async (flags) => {
43
+ const { pilot } = require('../scripts/commands/pilot')
44
+ await pilot(flags).catch(e => { console.error('❌', e.message); process.exit(1) })
45
+ })
46
+
47
+ // ── chat (快捷方式) ───────────────────────────────────────────────────────────
48
+ program
49
+ .command('chat <prompt>')
50
+ .description('快捷对话(等同于 pilot --type chat)')
51
+ .option('--model <model>', '指定模型')
52
+ .action(async (prompt, flags) => {
53
+ const { pilot } = require('../scripts/commands/pilot')
54
+ await pilot({ type: 'chat', prompt, ...flags }).catch(e => { console.error('❌', e.message); process.exit(1) })
55
+ })
56
+
57
+ // ── balance ───────────────────────────────────────────────────────────────────
58
+ program
59
+ .command('balance')
60
+ .description('查看积分余额')
61
+ .action(async () => {
62
+ const { authStatus } = require('../scripts/commands/auth')
63
+ await authStatus()
64
+ })
65
+
66
+ program.parse(process.argv)
package/install.sh ADDED
@@ -0,0 +1,103 @@
1
+ #!/usr/bin/env bash
2
+ # install.sh — SkillFree 一键安装脚本
3
+ # curl -fsSL https://skillfree.ai/install.sh | bash
4
+
5
+ set -e
6
+
7
+ CYAN='\033[0;36m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; RED='\033[0;31m'; NC='\033[0m'
8
+ info() { echo -e "${CYAN}[SkillFree]${NC} $*"; }
9
+ success() { echo -e "${GREEN}[SkillFree]${NC} ✓ $*"; }
10
+ warn() { echo -e "${YELLOW}[SkillFree]${NC} ⚠ $*"; }
11
+ err() { echo -e "${RED}[SkillFree]${NC} ✗ $*"; exit 1; }
12
+
13
+ echo ""
14
+ echo -e "${CYAN}🦞 SkillFree 安装程序${NC}"
15
+ echo -e " 一个 API,满足所有龙虾技能需求"
16
+ echo ""
17
+
18
+ # ── 1. 检查 Node.js ──────────────────────────────────────────────────────────
19
+ if ! command -v node &>/dev/null; then
20
+ err "需要 Node.js 18+,请先安装:https://nodejs.org"
21
+ fi
22
+ NODE_VER=$(node -e "process.stdout.write(process.versions.node.split('.')[0])")
23
+ [ "$NODE_VER" -lt 18 ] && err "Node.js 版本过低(当前 v$NODE_VER),需要 18+"
24
+
25
+ # ── 2. 安装 CLI 包 ────────────────────────────────────────────────────────────
26
+ info "安装 skillfree CLI..."
27
+ npm install -g skillfree --quiet 2>/dev/null \
28
+ || npx --yes skillfree --version &>/dev/null \
29
+ || warn "全局安装需要权限,尝试 sudo npm install -g skillfree"
30
+ success "CLI 安装完成"
31
+
32
+ # ── 3. 输入 API Key ───────────────────────────────────────────────────────────
33
+ echo ""
34
+ echo -e " 请前往 ${CYAN}https://skillfree.ai/dashboard${NC} 获取 API Key"
35
+ read -rp " 输入你的 API Key (sk-sf-...): " API_KEY
36
+
37
+ if [[ -z "$API_KEY" || "$API_KEY" != sk-sf-* ]]; then
38
+ warn "未输入有效 Key,跳过配置(稍后可运行 skillfree auth login)"
39
+ else
40
+ # ── 4. 写入环境变量 ─────────────────────────────────────────────────────────
41
+ SHELL_RC="$HOME/.zshrc"
42
+ [[ "$SHELL" == *bash* ]] && SHELL_RC="$HOME/.bashrc"
43
+ if ! grep -q "SKILLFREE_API_KEY" "$SHELL_RC" 2>/dev/null; then
44
+ echo "" >> "$SHELL_RC"
45
+ echo "# SkillFree" >> "$SHELL_RC"
46
+ echo "export SKILLFREE_API_KEY=\"$API_KEY\"" >> "$SHELL_RC"
47
+ else
48
+ sed -i.bak "s/export SKILLFREE_API_KEY=.*/export SKILLFREE_API_KEY=\"$API_KEY\"/" "$SHELL_RC"
49
+ fi
50
+ export SKILLFREE_API_KEY="$API_KEY"
51
+ success "API Key 已写入 $SHELL_RC"
52
+
53
+ # ── 5. 自动配置各 AI 工具 ────────────────────────────────────────────────────
54
+ SKILL_SRC_DIR="$HOME/.skillfree/skill"
55
+ mkdir -p "$SKILL_SRC_DIR"
56
+
57
+ # 下载 SKILL.md(龙虾技能描述)
58
+ curl -fsSL "https://skillfree.ai/skill/SKILL.md" -o "$SKILL_SRC_DIR/SKILL.md" 2>/dev/null \
59
+ || echo "# SkillFree\nUse skillfree CLI for AI tasks." > "$SKILL_SRC_DIR/SKILL.md"
60
+
61
+ # OpenClaw
62
+ for dir in "$HOME/.agents/skills" "$HOME/.openclaw/workspace/skills"; do
63
+ if [ -d "$dir" ]; then
64
+ mkdir -p "$dir/skillfree"
65
+ cp "$SKILL_SRC_DIR/SKILL.md" "$dir/skillfree/SKILL.md"
66
+ echo "SKILLFREE_API_KEY=$API_KEY" > "$dir/skillfree/.env"
67
+ success "OpenClaw 配置完成 → $dir/skillfree/"
68
+ fi
69
+ done
70
+
71
+ # Claude Code
72
+ if [ -d "$HOME/.claude" ]; then
73
+ mkdir -p "$HOME/.claude/skills/skillfree"
74
+ cp "$SKILL_SRC_DIR/SKILL.md" "$HOME/.claude/skills/skillfree/SKILL.md"
75
+ success "Claude Code 配置完成 → ~/.claude/skills/skillfree/"
76
+ fi
77
+
78
+ # Codex CLI
79
+ if [ -d "$HOME/.codex" ]; then
80
+ mkdir -p "$HOME/.codex/skills/skillfree"
81
+ cp "$SKILL_SRC_DIR/SKILL.md" "$HOME/.codex/skills/skillfree/SKILL.md"
82
+ success "Codex 配置完成 → ~/.codex/skills/skillfree/"
83
+ fi
84
+
85
+ # Continue.dev
86
+ if [ -d "$HOME/.continue" ]; then
87
+ mkdir -p "$HOME/.continue/skills/skillfree"
88
+ cp "$SKILL_SRC_DIR/SKILL.md" "$HOME/.continue/skills/skillfree/SKILL.md"
89
+ success "Continue.dev 配置完成 → ~/.continue/skills/skillfree/"
90
+ fi
91
+ fi
92
+
93
+ # ── 6. 完成 ───────────────────────────────────────────────────────────────────
94
+ echo ""
95
+ echo -e "${GREEN}🎉 安装完成!${NC}"
96
+ echo ""
97
+ echo -e " ${CYAN}快速开始:${NC}"
98
+ echo -e " $ skillfree pilot --type chat --prompt \"你好龙虾\""
99
+ echo -e " $ skillfree pilot --type image --prompt \"赛博朋克的上海\" --output ./img.png"
100
+ echo ""
101
+ echo -e " ${CYAN}充值积分:${NC} https://skillfree.ai/billing"
102
+ echo -e " ${CYAN}查看文档:${NC} https://skillfree.ai/docs"
103
+ echo ""
package/package.json ADDED
@@ -0,0 +1,22 @@
1
+ {
2
+ "name": "skillfree",
3
+ "version": "0.1.0",
4
+ "description": "🦞 一个 API,满足所有龙虾技能需求",
5
+ "main": "bin/skillfree.js",
6
+ "bin": {
7
+ "skillfree": "./bin/skillfree.js"
8
+ },
9
+ "scripts": {
10
+ "test": "node bin/skillfree.js --help"
11
+ },
12
+ "keywords": ["ai", "skillfree", "openclaw", "claude", "llm"],
13
+ "homepage": "https://skillfree.tech",
14
+ "repository": {
15
+ "type": "git",
16
+ "url": "https://github.com/ChongC1990/skillfree"
17
+ },
18
+ "license": "MIT",
19
+ "dependencies": {
20
+ "commander": "^12.0.0"
21
+ }
22
+ }
@@ -0,0 +1,59 @@
1
+ const readline = require('readline')
2
+ const { saveConfig, BASE_URL } = require('../lib/client')
3
+
4
+ async function authLogin() {
5
+ console.log(`\n🦞 SkillFree 登录`)
6
+ console.log(` 控制台:${BASE_URL}/app\n`)
7
+
8
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout })
9
+ const ask = (q) => new Promise(resolve => rl.question(q, resolve))
10
+
11
+ console.log('请前往控制台 → API Keys → 创建 Key,然后粘贴到此处。')
12
+ const key = (await ask('API Key (sk-sf-...): ')).trim()
13
+ rl.close()
14
+
15
+ if (!key.startsWith('sk-sf-')) {
16
+ console.error('❌ 格式不正确,Key 应以 sk-sf- 开头')
17
+ process.exit(1)
18
+ }
19
+
20
+ // 验证 key 是否有效
21
+ process.stdout.write('验证中...')
22
+ try {
23
+ const res = await fetch(`${BASE_URL}/v1/balance`, {
24
+ headers: { 'Authorization': `Bearer ${key}` }
25
+ })
26
+ if (!res.ok) throw new Error(res.status)
27
+ const data = await res.json()
28
+ saveConfig({ apiKey: key })
29
+ console.log(` ✅\n`)
30
+ console.log(`🎉 登录成功!当前积分:${data.credits}`)
31
+ console.log(`\n快速开始:`)
32
+ console.log(` skillfree pilot --type chat --prompt "你好"`)
33
+ } catch (e) {
34
+ console.log(` ❌\nAPI Key 无效,请重新检查`)
35
+ process.exit(1)
36
+ }
37
+ }
38
+
39
+ async function authStatus() {
40
+ const { getApiKey, BASE_URL } = require('../lib/client')
41
+ const key = getApiKey()
42
+ if (!key) {
43
+ console.log('未登录。运行: skillfree auth login')
44
+ return
45
+ }
46
+ const res = await fetch(`${BASE_URL}/v1/balance`, {
47
+ headers: { 'Authorization': `Bearer ${key}` }
48
+ })
49
+ if (!res.ok) {
50
+ console.log('API Key 已失效,请重新登录')
51
+ return
52
+ }
53
+ const data = await res.json()
54
+ console.log(`✅ 已登录`)
55
+ console.log(` 剩余积分:${data.credits}`)
56
+ console.log(` Key:${key.slice(0, 10)}****${key.slice(-4)}`)
57
+ }
58
+
59
+ module.exports = { authLogin, authStatus }
@@ -0,0 +1,32 @@
1
+ const { run } = require('./run')
2
+
3
+ /**
4
+ * Chat completion command
5
+ * @param {object} params - Chat parameters
6
+ * @param {string} params.model - Model in "vendor/model" format
7
+ * @param {string} [params.prompt] - Simple prompt (converted to messages)
8
+ * @param {Array} [params.messages] - Full messages array
9
+ * @param {string} [params.system] - System prompt
10
+ * @param {boolean} [params.stream] - Enable streaming
11
+ * @param {number} [params.maxTokens] - Max tokens
12
+ * @param {number} [params.temperature] - Temperature
13
+ * @returns {Promise<object|AsyncGenerator>} Chat response or stream
14
+ */
15
+ async function chat(params) {
16
+ let messages = params.messages
17
+ if (!messages && params.prompt) {
18
+ messages = [{ role: 'user', content: params.prompt }]
19
+ }
20
+ if (!messages) {
21
+ throw new Error('Either --prompt or --messages is required')
22
+ }
23
+
24
+ const inputs = { messages }
25
+ if (params.system) inputs.system = params.system
26
+ if (params.maxTokens) inputs.max_tokens = params.maxTokens
27
+ if (params.temperature !== undefined) inputs.temperature = params.temperature
28
+
29
+ return run({ model: params.model, inputs, stream: params.stream })
30
+ }
31
+
32
+ module.exports = { chat }
@@ -0,0 +1,92 @@
1
+ const { run } = require('./run')
2
+
3
+ /**
4
+ * Image generation command
5
+ * @param {object} params - Image parameters
6
+ * @param {string} params.model - Model in "vendor/model" format
7
+ * @param {string} params.prompt - Image generation prompt
8
+ * @param {string} [params.size] - Image size (e.g., "1024x1024")
9
+ * @param {string} [params.output] - Output file path
10
+ * @returns {Promise<object>} Image generation result
11
+ */
12
+ async function image(params) {
13
+ if (!params.prompt) {
14
+ throw new Error('--prompt is required for image generation')
15
+ }
16
+
17
+ const [vendor] = params.model.split('/')
18
+ const inputs = {}
19
+
20
+ if (vendor === 'vertex') {
21
+ inputs.messages = [{ role: 'user', content: params.prompt }]
22
+ } else if (vendor === 'mm') {
23
+ // MM uses prompt and size in "1024*1536" format
24
+ inputs.prompt = params.prompt
25
+ if (params.size) {
26
+ // Convert "1024x1536" to "1024*1536" if needed
27
+ inputs.size = params.size.replace('x', '*')
28
+ }
29
+ } else {
30
+ inputs.prompt = params.prompt
31
+ if (params.size) inputs.size = params.size
32
+ }
33
+
34
+ return run({ model: params.model, inputs, output: params.output })
35
+ }
36
+
37
+ /**
38
+ * Image upscale command (FAL creative-upscaler)
39
+ * @param {object} params - Upscale parameters
40
+ * @param {string} params.imageUrl - URL of image to upscale
41
+ * @param {number} [params.scale] - Upscale factor: 2 or 4 (default: 2)
42
+ * @param {string} [params.outputFormat] - "png" or "jpeg" (default: "png")
43
+ * @param {string} [params.output] - Output file path
44
+ * @returns {Promise<object>} Upscale result {image_url, images}
45
+ */
46
+ async function upscale(params) {
47
+ if (!params.imageUrl) {
48
+ throw new Error('--image-url is required for upscale')
49
+ }
50
+
51
+ const inputs = {
52
+ image_url: params.imageUrl,
53
+ scale: params.scale || 2,
54
+ output_format: params.outputFormat || 'png',
55
+ }
56
+
57
+ return run({ model: 'fal/upscale', inputs, output: params.output })
58
+ }
59
+
60
+ /**
61
+ * Image-to-image transformation command (FAL FLUX dev)
62
+ * @param {object} params - img2img parameters
63
+ * @param {string} params.imageUrl - URL of source image
64
+ * @param {string} params.prompt - Transformation description
65
+ * @param {number} [params.strength] - Transform strength 0.0-1.0 (default: 0.75)
66
+ * @param {string} [params.imageSize] - Size preset: square_hd, square, portrait_4_3, landscape_16_9, etc. (default: square_hd)
67
+ * @param {string} [params.outputFormat] - "jpeg" or "png" (default: "jpeg")
68
+ * @param {number} [params.numImages] - Number of images 1-4 (default: 1)
69
+ * @param {string} [params.output] - Output file path
70
+ * @returns {Promise<object>} img2img result {image_url, images}
71
+ */
72
+ async function img2img(params) {
73
+ if (!params.imageUrl) {
74
+ throw new Error('--image-url is required for img2img')
75
+ }
76
+ if (!params.prompt) {
77
+ throw new Error('--prompt is required for img2img')
78
+ }
79
+
80
+ const inputs = {
81
+ image_url: params.imageUrl,
82
+ prompt: params.prompt,
83
+ strength: params.strength || 0.75,
84
+ image_size: params.imageSize || 'square_hd',
85
+ output_format: params.outputFormat || 'jpeg',
86
+ num_images: params.numImages || 1,
87
+ }
88
+
89
+ return run({ model: 'fal/img2img', inputs, output: params.output })
90
+ }
91
+
92
+ module.exports = { image, upscale, img2img }
@@ -0,0 +1,32 @@
1
+ const { apiHubGet } = require('../lib/client')
2
+
3
+ /**
4
+ * List available models from API Hub
5
+ * @param {object} [params] - List parameters
6
+ * @param {string} [params.type] - Filter by category (chat, tts, image, video, scraping, etc.)
7
+ * @param {string} [params.vendor] - Filter by vendor
8
+ * @returns {Promise<object>} Models list
9
+ */
10
+ async function listModels(params = {}) {
11
+ const response = await apiHubGet('/v1/models')
12
+ let models = response.models || []
13
+
14
+ // Filter by category/type
15
+ if (params.type) {
16
+ const typeFilter = params.type.toLowerCase()
17
+ models = models.filter(m =>
18
+ m.category?.toLowerCase() === typeFilter ||
19
+ m.type?.toLowerCase() === typeFilter
20
+ )
21
+ }
22
+
23
+ // Filter by vendor
24
+ if (params.vendor) {
25
+ const vendorFilter = params.vendor.toLowerCase()
26
+ models = models.filter(m => m.vendor?.toLowerCase() === vendorFilter)
27
+ }
28
+
29
+ return { count: models.length, models }
30
+ }
31
+
32
+ module.exports = { listModels }
@@ -0,0 +1,28 @@
1
+ const { run } = require('./run')
2
+
3
+ /**
4
+ * Music generation command
5
+ * @param {object} params - Music parameters
6
+ * @param {string} params.model - Model in "vendor/model" format
7
+ * @param {string} params.prompt - Music generation prompt
8
+ * @param {number} [params.duration] - Duration in seconds
9
+ * @param {string} [params.output] - Output file path
10
+ * @returns {Promise<object>} Music generation result
11
+ */
12
+ async function music(params) {
13
+ if (!params.prompt) {
14
+ throw new Error('--prompt is required for music generation')
15
+ }
16
+
17
+ const inputs = {
18
+ prompt: params.prompt,
19
+ }
20
+
21
+ if (params.duration) {
22
+ inputs.duration = parseInt(params.duration)
23
+ }
24
+
25
+ return run({ model: params.model, inputs, output: params.output })
26
+ }
27
+
28
+ module.exports = { music }
@@ -0,0 +1,100 @@
1
+ const { post, postStream, BASE_URL } = require('../lib/client')
2
+ const fs = require('fs')
3
+ const path = require('path')
4
+
5
+ async function pilot(flags) {
6
+ const type = flags.type || 'chat'
7
+ const prompt = flags.prompt || flags.text || ''
8
+ const output = flags.output || null
9
+
10
+ // 构造请求体
11
+ const body = { type, inputs: {} }
12
+
13
+ switch (type) {
14
+ case 'chat':
15
+ body.model = flags.model
16
+ body.inputs.messages = [{ role: 'user', content: prompt }]
17
+ break
18
+ case 'image':
19
+ body.model = flags.model
20
+ body.inputs.prompt = prompt
21
+ if (flags.size) body.inputs.size = flags.size
22
+ break
23
+ case 'tts':
24
+ body.model = flags.model
25
+ body.inputs.text = prompt
26
+ body.inputs.input = prompt
27
+ body.inputs.voice = flags.voice || 'nova'
28
+ break
29
+ case 'stt':
30
+ if (flags.file) {
31
+ body.inputs.audio_data = fs.readFileSync(path.resolve(flags.file)).toString('base64')
32
+ body.inputs.filename = path.basename(flags.file)
33
+ }
34
+ break
35
+ case 'video':
36
+ body.model = flags.model
37
+ body.inputs.prompt = prompt
38
+ body.inputs.duration = flags.duration ? parseInt(flags.duration) : 5
39
+ break
40
+ case 'music':
41
+ body.model = flags.model
42
+ body.inputs.prompt = prompt
43
+ break
44
+ default:
45
+ body.inputs.prompt = prompt
46
+ }
47
+
48
+ // 流式输出(chat 默认流式)
49
+ if (type === 'chat' && !output) {
50
+ const res = await postStream('/chat/completions', {
51
+ model: flags.model || 'DeepSeek-V3.2-Fast',
52
+ messages: [{ role: 'user', content: prompt }],
53
+ stream: true,
54
+ })
55
+
56
+ const reader = res.body.getReader()
57
+ const decoder = new TextDecoder()
58
+ let buf = ''
59
+ process.stdout.write('\n')
60
+ while (true) {
61
+ const { done, value } = await reader.read()
62
+ if (done) break
63
+ buf += decoder.decode(value, { stream: true })
64
+ const lines = buf.split('\n')
65
+ buf = lines.pop()
66
+ for (const line of lines) {
67
+ if (line.startsWith('data: ')) {
68
+ const chunk = line.slice(6)
69
+ if (chunk === '[DONE]') { process.stdout.write('\n'); return }
70
+ try {
71
+ const d = JSON.parse(chunk)
72
+ const text = d.choices?.[0]?.delta?.content
73
+ if (text) process.stdout.write(text)
74
+ } catch {}
75
+ }
76
+ }
77
+ }
78
+ return
79
+ }
80
+
81
+ // 非流式
82
+ const result = await post('/pilot', body)
83
+
84
+ // 如果有媒体 URL 且指定了输出文件,下载保存
85
+ const mediaUrl = result.image_url || result.video_url || result.audio_url
86
+ || result.data?.[0]?.url || result.data?.[0]
87
+ if (output && mediaUrl) {
88
+ const resp = await fetch(typeof mediaUrl === 'string' ? mediaUrl : mediaUrl.url)
89
+ fs.writeFileSync(output, Buffer.from(await resp.arrayBuffer()))
90
+ console.log(`✅ 已保存到 ${output}`)
91
+ return
92
+ }
93
+
94
+ // 文本输出
95
+ const text = result.choices?.[0]?.message?.content
96
+ || result.text || result.transcript || JSON.stringify(result, null, 2)
97
+ console.log(text)
98
+ }
99
+
100
+ module.exports = { pilot }
@@ -0,0 +1,47 @@
1
+ const fs = require('fs')
2
+ const path = require('path')
3
+ const { run } = require('./run')
4
+
5
+ /**
6
+ * Speech-to-text command
7
+ * @param {object} params - STT parameters
8
+ * @param {string} params.file - Local audio file path
9
+ * @param {string} [params.model] - Model (default: openai/whisper-1)
10
+ * @param {string} [params.prompt] - Optional prompt to guide transcription style
11
+ * @param {string} [params.language] - Optional language code (e.g., "en")
12
+ * @param {string} [params.output] - Optional output file path for transcript
13
+ * @returns {Promise<object>} STT result with transcribed text
14
+ */
15
+ async function stt(params) {
16
+ if (!params.file) {
17
+ throw new Error('--file is required for STT (local audio file path)')
18
+ }
19
+
20
+ const filePath = path.resolve(params.file)
21
+ if (!fs.existsSync(filePath)) {
22
+ throw new Error(`Audio file not found: ${filePath}`)
23
+ }
24
+
25
+ const audioData = fs.readFileSync(filePath).toString('base64')
26
+ const filename = path.basename(filePath)
27
+
28
+ const inputs = {
29
+ audio_data: audioData,
30
+ filename,
31
+ }
32
+ if (params.prompt) inputs.prompt = params.prompt
33
+ if (params.language) inputs.language = params.language
34
+
35
+ const model = params.model || 'openai/whisper-1'
36
+ const result = await run({ model, inputs })
37
+
38
+ const text = result.text || JSON.stringify(result)
39
+
40
+ if (params.output) {
41
+ fs.writeFileSync(params.output, text)
42
+ }
43
+
44
+ return { text, ...(params.output ? { saved: params.output } : {}) }
45
+ }
46
+
47
+ module.exports = { stt }
@@ -0,0 +1,67 @@
1
+ const { run } = require('./run')
2
+
3
+ /**
4
+ * Text-to-speech command
5
+ * @param {object} params - TTS parameters
6
+ * @param {string} params.model - Model in "vendor/model" format
7
+ * @param {string} params.text - Text to synthesize
8
+ * @param {string} [params.voiceId] - Voice ID (provider-specific)
9
+ * @param {string} params.output - Output audio file path
10
+ * @returns {Promise<object>} TTS result
11
+ */
12
+ async function tts(params) {
13
+ if (!params.text) {
14
+ throw new Error('--text is required for TTS')
15
+ }
16
+ if (!params.output) {
17
+ throw new Error('--output is required for TTS')
18
+ }
19
+
20
+ const inputs = {}
21
+
22
+ // Provider-specific input mapping
23
+ const [vendor] = params.model.split('/')
24
+ if (vendor === 'elevenlabs') {
25
+ // ElevenLabs uses 'text' and requires voice_id - default to "Rachel"
26
+ inputs.text = params.text
27
+ inputs.voice_id = params.voiceId || 'EXAVITQu4vr4xnSDxMaL'
28
+ } else if (vendor === 'minimax') {
29
+ // MiniMax uses 'text' and 'voice_setting' object
30
+ inputs.text = params.text
31
+ inputs.voice_setting = {
32
+ voice_id: params.voiceId || 'male-qn-qingse',
33
+ speed: 1.0,
34
+ vol: 1.0,
35
+ pitch: 0,
36
+ }
37
+ } else if (vendor === 'openai') {
38
+ // OpenAI TTS uses 'input' and 'voice'
39
+ inputs.input = params.text
40
+ inputs.voice = params.voiceId || 'alloy'
41
+ } else if (vendor === 'replicate') {
42
+ // Replicate XTTS uses 'text' and requires 'speaker' (audio URL for voice cloning)
43
+ inputs.text = params.text
44
+ if (params.speaker) {
45
+ inputs.speaker = params.speaker
46
+ } else if (params.voiceId) {
47
+ inputs.speaker = params.voiceId
48
+ } else {
49
+ // Default speaker sample
50
+ inputs.speaker =
51
+ 'https://replicate.delivery/pbxt/Jt79w0xsT64R1JsiJ0LQRL8UcWspg5J4RFrU6YwEKpOT1ukS/male.wav'
52
+ }
53
+ } else if (vendor === 'mm') {
54
+ // MM TTS (qwen3-tts-flash) uses 'text' and optional 'voice'
55
+ inputs.text = params.text
56
+ if (params.voiceId) {
57
+ inputs.voice = params.voiceId
58
+ }
59
+ } else {
60
+ // Default: use 'text'
61
+ inputs.text = params.text
62
+ }
63
+
64
+ return run({ model: params.model, inputs, output: params.output })
65
+ }
66
+
67
+ module.exports = { tts }
@@ -0,0 +1,97 @@
1
+ const { run } = require('./run')
2
+
3
+ /**
4
+ * Video generation command
5
+ * @param {object} params - Video parameters
6
+ * @param {string} params.model - Model in "vendor/model" format
7
+ * @param {string} params.prompt - Video generation prompt
8
+ * @param {string} [params.output] - Output file path
9
+ * @returns {Promise<object>} Video generation result
10
+ */
11
+ async function video(params) {
12
+ if (!params.prompt) {
13
+ throw new Error('--prompt is required for video generation')
14
+ }
15
+
16
+ const [vendor] = params.model.split('/')
17
+ const inputs = {}
18
+
19
+ if (vendor === 'vertex') {
20
+ // Vertex/Veo uses instances array format
21
+ inputs.instances = [{ prompt: params.prompt }]
22
+ inputs.parameters = {}
23
+ } else if (vendor === 'mm') {
24
+ // MM video models: t2v (text-to-video), i2v (image-to-video)
25
+ inputs.prompt = params.prompt
26
+ if (params.size) {
27
+ // Convert "1280x720" to "1280*720" if needed
28
+ inputs.size = params.size.replace('x', '*')
29
+ }
30
+ if (params.duration) {
31
+ inputs.duration = parseInt(params.duration)
32
+ }
33
+ if (params.image) {
34
+ // i2v mode: image-to-video
35
+ inputs.image_url = params.image
36
+ }
37
+ } else {
38
+ // MiniMax and others use 'prompt'
39
+ inputs.prompt = params.prompt
40
+ }
41
+
42
+ return run({ model: params.model, inputs, output: params.output })
43
+ }
44
+
45
+ /**
46
+ * Multimodal understanding command (video/image/audio analysis)
47
+ * @param {object} params - Multimodal parameters
48
+ * @param {string} params.model - Model in "vendor/model" format (e.g., mm/qwen3-vl-plus)
49
+ * @param {string} params.prompt - Text prompt/question about the media
50
+ * @param {string} [params.video] - Video URL to analyze
51
+ * @param {string} [params.image] - Image URL to analyze
52
+ * @param {string} [params.audio] - Audio URL to analyze/transcribe
53
+ * @returns {Promise<object>} Multimodal analysis result
54
+ */
55
+ async function multimodal(params) {
56
+ if (!params.prompt) {
57
+ throw new Error('--prompt is required for multimodal')
58
+ }
59
+ if (!params.video && !params.image && !params.audio) {
60
+ throw new Error('At least one of --video, --image, or --audio is required')
61
+ }
62
+
63
+ const [vendor] = params.model.split('/')
64
+ const inputs = {}
65
+
66
+ if (vendor === 'mm') {
67
+ // MM multimodal models use messages format
68
+ const content = []
69
+ if (params.video) {
70
+ content.push({ video: params.video })
71
+ if (params.fps) {
72
+ content[content.length - 1].fps = parseInt(params.fps)
73
+ }
74
+ }
75
+ if (params.image) {
76
+ content.push({ image: params.image })
77
+ }
78
+ if (params.audio) {
79
+ content.push({ audio: params.audio })
80
+ }
81
+ content.push({ text: params.prompt })
82
+
83
+ inputs.input = {
84
+ messages: [{ role: 'user', content }]
85
+ }
86
+ } else {
87
+ // Generic format
88
+ inputs.prompt = params.prompt
89
+ if (params.video) inputs.video_url = params.video
90
+ if (params.image) inputs.image_url = params.image
91
+ if (params.audio) inputs.audio_url = params.audio
92
+ }
93
+
94
+ return run({ model: params.model, inputs })
95
+ }
96
+
97
+ module.exports = { video, multimodal }
@@ -0,0 +1,92 @@
1
+ const fs = require('fs')
2
+ const path = require('path')
3
+ const os = require('os')
4
+
5
+ // ─── 配置路径 ─────────────────────────────────────────────────────────────────
6
+ // API Key 存储在 ~/.skillfree/config.json
7
+ const CONFIG_DIR = path.join(os.homedir(), '.skillfree')
8
+ const CONFIG_PATH = path.join(CONFIG_DIR, 'config.json')
9
+
10
+ const BASE_URL = 'https://skillfree.tech'
11
+ const API_BASE = `${BASE_URL}/v1`
12
+
13
+ // ─── 读写配置 ─────────────────────────────────────────────────────────────────
14
+ function loadConfig() {
15
+ try {
16
+ if (fs.existsSync(CONFIG_PATH)) {
17
+ return JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8'))
18
+ }
19
+ } catch {}
20
+ return {}
21
+ }
22
+
23
+ function saveConfig(cfg) {
24
+ fs.mkdirSync(CONFIG_DIR, { recursive: true })
25
+ fs.writeFileSync(CONFIG_PATH, JSON.stringify(cfg, null, 2) + '\n')
26
+ }
27
+
28
+ let _config = loadConfig()
29
+
30
+ function getApiKey() {
31
+ return _config.apiKey || process.env.SKILLFREE_API_KEY || ''
32
+ }
33
+
34
+ // ─── HTTP 工具 ────────────────────────────────────────────────────────────────
35
+ async function request(endpoint, options = {}) {
36
+ const apiKey = getApiKey()
37
+ if (!apiKey) {
38
+ throw new Error('未登录,请先运行: skillfree auth login')
39
+ }
40
+
41
+ const url = `${API_BASE}${endpoint}`
42
+ const res = await fetch(url, {
43
+ ...options,
44
+ headers: {
45
+ 'Content-Type': 'application/json',
46
+ 'Authorization': `Bearer ${apiKey}`,
47
+ ...(options.headers || {}),
48
+ },
49
+ })
50
+
51
+ if (res.status === 402) throw new Error('积分不足,请前往 https://skillfree.tech/app 充值')
52
+ if (res.status === 401) throw new Error('API Key 无效或已过期,请重新登录: skillfree auth login')
53
+
54
+ return res
55
+ }
56
+
57
+ async function post(endpoint, body) {
58
+ const res = await request(endpoint, {
59
+ method: 'POST',
60
+ body: JSON.stringify(body),
61
+ })
62
+ return res.json()
63
+ }
64
+
65
+ async function postStream(endpoint, body) {
66
+ const res = await request(endpoint, {
67
+ method: 'POST',
68
+ headers: { 'Accept': 'text/event-stream' },
69
+ body: JSON.stringify({ ...body, stream: true }),
70
+ })
71
+ return res
72
+ }
73
+
74
+ async function get(endpoint) {
75
+ const res = await request(endpoint, { method: 'GET' })
76
+ return res.json()
77
+ }
78
+
79
+ // ─── 导出 ─────────────────────────────────────────────────────────────────────
80
+ module.exports = {
81
+ BASE_URL,
82
+ API_BASE,
83
+ loadConfig,
84
+ saveConfig,
85
+ getApiKey,
86
+ get _config() { return _config },
87
+ set _config(v) { _config = v },
88
+ request,
89
+ post,
90
+ postStream,
91
+ get,
92
+ }
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Shared fetch with retry logic for network errors and rate limits
3
+ */
4
+
5
+ const MAX_RETRIES = 3
6
+ const INITIAL_DELAY = 5000 // 5 seconds
7
+
8
+ function sleep(ms) {
9
+ return new Promise(resolve => setTimeout(resolve, ms))
10
+ }
11
+
12
+ /**
13
+ * Fetch with automatic retry for network errors and rate limits (429)
14
+ *
15
+ * Design principles:
16
+ * - No HTTP timeout: Let server control (video generation can take 10+ minutes)
17
+ * - Retry on: Network errors (TypeError: fetch failed), 429 rate limit
18
+ * - Don't retry: 4xx client errors (except 429), 5xx server errors (server has retry)
19
+ *
20
+ * @param {string} url - The URL to fetch
21
+ * @param {object} options - Fetch options
22
+ * @param {number} maxRetries - Maximum number of retries (default: 3)
23
+ * @returns {Promise<Response>} The fetch response
24
+ */
25
+ async function fetchWithRetry(url, options = {}, maxRetries = MAX_RETRIES) {
26
+ let lastError
27
+
28
+ for (let attempt = 1; attempt <= maxRetries; attempt++) {
29
+ try {
30
+ const response = await fetch(url, options)
31
+
32
+ // Handle rate limit (429)
33
+ if (response.status === 429) {
34
+ if (attempt >= maxRetries) {
35
+ return response // Return the 429 response on final attempt
36
+ }
37
+
38
+ const retryAfter = response.headers.get('Retry-After')
39
+ const delay = retryAfter ? parseInt(retryAfter) * 1000 : INITIAL_DELAY * attempt
40
+ console.log(`Rate limited. Waiting ${delay / 1000}s before retry ${attempt}/${maxRetries}...`)
41
+ await sleep(delay)
42
+ continue
43
+ }
44
+
45
+ // Return response for all other status codes (let caller handle errors)
46
+ return response
47
+ } catch (error) {
48
+ lastError = error
49
+
50
+ // Only retry on network errors (TypeError: fetch failed)
51
+ if (attempt < maxRetries) {
52
+ const delay = INITIAL_DELAY * attempt
53
+ console.log(`Network error: ${error.message}. Retry ${attempt}/${maxRetries} in ${delay / 1000}s...`)
54
+ await sleep(delay)
55
+ }
56
+ }
57
+ }
58
+
59
+ throw lastError
60
+ }
61
+
62
+ module.exports = { fetchWithRetry, sleep }