touka-ai 0.2.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.
Files changed (2) hide show
  1. package/package.json +24 -0
  2. package/src/cli.js +585 -0
package/package.json ADDED
@@ -0,0 +1,24 @@
1
+ {
2
+ "name": "touka-ai",
3
+ "version": "0.2.0",
4
+ "description": "Touka CLI — AI Gateway client for skills, LLM, AIGC, ASR, TTS",
5
+ "type": "module",
6
+ "bin": {
7
+ "touka": "./src/cli.js"
8
+ },
9
+ "files": [
10
+ "src/cli.js"
11
+ ],
12
+ "engines": {
13
+ "node": ">=18.0.0"
14
+ },
15
+ "scripts": {
16
+ "start": "node src/cli.js"
17
+ },
18
+ "keywords": ["ai", "agent", "skill", "cli", "codex", "claude-code", "llm", "tts", "asr", "aigc"],
19
+ "license": "MIT",
20
+ "repository": {
21
+ "type": "git",
22
+ "url": "https://github.com/touka-ai/touka-cli"
23
+ }
24
+ }
package/src/cli.js ADDED
@@ -0,0 +1,585 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Touka CLI v0.2.0 — AI Gateway Client
5
+ * Modules: skill, aigc, llm, asr, tts, config
6
+ *
7
+ * Requires Node.js >= 18
8
+ */
9
+
10
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, unlinkSync } from 'fs';
11
+ import { join, resolve, basename } from 'path';
12
+ import { homedir } from 'os';
13
+
14
+ const PKG_NAME = 'touka-ai';
15
+ const VERSION = '0.2.0';
16
+ const CONFIG_DIR = join(homedir(), '.touka');
17
+ const CONFIG_FILE = join(CONFIG_DIR, 'config.json');
18
+ const UPDATE_CHECK_FILE = join(CONFIG_DIR, '.update-check');
19
+ const MAX_ROWS = 20;
20
+
21
+ // ── Update check (non-blocking, runs in background) ──
22
+ function checkForUpdate() {
23
+ try {
24
+ // Only check once per day
25
+ if (existsSync(UPDATE_CHECK_FILE)) {
26
+ const last = JSON.parse(readFileSync(UPDATE_CHECK_FILE, 'utf-8'));
27
+ if (Date.now() - last.ts < 86400000) {
28
+ if (last.latest && last.latest !== VERSION) {
29
+ console.log(`\n Update available: v${VERSION} → v${last.latest}`);
30
+ console.log(` Run: touka update\n`);
31
+ }
32
+ return;
33
+ }
34
+ }
35
+ } catch {}
36
+ // Fire-and-forget: query npm registry
37
+ fetch(`https://registry.npmjs.org/${PKG_NAME}/latest`, { signal: AbortSignal.timeout(3000) })
38
+ .then(r => r.ok ? r.json() : null)
39
+ .then(d => {
40
+ if (!d?.version) return;
41
+ mkdirSync(CONFIG_DIR, { recursive: true });
42
+ writeFileSync(UPDATE_CHECK_FILE, JSON.stringify({ ts: Date.now(), latest: d.version }));
43
+ if (d.version !== VERSION) {
44
+ console.log(`\n Update available: v${VERSION} → v${d.version}`);
45
+ console.log(` Run: touka update\n`);
46
+ }
47
+ })
48
+ .catch(() => {}); // silently ignore network errors
49
+ }
50
+
51
+ // ── Config ──
52
+ function loadConfig() { try { return existsSync(CONFIG_FILE) ? JSON.parse(readFileSync(CONFIG_FILE, 'utf-8')) : {}; } catch { return {}; } }
53
+ function saveConfig(c) { mkdirSync(CONFIG_DIR, { recursive: true }); writeFileSync(CONFIG_FILE, JSON.stringify(c, null, 2)); }
54
+ function ep() { return loadConfig().endpoint || process.env.SKYLINK_ENDPOINT || ''; }
55
+ function ak() { return loadConfig().api_key || process.env.SKYLINK_API_KEY || ''; }
56
+ function tk() { return loadConfig().token || process.env.SKYLINK_TOKEN || ''; }
57
+
58
+ // ── HTTP ──
59
+ async function api(method, path, { body, stream, auth } = {}) {
60
+ const e = ep();
61
+ if (!e) throw new Error('Not configured.\n Run: touka config set --endpoint URL --api-key KEY');
62
+ const t = auth === 'token' ? tk() : ak();
63
+ if (!t) throw new Error(`Missing ${auth === 'token' ? 'token' : 'API key'}.\n Run: touka config set --${auth === 'token' ? 'token T' : 'api-key K'}`);
64
+ const url = `${e.replace(/\/$/, '')}/v1${path}`;
65
+ const h = { 'Authorization': `Bearer ${t}` };
66
+ const o = { method, headers: h };
67
+ if (body instanceof FormData) o.body = body;
68
+ else if (body) { h['Content-Type'] = 'application/json'; o.body = JSON.stringify(body); }
69
+ const r = await fetch(url, o);
70
+ if (stream) return r;
71
+ const d = await r.json();
72
+ if (r.status >= 400) throw new Error(d?.message || d?.detail || d?.data?.message || `HTTP ${r.status}`);
73
+ return d?.data ?? d;
74
+ }
75
+
76
+ // ── Output ──
77
+ function out(text, code = 0, t = Date.now()) { console.log(text); console.log(`[exit:${code} | ${Date.now()-t}ms]`); process.exit(code); }
78
+ function fail(err, t = Date.now()) { console.error(`[error] ${err instanceof Error ? err.message : err}`); console.error(`[exit:1 | ${Date.now()-t}ms]`); process.exit(1); }
79
+ function tbl(rows, cols) {
80
+ if (!rows.length) return '(no results)';
81
+ const w = cols.map(c => Math.max(c.l.length, ...rows.map((r, i) => String(c.f(r, i)).length)));
82
+ return [cols.map((c, i) => c.l.padEnd(w[i])).join(' '), cols.map((_, i) => '-'.repeat(w[i])).join(' '),
83
+ ...rows.map((r, ri) => cols.map((c, i) => String(c.f(r, ri)).padEnd(w[i])).join(' '))].join('\n');
84
+ }
85
+ function flag(args, short, long) { for (let i = 0; i < args.length; i++) if ((args[i]===short||args[i]===long) && args[i+1]) return args[++i]; return null; }
86
+ function hasHelp(args) { return args.includes('-h') || args.includes('--help'); }
87
+ function positional(args) { return args.filter(a => !a.startsWith('-')); }
88
+
89
+ // ════════════════════════════════════════════
90
+ // CONFIG
91
+ // ════════════════════════════════════════════
92
+ async function cmdConfig(args) {
93
+ const t = Date.now(), sub = args[0];
94
+ if (!sub || hasHelp(args)) out(
95
+ 'Usage: touka config <command>\n\nManage CLI configuration.\n\n' +
96
+ 'Commands:\n set Set values (--endpoint, --api-key, --token)\n show Show current config\n reset Clear all config\n\n' +
97
+ 'Options:\n -h, --help Show this help\n\n' +
98
+ 'Examples:\n touka config set --endpoint https://gateway.example.com --api-key sk-xxx\n touka config set --token YOUR_USER_TOKEN\n touka config show', 0, t);
99
+ if (sub === 'set') {
100
+ const c = loadConfig();
101
+ const e = flag(args, null, '--endpoint'), a = flag(args, null, '--api-key'), to = flag(args, null, '--token');
102
+ if (e) c.endpoint = e; if (a) c.api_key = a; if (to) c.token = to;
103
+ saveConfig(c); out(`Saved to ${CONFIG_FILE}`, 0, t);
104
+ } else if (sub === 'show') {
105
+ const c = loadConfig();
106
+ out(`endpoint: ${c.endpoint||'(not set)'}\napi_key: ${c.api_key?c.api_key.slice(0,8)+'...':'(not set)'}\ntoken: ${c.token?c.token.slice(0,8)+'...':'(not set)'}\nfile: ${CONFIG_FILE}`, 0, t);
107
+ } else if (sub === 'reset') { saveConfig({}); out('Config reset.', 0, t); }
108
+ else out(`[error] Unknown: ${sub}\n Available: set, show, reset`, 1, t);
109
+ }
110
+
111
+ // ════════════════════════════════════════════
112
+ // SKILL
113
+ // ════════════════════════════════════════════
114
+ async function cmdSkill(args) {
115
+ const t = Date.now(), sub = args[0];
116
+ if (!sub || hasHelp([sub])) out(
117
+ 'Usage: touka skill <command> [args] [flags]\n\nSearch, install, and publish AI agent skills.\n\n' +
118
+ 'Commands:\n search <query> Search public skills\n install <slug> Download and install a skill by name\n info <slug> Show skill details\n publish <path> Upload a skill zip\n list List your skills\n\n' +
119
+ 'Flags:\n -c, --category <cat> coding, devops, testing, writing, other\n -n, --limit <n> Max results (default: 20)\n -h, --help\n\n' +
120
+ 'Install:\n' +
121
+ ' Skills are installed to ~/.codex/skills/<slug>/ for Codex.\n' +
122
+ ' If a skill is not found on SkillHub, ClawHub will be searched.\n\n' +
123
+ 'Examples:\n touka skill search "code review" -c coding\n touka skill install code-review-cn\n touka skill info touka-guide\n touka skill publish ./s.zip --slug my-skill --name "My Skill"', 0, t);
124
+
125
+ const sa = args.slice(1);
126
+ if (sub === 'search') {
127
+ const q = positional(sa).join(' ');
128
+ if (!q) out('[error] Missing <query>\n Usage: touka skill search <query> [-c cat] [-n limit]', 1, t);
129
+ const cat = flag(sa, '-c', '--category'), lim = flag(sa, '-n', '--limit') || 20;
130
+ let p = `/skillhub/skills/search?q=${encodeURIComponent(q)}&limit=${lim}`;
131
+ if (cat) p += `&category=${cat}`;
132
+ const d = await api('GET', p);
133
+ if (!(d.items||[]).length) out(`No skills found for "${q}".`, 0, t);
134
+ let r = tbl(d.items.slice(0, MAX_ROWS), [
135
+ {l:'#',f:(_,i)=>i+1}, {l:'ID',f:r=>r.id}, {l:'SLUG',f:r=>r.slug},
136
+ {l:'NAME',f:r=>(r.display_name||'').slice(0,25)}, {l:'BY',f:r=>(r.author||'').slice(0,12)},
137
+ {l:'⬇',f:r=>r.install_count}, {l:'❤',f:r=>r.like_count||0}, {l:'VER',f:r=>r.version},
138
+ ]);
139
+ if (d.items.length > MAX_ROWS) r += `\n--- ${MAX_ROWS} of ${d.items.length} ---`;
140
+ r += `\n\n${d.items.length} result(s). Install: touka skill install <SLUG>`;
141
+ out(r, 0, t);
142
+ }
143
+ if (sub === 'info') {
144
+ const slug = sa[0]; if (!slug) out('[error] Missing <slug>\n Usage: touka skill info <slug>', 1, t);
145
+ const d = await api('GET', `/skillhub/skills/by-slug/${encodeURIComponent(slug)}`);
146
+ out([`${d.display_name} v${d.version}`,`by ${d.author||'?'} ❤ ${d.like_count||0} ⬇ ${d.install_count}`,'',
147
+ d.description||'(no description)','','── SKILL.md Preview ──',d.skill_md_preview||'(none)',
148
+ '',`Install: touka skill install ${d.slug}`].join('\n'), 0, t);
149
+ }
150
+ if (sub === 'install') {
151
+ const slug = sa[0]; if (!slug) out('[error] Missing <slug>\n Usage: touka skill install <slug>\n Find slugs: touka skill search <query>', 1, t);
152
+
153
+ // Step 1: resolve slug to ID via internal SkillHub
154
+ let skillId = null, skillSlug = slug, skillVer = '?';
155
+ try {
156
+ const info = await api('GET', `/skillhub/skills/by-slug/${encodeURIComponent(slug)}`);
157
+ skillId = info.id;
158
+ skillSlug = info.slug;
159
+ skillVer = info.version;
160
+ console.log(`Found "${info.display_name}" v${skillVer} on SkillHub`);
161
+ } catch {
162
+ // Not found on SkillHub → search ClawHub directly
163
+ console.log(`"${slug}" not found on SkillHub, searching ClawHub...`);
164
+ try {
165
+ const chResp = await fetch(`https://wry-manatee-359.convex.site/api/v1/search?q=${encodeURIComponent(slug)}&limit=5`);
166
+ if (!chResp.ok) throw new Error('ClawHub search failed');
167
+ const chData = await chResp.json();
168
+ const results = chData?.results || [];
169
+ const match = results.find(r => r.slug === slug) || results[0];
170
+ if (!match) out(`[error] "${slug}" not found on SkillHub or ClawHub.\n Try: touka skill search <keyword>`, 1, t);
171
+
172
+ const chSlug = match.slug;
173
+ console.log(`Found "${match.displayName}" on ClawHub (${chSlug})`);
174
+ console.log(`Downloading from ClawHub...`);
175
+
176
+ // Download zip from ClawHub
177
+ const dlResp = await fetch(`https://wry-manatee-359.convex.site/api/v1/download?slug=${encodeURIComponent(chSlug)}`);
178
+ if (!dlResp.ok) throw new Error(`ClawHub download failed: ${dlResp.status}`);
179
+ const chBuf = Buffer.from(await dlResp.arrayBuffer());
180
+
181
+ // Install to Codex
182
+ const codexDir = join(homedir(), '.codex', 'skills', chSlug);
183
+ mkdirSync(codexDir, { recursive: true });
184
+ const tmpZ = join(codexDir, '__tmp.zip'); writeFileSync(tmpZ, chBuf);
185
+ const { execSync } = await import('child_process');
186
+ try { execSync(`unzip -o "${tmpZ}" -d "${codexDir}"`, { stdio: 'pipe' }); }
187
+ catch (e) { throw new Error(`unzip failed: ${e.message}`); }
188
+ try { unlinkSync(tmpZ); } catch {}
189
+ // Clean up .git dir from ClawHub packages
190
+ const gitDir = join(codexDir, '.git');
191
+ if (existsSync(gitDir)) try { execSync(`rm -rf "${gitDir}"`, { stdio: 'pipe' }); } catch {}
192
+ const fls = readdirSync(codexDir, { recursive: true }).filter(f => !String(f).startsWith('__') && !String(f).startsWith('.git'));
193
+
194
+ out([
195
+ `Installed from ClawHub: ${chSlug} → ${codexDir}`,
196
+ ...fls.map(f => ` ${f}`), '',
197
+ `Source: ClawHub (${match.displayName})`,
198
+ `Use in Codex: the skill is now available as /${chSlug}`,
199
+ ].join('\n'), 0, t);
200
+ } catch (chErr) {
201
+ out(`[error] "${slug}" not found on SkillHub.\nClawHub: ${chErr.message}\n Try: touka skill search <keyword>`, 1, t);
202
+ }
203
+ }
204
+
205
+ if (!skillId) process.exit(0);
206
+
207
+ // Step 3: download from SkillHub
208
+ console.log(`Downloading ${skillSlug} v${skillVer}...`);
209
+ const r = await api('GET', `/skillhub/skills/${skillId}/download?agent_type=codex`, { stream: true });
210
+ if (!r.ok) { const e = await r.json().catch(()=>({})); throw new Error(e?.message||`HTTP ${r.status}`); }
211
+ const buf = Buffer.from(await r.arrayBuffer());
212
+
213
+ // Step 4: install to Codex skill directory (~/.codex/skills/<slug>/)
214
+ const codexSkillDir = join(homedir(), '.codex', 'skills', skillSlug);
215
+ mkdirSync(codexSkillDir, { recursive: true });
216
+ const tmp = join(codexSkillDir, '__tmp.zip'); writeFileSync(tmp, buf);
217
+ const { execSync } = await import('child_process');
218
+ try { execSync(`unzip -o "${tmp}" -d "${codexSkillDir}"`, { stdio: 'pipe' }); }
219
+ catch (e) { throw new Error(`unzip failed: ${e.message}`); }
220
+ try { unlinkSync(tmp); } catch {}
221
+ // Clean up .git dir
222
+ const gitD = join(codexSkillDir, '.git');
223
+ if (existsSync(gitD)) try { execSync(`rm -rf "${gitD}"`, { stdio: 'pipe' }); } catch {}
224
+ const files = readdirSync(codexSkillDir, { recursive: true }).filter(f => !String(f).startsWith('__') && !String(f).startsWith('.git'));
225
+ out([
226
+ `Installed ${skillSlug} v${skillVer} → ${codexSkillDir}`,
227
+ ...files.map(f => ` ${f}`), '',
228
+ `Use in Codex: the skill is now available as /${skillSlug}`,
229
+ ].join('\n'), 0, t);
230
+ }
231
+ if (sub === 'publish') {
232
+ const zp = sa[0]; if (!zp) out('[error] Missing <path>\n Usage: touka skill publish <path.zip> --slug s --name "N" [--category c] [--visibility v] [--version ver] [--tags t1,t2]', 1, t);
233
+ const abs = resolve(zp);
234
+ if (!existsSync(abs)) throw new Error(`File not found: ${abs}`);
235
+ if (!abs.endsWith('.zip')) throw new Error('Not a .zip file.\n Create: zip -r my-skill.zip SKILL.md');
236
+ const sl = flag(sa, null, '--slug'), nm = flag(sa, null, '--name');
237
+ if (!sl) throw new Error('Missing --slug'); if (!nm) throw new Error('Missing --name');
238
+ const desc = flag(sa, null, '--description'), cat = flag(sa, null, '--category');
239
+ const vis = flag(sa, null, '--visibility') || 'private', ver = flag(sa, null, '--version') || '1.0.0';
240
+ const tags = flag(sa, null, '--tags');
241
+ console.log(`Uploading ${basename(abs)}...`);
242
+ const fd = new FormData();
243
+ fd.append('file', new Blob([readFileSync(abs)], { type: 'application/zip' }), basename(abs));
244
+ fd.append('slug', sl); fd.append('display_name', nm);
245
+ if (desc) fd.append('description', desc); if (cat) fd.append('category', cat);
246
+ if (tags) fd.append('tags', JSON.stringify(tags.split(',')));
247
+ fd.append('visibility', vis); fd.append('version', ver);
248
+ const d = await api('POST', '/admin/skillhub/skills', { body: fd, auth: 'token' });
249
+ const lines = [`Published: ${sl} v${ver} (id: ${d.id})`, `Visibility: ${d.visibility}`];
250
+ if (d.review_status==='rejected') lines.push(`Review: REJECTED — ${d.review_notes}`);
251
+ else if (d.review_status==='approved') lines.push('Review: APPROVED');
252
+ out(lines.join('\n'), 0, t);
253
+ }
254
+ if (sub === 'list') {
255
+ const d = await api('GET', '/admin/skillhub/skills?my_only=true&page_size=50', { auth: 'token' });
256
+ if (!(d.items||[]).length) out('No skills yet.\n Publish: touka skill publish ./s.zip --slug s --name "S"', 0, t);
257
+ out(tbl(d.items, [{l:'ID',f:r=>r.id},{l:'SLUG',f:r=>r.slug},{l:'NAME',f:r=>(r.display_name||'').slice(0,25)},
258
+ {l:'VIS',f:r=>r.visibility},{l:'VER',f:r=>r.version},{l:'⬇',f:r=>r.install_count},{l:'❤',f:r=>r.like_count||0}])
259
+ +`\n\n${d.items.length} skill(s)`, 0, t);
260
+ }
261
+ }
262
+
263
+ // ════════════════════════════════════════════
264
+ // AIGC (image, video, status)
265
+ // ════════════════════════════════════════════
266
+ async function cmdAigc(args) {
267
+ const t = Date.now(), sub = args[0];
268
+ if (!sub || hasHelp([sub])) out(
269
+ 'Usage: touka aigc <command> [args] [flags]\n\nGenerate images and videos via AI.\n\n' +
270
+ 'Commands:\n image <prompt> Text-to-image generation\n video <prompt> Text-to-video generation\n status <task_id> Check task status\n\n' +
271
+ 'Image flags:\n' +
272
+ ' --model <model> Model (default: doubao-seedream-5-0-260128)\n' +
273
+ ' --size <WxH> Size: 1024x1024, 768x1024, 512x512 etc.\n' +
274
+ ' -n <count> Number of images 1-8 (default: 1)\n' +
275
+ ' --negative <text> Negative prompt\n\n' +
276
+ 'Video flags:\n' +
277
+ ' --model <model> Model (default: doubao-seedance-1-5-pro-251215)\n' +
278
+ ' --image <url> Reference image URL (for image-to-video)\n' +
279
+ ' --duration <sec> Video duration 1-120 seconds\n' +
280
+ ' --ratio <ratio> Aspect ratio: 16:9, 9:16, 1:1 etc.\n' +
281
+ ' --negative <text> Negative prompt\n\n' +
282
+ 'Options:\n -h, --help\n\n' +
283
+ 'Available image models:\n' +
284
+ ' doubao-seedream-5-0-260128 (default, latest)\n' +
285
+ ' doubao-seedream-4-5-251128\n' +
286
+ ' doubao-seedream-4-0-250828\n' +
287
+ ' doubao-seedream-3-0-t2i-250415\n\n' +
288
+ 'Available video models:\n' +
289
+ ' doubao-seedance-1-5-pro-251215 (default, best quality)\n' +
290
+ ' doubao-seedance-1-0-pro-250528\n' +
291
+ ' doubao-seedance-1-0-lite-t2v-250428\n' +
292
+ ' doubao-seedance-1-0-lite-i2v-250428\n\n' +
293
+ 'Examples:\n' +
294
+ ' touka aigc image "a cat on a laptop"\n' +
295
+ ' touka aigc image "sunset" --size 768x1024 --model doubao-seedream-4-5-251128 -n 2\n' +
296
+ ' touka aigc video "city night timelapse" --duration 10\n' +
297
+ ' touka aigc video "dancing character" --image https://example.com/ref.jpg\n' +
298
+ ' touka aigc status task_abc123', 0, t);
299
+
300
+ const sa = args.slice(1);
301
+ if (sub === 'image') {
302
+ const prompt = positional(sa).join(' ');
303
+ if (!prompt) out('[error] Missing <prompt>\n Usage: touka aigc image <prompt> [--model m] [--size WxH] [-n count]\n Example: touka aigc image "a futuristic city"', 1, t);
304
+ const model = flag(sa, null, '--model') || 'doubao-seedream-5-0-260128';
305
+ const size = flag(sa, null, '--size') || null;
306
+ const n = parseInt(flag(sa, '-n', null) || '1');
307
+ const neg = flag(sa, null, '--negative') || null;
308
+ console.log(`Generating ${n} image(s) with ${model}${size ? ` (${size})` : ''}...`);
309
+ const body = { model, prompt, n };
310
+ if (size) body.size = size;
311
+ if (neg) body.negative_prompt = neg;
312
+ const d = await api('POST', '/aigc/images/generations', { body });
313
+ // Response could be async task or direct result
314
+ if (d.task_id) {
315
+ out(`Task submitted: ${d.task_id}\nModel: ${model}\nCheck: touka aigc status ${d.task_id}`, 0, t);
316
+ } else {
317
+ const urls = (d?.data||[]).map((x,i) => ` [${i+1}] ${x.url || '(processing)'}`);
318
+ out(`Image(s):\n${urls.join('\n')||' (check task status)'}`, 0, t);
319
+ }
320
+ }
321
+
322
+ if (sub === 'video') {
323
+ const prompt = positional(sa).join(' ');
324
+ if (!prompt) out('[error] Missing <prompt>\n Usage: touka aigc video <prompt> [--model m] [--image url] [--duration s]\n Example: touka aigc video "ocean waves"', 1, t);
325
+ const model = flag(sa, null, '--model') || 'doubao-seedance-1-5-pro-251215';
326
+ const imageUrl = flag(sa, null, '--image') || null;
327
+ const duration = flag(sa, null, '--duration') || null;
328
+ const ratio = flag(sa, null, '--ratio') || null;
329
+ const neg = flag(sa, null, '--negative') || null;
330
+ console.log(`Submitting video (${model})${imageUrl ? ' with reference image' : ''}...`);
331
+ const body = { model, prompt };
332
+ if (imageUrl) body.images = [{ url: imageUrl }];
333
+ if (duration) body.duration_seconds = parseInt(duration);
334
+ if (ratio) body.aspect_ratio = ratio;
335
+ if (neg) body.negative_prompt = neg;
336
+ const d = await api('POST', '/aigc/videos/generations', { body });
337
+ const tid = d?.task_id || 'unknown';
338
+ out(`Task: ${tid}\nModel: ${model}\nCheck: touka aigc status ${tid}`, 0, t);
339
+ }
340
+
341
+ if (sub === 'status') {
342
+ const tid = sa[0]; if (!tid) out('[error] Missing <task_id>\n Usage: touka aigc status <task_id>', 1, t);
343
+ const d = await api('GET', `/aigc/tasks/${tid}`);
344
+ const lines = [`Task: ${d.task_id||tid}`, `Status: ${d.status||'unknown'}`, `Progress: ${d.progress_percent||0}%`];
345
+ if (d.prompt) lines.push(`Prompt: ${d.prompt}`);
346
+ if (d.status_message) lines.push(`Message: ${d.status_message}`);
347
+ if (d.preview_url) lines.push(`Preview: ${d.preview_url}`);
348
+ if (d.output_assets?.length) d.output_assets.forEach((a,i) => lines.push(`Output[${i}]: ${a.url||'(pending)'}`));
349
+ if (d.error_message) lines.push(`Error: ${d.error_message}`);
350
+ out(lines.join('\n'), 0, t);
351
+ }
352
+ }
353
+
354
+ // ════════════════════════════════════════════
355
+ // LLM (chat, models)
356
+ // ════════════════════════════════════════════
357
+ async function cmdLlm(args) {
358
+ const t = Date.now(), sub = args[0];
359
+ if (!sub || hasHelp([sub])) out(
360
+ 'Usage: touka llm <command> [args] [flags]\n\nChat with large language models.\n\n' +
361
+ 'Commands:\n chat <message> Send a message\n models List available models\n\n' +
362
+ 'Chat flags:\n' +
363
+ ' --model <model> Model (default: doubao-1-5-pro-32k-250115)\n' +
364
+ ' --system <prompt> System prompt\n' +
365
+ ' --max-tokens <n> Max tokens (default: 2048)\n' +
366
+ ' --temperature <t> 0-2 (default: 0.7)\n\n' +
367
+ 'Options:\n -h, --help\n\n' +
368
+ 'Available models:\n' +
369
+ ' doubao-1-5-pro-32k-250115 (default)\n' +
370
+ ' doubao-1-5-lite-32k-250115\n' +
371
+ ' deepseek-v3-250324\n' +
372
+ ' deepseek-r1-250528\n' +
373
+ ' kimi-k2-250905\n' +
374
+ ' glm-4-5-air\n' +
375
+ ' qwen3-235b-a22b\n\n' +
376
+ 'Examples:\n' +
377
+ ' touka llm chat "What is machine learning?"\n' +
378
+ ' touka llm chat "翻译成英文:你好" --model deepseek-v3-250324\n' +
379
+ ' touka llm chat "Write a poem" --system "You are a poet" --temperature 1.2', 0, t);
380
+
381
+ const sa = args.slice(1);
382
+ if (sub === 'chat') {
383
+ const msg = positional(sa).join(' ');
384
+ if (!msg) out('[error] Missing <message>\n Usage: touka llm chat <message> [--model m] [--system s]', 1, t);
385
+ const model = flag(sa, null, '--model') || 'doubao-1-5-pro-32k-250115';
386
+ const sys = flag(sa, null, '--system') || '';
387
+ const maxT = parseInt(flag(sa, null, '--max-tokens') || '2048');
388
+ const temp = parseFloat(flag(sa, null, '--temperature') || '0.7');
389
+ const messages = []; if (sys) messages.push({ role: 'system', content: sys });
390
+ messages.push({ role: 'user', content: msg });
391
+ console.log(`[${model}] Thinking...`);
392
+ const d = await api('POST', '/llm/chat/completions', { body: { model, messages, max_tokens: maxT, temperature: temp } });
393
+ const reply = d?.choices?.[0]?.message?.content || '(no response)';
394
+ const u = d?.usage;
395
+ let r = reply;
396
+ if (u) r += `\n\n── ${u.prompt_tokens||0} in + ${u.completion_tokens||0} out = ${u.total_tokens||0} tokens ──`;
397
+ out(r, 0, t);
398
+ }
399
+ if (sub === 'models') {
400
+ out('Available LLM models:\n' +
401
+ ' doubao-1-5-pro-32k-250115 (recommended)\n' +
402
+ ' doubao-1-5-lite-32k-250115 (fast)\n' +
403
+ ' deepseek-v3-250324 (strong reasoning)\n' +
404
+ ' deepseek-r1-250528 (deepseek latest)\n' +
405
+ ' kimi-k2-250905 (kimi)\n' +
406
+ ' glm-4-5-air (zhipu)\n' +
407
+ ' qwen3-235b-a22b (alibaba)', 0, t);
408
+ }
409
+ }
410
+
411
+ // ════════════════════════════════════════════
412
+ // ASR (speech-to-text)
413
+ // ════════════════════════════════════════════
414
+ async function cmdAsr(args) {
415
+ const t = Date.now(), sub = args[0];
416
+ if (!sub || hasHelp([sub])) out(
417
+ 'Usage: touka asr <command> [args] [flags]\n\nSpeech-to-text — transcribe audio.\n\n' +
418
+ 'Commands:\n transcribe <url> Transcribe audio from URL\n query <task_id> Query transcription result\n\n' +
419
+ 'Transcribe flags:\n' +
420
+ ' --model <model> ASR model (default: byteplus-asr)\n' +
421
+ ' --language <lang> zh-CN (default), en-US\n' +
422
+ ' --format <fmt> mp3, wav, ogg, mp4\n' +
423
+ ' --speaker Enable speaker detection\n\n' +
424
+ 'Options:\n -h, --help\n\n' +
425
+ 'Note: Audio must be a publicly accessible URL.\n' +
426
+ ' For local files, upload to S3/OSS first.\n\n' +
427
+ 'Examples:\n' +
428
+ ' touka asr transcribe https://example.com/audio.mp3\n' +
429
+ ' touka asr transcribe https://example.com/en.wav --language en-US\n' +
430
+ ' touka asr query task_abc123', 0, t);
431
+
432
+ const sa = args.slice(1);
433
+ if (sub === 'transcribe') {
434
+ const url = sa[0]; if (!url || url.startsWith('-')) out('[error] Missing <url>\n Usage: touka asr transcribe <audio_url> [--language lang]\n Note: Audio must be a publicly accessible URL.', 1, t);
435
+ const model = flag(sa, null, '--model') || 'byteplus-asr';
436
+ const lang = flag(sa, null, '--language') || 'zh-CN';
437
+ const fmt = flag(sa, null, '--format') || null;
438
+ const speaker = sa.includes('--speaker');
439
+ console.log(`Transcribing (${model}, ${lang})...`);
440
+ const body = { model, audio_url: url, language: lang, enable_punc: true, enable_itn: true };
441
+ if (fmt) body.audio_format = fmt;
442
+ if (speaker) body.enable_speaker_info = true;
443
+ const d = await api('POST', '/asr/recognize', { body });
444
+ if (d.task_id) out(`Task submitted: ${d.task_id}\nQuery: touka asr query ${d.task_id}`, 0, t);
445
+ else out(`Result:\n${d.text || JSON.stringify(d, null, 2)}`, 0, t);
446
+ }
447
+ if (sub === 'query') {
448
+ const tid = sa[0]; if (!tid) out('[error] Missing <task_id>\n Usage: touka asr query <task_id>', 1, t);
449
+ const d = await api('POST', '/asr/query', { body: { task_id: tid } });
450
+ const lines = [`Task: ${tid}`, `Status: ${d.status||'unknown'}`];
451
+ if (d.text) lines.push(`\nTranscription:\n${d.text}`);
452
+ if (d.segments?.length) lines.push(`\nSegments: ${d.segments.length}`);
453
+ out(lines.join('\n'), 0, t);
454
+ }
455
+ }
456
+
457
+ // ════════════════════════════════════════════
458
+ // TTS (text-to-speech)
459
+ // ════════════════════════════════════════════
460
+ async function cmdTts(args) {
461
+ const t = Date.now(), sub = args[0];
462
+ if (!sub || hasHelp([sub])) out(
463
+ 'Usage: touka tts <command> [args] [flags]\n\nText-to-speech — synthesize audio from text.\n\n' +
464
+ 'Commands:\n speak <text> Synthesize speech\n voices List available voices\n query <task_id> Query synthesis result\n download <task_id> Download audio file\n\n' +
465
+ 'Speak flags:\n' +
466
+ ' --model <model> TTS model (default: byteplus-tts)\n' +
467
+ ' --voice <voice> Voice ID (required)\n' +
468
+ ' --format <fmt> mp3 (default), wav, pcm\n' +
469
+ ' --sample-rate <n> 8000, 16000, 24000 (default)\n\n' +
470
+ 'Options:\n -h, --help\n\n' +
471
+ 'Examples:\n' +
472
+ ' touka tts voices\n' +
473
+ ' touka tts speak "你好世界" --voice zh_female_shuangkuaisisi_moon_bigtts\n' +
474
+ ' touka tts speak "Hello world" --voice en_male_adam_bigtts --format wav\n' +
475
+ ' touka tts query task_abc123\n' +
476
+ ' touka tts download task_abc123', 0, t);
477
+
478
+ const sa = args.slice(1);
479
+ if (sub === 'speak') {
480
+ const text = positional(sa).join(' ');
481
+ if (!text) out('[error] Missing <text>\n Usage: touka tts speak <text> --voice <voice_id>\n List voices: touka tts voices', 1, t);
482
+ const voice = flag(sa, null, '--voice');
483
+ if (!voice) out('[error] Missing --voice\n Usage: touka tts speak <text> --voice <voice_id>\n List voices: touka tts voices', 1, t);
484
+ const model = flag(sa, null, '--model') || 'byteplus-tts';
485
+ const fmt = flag(sa, null, '--format') || 'mp3';
486
+ const sr = parseInt(flag(sa, null, '--sample-rate') || '24000');
487
+ console.log(`Synthesizing (${model}, voice=${voice})...`);
488
+ const d = await api('POST', '/tts/synthesize', { body: { model, text, voice, audio_format: fmt, sample_rate: sr } });
489
+ if (d.task_id) out(`Task: ${d.task_id}\nStatus: ${d.status||'processing'}\nQuery: touka tts query ${d.task_id}\nDownload: touka tts download ${d.task_id}`, 0, t);
490
+ else out(`Result: ${JSON.stringify(d)}`, 0, t);
491
+ }
492
+ if (sub === 'voices') {
493
+ console.log('Fetching voices...');
494
+ try {
495
+ const d = await api('GET', '/tts/voices');
496
+ const voices = d?.voices || d?.data || d || [];
497
+ if (Array.isArray(voices) && voices.length) {
498
+ const t2 = tbl(voices.slice(0, 30), [
499
+ {l:'VOICE_ID',f:r=>r.voice_id||r.id||''}, {l:'NAME',f:r=>r.name||r.voice_name||''},
500
+ {l:'LANG',f:r=>r.language||''}, {l:'GENDER',f:r=>r.gender||''},
501
+ ]);
502
+ out(t2, 0, t);
503
+ } else out(`Voices: ${JSON.stringify(voices).slice(0,500)}`, 0, t);
504
+ } catch (e) { out(`Failed to list voices: ${e.message}\n Common voices: zh_female_shuangkuaisisi_moon_bigtts`, 0, t); }
505
+ }
506
+ if (sub === 'query') {
507
+ const tid = sa[0]; if (!tid) out('[error] Missing <task_id>', 1, t);
508
+ const model = flag(sa, null, '--model') || 'byteplus-tts';
509
+ const d = await api('POST', '/tts/query', { body: { task_id: tid, model } });
510
+ out(`Task: ${tid}\nStatus: ${d.status||'unknown'}\n${d.status==='completed'?`Download: touka tts download ${tid}`:''}`, 0, t);
511
+ }
512
+ if (sub === 'download') {
513
+ const tid = sa[0]; if (!tid) out('[error] Missing <task_id>', 1, t);
514
+ const model = flag(sa, null, '--model') || 'byteplus-tts';
515
+ console.log(`Downloading audio for ${tid}...`);
516
+ const resp = await api('GET', `/tts/result/${tid}?model=${encodeURIComponent(model)}`, { stream: true });
517
+ if (!resp.ok) { const e = await resp.json().catch(()=>({})); throw new Error(e?.message||`HTTP ${resp.status}`); }
518
+ const buf = Buffer.from(await resp.arrayBuffer());
519
+ const outFile = `${tid}.mp3`;
520
+ writeFileSync(outFile, buf);
521
+ out(`Saved: ${outFile} (${(buf.length/1024).toFixed(1)}KB)`, 0, t);
522
+ }
523
+ }
524
+
525
+ // ════════════════════════════════════════════
526
+ // Main
527
+ // ════════════════════════════════════════════
528
+ async function main() {
529
+ const t = Date.now(), args = process.argv.slice(2), cmd = args[0];
530
+ if (!cmd || cmd==='-h' || cmd==='--help') out(
531
+ `Touka CLI v${VERSION} — AI Gateway Client\n\n` +
532
+ 'Usage: touka <command> [args] [flags]\n\n' +
533
+ 'Commands:\n' +
534
+ ' skill Search, install, publish AI agent skills\n' +
535
+ ' llm Chat with language models (doubao, deepseek, kimi, qwen...)\n' +
536
+ ' aigc Generate images (seedream) and videos (seedance)\n' +
537
+ ' asr Speech-to-text (transcribe audio)\n' +
538
+ ' tts Text-to-speech (synthesize audio)\n' +
539
+ ' config Configure endpoint and credentials\n' +
540
+ ' update Update touka to the latest version\n' +
541
+ ' version Show version\n\n' +
542
+ 'Options:\n -h, --help Show help (works on any command)\n\n' +
543
+ 'Get started:\n touka config set --endpoint https://your-gateway.com --api-key YOUR_KEY\n\n' +
544
+ 'Examples:\n' +
545
+ ' touka skill search "code review"\n' +
546
+ ' touka llm chat "Hello!"\n' +
547
+ ' touka aigc image "a cat" --size 512x512\n' +
548
+ ' touka aigc video "ocean waves" --image https://example.com/ref.jpg\n' +
549
+ ' touka asr transcribe https://example.com/audio.wav\n' +
550
+ ' touka tts speak "你好" --voice zh_female_shuangkuaisisi_moon_bigtts\n\n' +
551
+ `Node.js >= 18 required. Current: ${process.version}`, 0, t);
552
+
553
+ if (cmd==='version'||cmd==='--version'||cmd==='-v') out(`touka v${VERSION}`, 0, t);
554
+
555
+ if (cmd==='update'||cmd==='self-update') {
556
+ console.log('Checking for updates...');
557
+ try {
558
+ const resp = await fetch(`https://registry.npmjs.org/${PKG_NAME}/latest`, { signal: AbortSignal.timeout(10000) });
559
+ if (!resp.ok) throw new Error(`npm registry: HTTP ${resp.status}`);
560
+ const d = await resp.json();
561
+ if (d.version === VERSION) out(`Already on latest: v${VERSION}`, 0, t);
562
+ console.log(`Updating: v${VERSION} → v${d.version}...`);
563
+ const { execSync } = await import('child_process');
564
+ execSync(`npm install -g ${PKG_NAME}@latest`, { stdio: 'inherit' });
565
+ // Clear update check cache
566
+ try { unlinkSync(UPDATE_CHECK_FILE); } catch {}
567
+ out(`Updated to v${d.version}`, 0, t);
568
+ } catch (e) { fail(`Update failed: ${e.message}\n Manual: npm install -g ${PKG_NAME}@latest`, t); }
569
+ }
570
+
571
+ // Background update check (non-blocking)
572
+ checkForUpdate();
573
+
574
+ try {
575
+ if (cmd==='config') await cmdConfig(args.slice(1));
576
+ else if (cmd==='skill') await cmdSkill(args.slice(1));
577
+ else if (cmd==='aigc') await cmdAigc(args.slice(1));
578
+ else if (cmd==='llm') await cmdLlm(args.slice(1));
579
+ else if (cmd==='asr') await cmdAsr(args.slice(1));
580
+ else if (cmd==='tts') await cmdTts(args.slice(1));
581
+ else fail(`Unknown command: ${cmd}\n Available: skill, llm, aigc, asr, tts, config, update, version\n Run: touka -h`, t);
582
+ } catch (e) { fail(e, t); }
583
+ }
584
+
585
+ main();