@aisa-one/cli 0.2.2 → 0.2.4
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/CHANGELOG.md +154 -1
- package/README.md +250 -103
- package/dist/api.d.ts +46 -1
- package/dist/api.js +77 -8
- package/dist/cache.d.ts +18 -0
- package/dist/cache.js +65 -0
- package/dist/catalog.d.ts +90 -0
- package/dist/catalog.js +101 -0
- package/dist/commands/account.js +4 -2
- package/dist/commands/api.d.ts +18 -4
- package/dist/commands/api.js +357 -24
- package/dist/commands/auth.js +1 -1
- package/dist/commands/cacheCmd.d.ts +2 -0
- package/dist/commands/cacheCmd.js +10 -0
- package/dist/commands/completionCmd.d.ts +7 -0
- package/dist/commands/completionCmd.js +37 -0
- package/dist/commands/configCmd.js +19 -1
- package/dist/commands/finance.d.ts +4 -0
- package/dist/commands/finance.js +212 -29
- package/dist/commands/models.js +6 -0
- package/dist/commands/run.d.ts +21 -0
- package/dist/commands/run.js +113 -6
- package/dist/commands/search.js +8 -3
- package/dist/commands/skills.d.ts +6 -2
- package/dist/commands/skills.js +199 -144
- package/dist/commands/twitter.js +2 -3
- package/dist/commands/video.d.ts +40 -0
- package/dist/commands/video.js +272 -55
- package/dist/completion.d.ts +26 -0
- package/dist/completion.js +293 -0
- package/dist/constants.d.ts +55 -5
- package/dist/constants.js +136 -11
- package/dist/index.js +88 -26
- package/dist/skills-registry.d.ts +64 -0
- package/dist/skills-registry.js +219 -0
- package/dist/types.d.ts +24 -0
- package/dist/utils/file.d.ts +5 -1
- package/dist/utils/file.js +5 -1
- package/dist/utils/pool.d.ts +8 -0
- package/dist/utils/pool.js +27 -0
- package/package.json +10 -4
- package/tests/account.test.ts +0 -89
- package/tests/streaming.test.ts +0 -73
package/dist/commands/video.js
CHANGED
|
@@ -1,94 +1,311 @@
|
|
|
1
|
+
import { writeFile } from "node:fs/promises";
|
|
1
2
|
import ora from "ora";
|
|
2
3
|
import chalk from "chalk";
|
|
3
4
|
import { requireApiKey } from "../config.js";
|
|
4
5
|
import { apiRequest } from "../api.js";
|
|
5
|
-
import { error, formatJson, success } from "../utils/display.js";
|
|
6
|
+
import { error, formatJson, hint, success } from "../utils/display.js";
|
|
7
|
+
import { DEFAULT_VIDEO_MODEL, MEDIA_TYPES, VIDEO_MODELS, videoModelSpec, } from "../constants.js";
|
|
8
|
+
/** Statuses after which the task will never change again. */
|
|
9
|
+
const TERMINAL_STATUSES = new Set(["completed", "failed", "cancelled"]);
|
|
10
|
+
const POLL_INTERVAL_MS = 5000;
|
|
11
|
+
const MAX_WAIT_MS = 15 * 60 * 1000;
|
|
12
|
+
const VIDEO_KEY = /video.*url|url.*video/i;
|
|
13
|
+
const URL_KEY = /url$/i;
|
|
14
|
+
/** Keys that carry a still image rather than the clip — thumbnails end in "url" too. */
|
|
15
|
+
const NOT_VIDEO_KEY = /thumb|cover|preview|poster|image|img|icon|logo|snapshot|avatar|first_frame|last_frame/i;
|
|
16
|
+
function searchUrl(value, keyMatches, depth = 0) {
|
|
17
|
+
if (depth > 8 || value == null)
|
|
18
|
+
return undefined;
|
|
19
|
+
if (Array.isArray(value)) {
|
|
20
|
+
for (const item of value) {
|
|
21
|
+
const found = searchUrl(item, keyMatches, depth + 1);
|
|
22
|
+
if (found)
|
|
23
|
+
return found;
|
|
24
|
+
}
|
|
25
|
+
return undefined;
|
|
26
|
+
}
|
|
27
|
+
if (typeof value !== "object")
|
|
28
|
+
return undefined;
|
|
29
|
+
const entries = Object.entries(value);
|
|
30
|
+
for (const [key, val] of entries) {
|
|
31
|
+
if (typeof val === "string" && keyMatches(key) && /^https?:\/\//.test(val))
|
|
32
|
+
return val;
|
|
33
|
+
}
|
|
34
|
+
for (const [, val] of entries) {
|
|
35
|
+
const found = searchUrl(val, keyMatches, depth + 1);
|
|
36
|
+
if (found)
|
|
37
|
+
return found;
|
|
38
|
+
}
|
|
39
|
+
return undefined;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Each upstream nests the finished video somewhere different (DashScope puts it
|
|
43
|
+
* at result.output.video_url, Seedance under result.data[].content[].url), so
|
|
44
|
+
* search rather than hardcode a path.
|
|
45
|
+
*
|
|
46
|
+
* Every pass covers the whole tree before the next one starts, and each is
|
|
47
|
+
* narrower than a plain "ends in url" match would be. Responses ship thumbnails
|
|
48
|
+
* and cover images beside the clip, and `thumbnail_url` both ends in "url" and
|
|
49
|
+
* tends to sit closer to the root than the video does — matching it would make
|
|
50
|
+
* `--output` download a still image.
|
|
51
|
+
*/
|
|
52
|
+
export function findVideoUrl(value) {
|
|
53
|
+
// The still-image exclusion applies to every pass: `video_thumbnail_url`
|
|
54
|
+
// matches the video-named pattern too, and saving it via --output would hand
|
|
55
|
+
// the user a JPEG with an .mp4 name.
|
|
56
|
+
return (
|
|
57
|
+
// 1. Explicitly video-named: output.video_url
|
|
58
|
+
searchUrl(value, (k) => VIDEO_KEY.test(k) && !NOT_VIDEO_KEY.test(k)) ??
|
|
59
|
+
// 2. A bare `url`, which is what a media-item object uses: content[].url
|
|
60
|
+
searchUrl(value, (k) => k.toLowerCase() === "url") ??
|
|
61
|
+
// 3. Any other *_url, minus the ones that name a still image
|
|
62
|
+
searchUrl(value, (k) => URL_KEY.test(k) && !NOT_VIDEO_KEY.test(k)));
|
|
63
|
+
}
|
|
64
|
+
function printTask(task, fallbackId) {
|
|
65
|
+
const url = task.result ? findVideoUrl(task.result) : undefined;
|
|
66
|
+
console.log(` Task: ${task.id || fallbackId}`);
|
|
67
|
+
console.log(` Status: ${task.status || "unknown"}`);
|
|
68
|
+
if (task.model)
|
|
69
|
+
console.log(` Model: ${task.model}`);
|
|
70
|
+
if (task.progress && !TERMINAL_STATUSES.has(task.status || "")) {
|
|
71
|
+
console.log(` Progress: ${task.progress}`);
|
|
72
|
+
}
|
|
73
|
+
if (url)
|
|
74
|
+
console.log(` URL: ${chalk.cyan(url)}`);
|
|
75
|
+
if (task.error?.message)
|
|
76
|
+
console.log(` Error: ${chalk.red(task.error.message)}`);
|
|
77
|
+
}
|
|
78
|
+
async function downloadVideo(url, outputPath) {
|
|
79
|
+
const res = await fetch(url);
|
|
80
|
+
if (!res.ok)
|
|
81
|
+
throw new Error(`Download failed: ${res.status}`);
|
|
82
|
+
const buffer = Buffer.from(await res.arrayBuffer());
|
|
83
|
+
await writeFile(outputPath, buffer);
|
|
84
|
+
}
|
|
85
|
+
/** Parse `--media first_frame=https://…`, and bare URLs from `--image`. */
|
|
86
|
+
export function parseMedia(specs = [], images = []) {
|
|
87
|
+
const media = images.map((url) => ({ type: "first_frame", url }));
|
|
88
|
+
for (const spec of specs) {
|
|
89
|
+
const eq = spec.indexOf("=");
|
|
90
|
+
if (eq < 0) {
|
|
91
|
+
throw new Error(`Invalid --media "${spec}". Use type=url, e.g. first_frame=https://…`);
|
|
92
|
+
}
|
|
93
|
+
const type = spec.slice(0, eq);
|
|
94
|
+
const url = spec.slice(eq + 1);
|
|
95
|
+
if (!MEDIA_TYPES.includes(type)) {
|
|
96
|
+
throw new Error(`Unknown media type "${type}". Valid: ${MEDIA_TYPES.join(", ")}`);
|
|
97
|
+
}
|
|
98
|
+
if (!url)
|
|
99
|
+
throw new Error(`Missing URL in --media "${spec}"`);
|
|
100
|
+
media.push({ type, url });
|
|
101
|
+
}
|
|
102
|
+
return media;
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Build the request body for a model's vendor. The gateway rewrites only the
|
|
106
|
+
* top-level `model` and forwards the rest verbatim, so a Bailian body sent to a
|
|
107
|
+
* Seedance model is rejected with a 400.
|
|
108
|
+
*/
|
|
109
|
+
export function buildVideoBody(model, prompt, media, options) {
|
|
110
|
+
const spec = videoModelSpec(model);
|
|
111
|
+
if (spec.family === "seedance") {
|
|
112
|
+
// BytePlus takes a multimodal content array rather than input/parameters.
|
|
113
|
+
return {
|
|
114
|
+
model,
|
|
115
|
+
content: [
|
|
116
|
+
{ type: "text", text: prompt },
|
|
117
|
+
...media.map((m) => ({ type: "image_url", image_url: { url: m.url } })),
|
|
118
|
+
],
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
return {
|
|
122
|
+
model,
|
|
123
|
+
input: { prompt, ...(media.length > 0 ? { media } : {}) },
|
|
124
|
+
parameters: {
|
|
125
|
+
resolution: options.resolution || "720P",
|
|
126
|
+
duration: options.duration ? parseInt(options.duration) : 5,
|
|
127
|
+
},
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Parse a `--body` payload. The option means "send this verbatim", so the model
|
|
132
|
+
* it names wins over the CLI's default — only an explicit `--model` overrides
|
|
133
|
+
* it. Anything else would silently redirect a valid request to another model.
|
|
134
|
+
*/
|
|
135
|
+
export function parseBodyOverride(raw, explicitModel) {
|
|
136
|
+
let parsed;
|
|
137
|
+
try {
|
|
138
|
+
parsed = JSON.parse(raw);
|
|
139
|
+
}
|
|
140
|
+
catch {
|
|
141
|
+
throw new Error("--body must be valid JSON");
|
|
142
|
+
}
|
|
143
|
+
// JSON.parse accepts null, arrays and numbers; none of them are request bodies.
|
|
144
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
145
|
+
throw new Error("--body must be a JSON object");
|
|
146
|
+
}
|
|
147
|
+
const body = { ...parsed };
|
|
148
|
+
if (explicitModel)
|
|
149
|
+
body.model = explicitModel;
|
|
150
|
+
else if (!body.model)
|
|
151
|
+
body.model = DEFAULT_VIDEO_MODEL;
|
|
152
|
+
return body;
|
|
153
|
+
}
|
|
6
154
|
export async function videoCreateAction(prompt, options) {
|
|
7
155
|
const key = requireApiKey();
|
|
156
|
+
let body;
|
|
157
|
+
if (options.body) {
|
|
158
|
+
// Escape hatch for shapes the CLI does not model; the gateway is a pass-through.
|
|
159
|
+
try {
|
|
160
|
+
body = parseBodyOverride(options.body, options.model);
|
|
161
|
+
}
|
|
162
|
+
catch (err) {
|
|
163
|
+
error(err.message);
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
else {
|
|
168
|
+
const model = options.model || DEFAULT_VIDEO_MODEL;
|
|
169
|
+
const spec = videoModelSpec(model);
|
|
170
|
+
let media;
|
|
171
|
+
try {
|
|
172
|
+
media = parseMedia(options.media, options.image);
|
|
173
|
+
}
|
|
174
|
+
catch (err) {
|
|
175
|
+
error(err.message);
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
// Image- and clip-driven models are accepted at submission time and only
|
|
179
|
+
// fail a minute later, after billing. Refuse before spending anything —
|
|
180
|
+
// and check for the type the model actually wants, since supplying a
|
|
181
|
+
// first_clip to an i2v model fails exactly the same way as supplying none.
|
|
182
|
+
if (spec.requiresMedia && !media.some((m) => m.type === spec.requiresMedia)) {
|
|
183
|
+
const shorthand = spec.requiresMedia === "first_frame" ? " (or --image <url>)" : "";
|
|
184
|
+
error(media.length === 0
|
|
185
|
+
? `${model} needs source media.`
|
|
186
|
+
: `${model} needs ${spec.requiresMedia} media, got ${media.map((m) => m.type).join(", ")}.`);
|
|
187
|
+
hint(`Pass --media ${spec.requiresMedia}=<url>${shorthand}`);
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
if (spec.family === "seedance" && media.length > 0) {
|
|
191
|
+
hint("Seedance media handling is unverified — use --body if the request is rejected");
|
|
192
|
+
}
|
|
193
|
+
body = buildVideoBody(model, prompt, media, options);
|
|
194
|
+
}
|
|
8
195
|
const spinner = ora("Creating video generation task...").start();
|
|
9
|
-
const
|
|
10
|
-
model: options.model || "wan2.6-t2v",
|
|
11
|
-
input: { prompt },
|
|
12
|
-
parameters: { resolution: "720P", duration: 5 },
|
|
13
|
-
};
|
|
14
|
-
const res = await apiRequest(key, "services/aigc/video-generation/video-synthesis", {
|
|
196
|
+
const res = await apiRequest(key, "video/generations", {
|
|
15
197
|
method: "POST",
|
|
16
198
|
body,
|
|
17
|
-
headers: { "X-DashScope-Async": "enable" },
|
|
18
|
-
domain: true,
|
|
19
199
|
});
|
|
20
200
|
if (!res.success || !res.data) {
|
|
21
201
|
spinner.fail("Failed to create video task");
|
|
22
202
|
error(res.error || "Unknown error");
|
|
203
|
+
if ((res.error || "").includes("model not found")) {
|
|
204
|
+
hint(`Available video models: ${Object.keys(VIDEO_MODELS).join(", ")}`);
|
|
205
|
+
}
|
|
23
206
|
return;
|
|
24
207
|
}
|
|
25
208
|
spinner.stop();
|
|
26
|
-
const taskId = res.data.
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
209
|
+
const taskId = res.data.id;
|
|
210
|
+
// --raw changes the output format; it must not cancel work the user asked
|
|
211
|
+
// for. Only skip polling when nothing was requested beyond submission.
|
|
212
|
+
if (!options.wait || !taskId) {
|
|
213
|
+
if (options.raw) {
|
|
214
|
+
console.log(JSON.stringify(res.data));
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
if (!taskId) {
|
|
218
|
+
console.log(formatJson(res.data));
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
success(`Task created: ${taskId}`);
|
|
222
|
+
hint(`Check status: aisa video status ${taskId}`);
|
|
34
223
|
return;
|
|
35
224
|
}
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
225
|
+
if (!options.raw)
|
|
226
|
+
success(`Task created: ${taskId}`);
|
|
227
|
+
const pollSpinner = options.raw ? undefined : ora("Generating video...").start();
|
|
228
|
+
const deadline = Date.now() + MAX_WAIT_MS;
|
|
229
|
+
let task = res.data;
|
|
230
|
+
while (!TERMINAL_STATUSES.has(task.status || "")) {
|
|
231
|
+
if (Date.now() > deadline) {
|
|
232
|
+
pollSpinner?.warn(`Still ${task.status || "pending"} after 15 minutes — giving up on waiting`);
|
|
233
|
+
if (options.raw)
|
|
234
|
+
console.log(JSON.stringify(task));
|
|
235
|
+
else
|
|
236
|
+
hint(`Check later: aisa video status ${taskId}`);
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS));
|
|
240
|
+
const pollRes = await apiRequest(key, `video/generations/${taskId}`);
|
|
46
241
|
if (!pollRes.success || !pollRes.data) {
|
|
47
|
-
pollSpinner
|
|
242
|
+
pollSpinner?.fail("Failed to check status");
|
|
48
243
|
error(pollRes.error || "Unknown error");
|
|
49
244
|
return;
|
|
50
245
|
}
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
}
|
|
55
|
-
if (status === "SUCCEEDED" || status === "completed") {
|
|
56
|
-
pollSpinner.succeed("Video generated!");
|
|
57
|
-
const videoUrl = lastPollData?.output?.video_url || lastPollData?.resultUrl;
|
|
58
|
-
if (videoUrl) {
|
|
59
|
-
console.log(` URL: ${chalk.cyan(videoUrl)}`);
|
|
246
|
+
task = pollRes.data;
|
|
247
|
+
if (pollSpinner) {
|
|
248
|
+
pollSpinner.text = `Generating video... (${task.status}${task.progress ? ` ${task.progress}` : ""})`;
|
|
60
249
|
}
|
|
61
250
|
}
|
|
62
|
-
|
|
63
|
-
|
|
251
|
+
const videoUrl = findVideoUrl(task.result);
|
|
252
|
+
if (task.status !== "completed") {
|
|
253
|
+
pollSpinner?.fail(`Video generation ${task.status}`);
|
|
254
|
+
if (options.raw)
|
|
255
|
+
console.log(JSON.stringify(task));
|
|
256
|
+
else if (task.error?.message)
|
|
257
|
+
error(task.error.message);
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
pollSpinner?.succeed("Video generated!");
|
|
261
|
+
if (options.output) {
|
|
262
|
+
await download(videoUrl, options.output, options.raw);
|
|
263
|
+
}
|
|
264
|
+
if (options.raw) {
|
|
265
|
+
console.log(JSON.stringify(task));
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
if (videoUrl)
|
|
269
|
+
console.log(` URL: ${chalk.cyan(videoUrl)}`);
|
|
270
|
+
else
|
|
271
|
+
console.log(formatJson(task.result));
|
|
272
|
+
}
|
|
273
|
+
/** Download to `output`, reporting through a spinner unless output is raw JSON. */
|
|
274
|
+
async function download(videoUrl, output, raw) {
|
|
275
|
+
if (!videoUrl) {
|
|
276
|
+
error("No video URL available to download");
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
const spinner = raw ? undefined : ora(`Downloading to ${output}...`).start();
|
|
280
|
+
try {
|
|
281
|
+
await downloadVideo(videoUrl, output);
|
|
282
|
+
spinner?.succeed(`Saved to ${output}`);
|
|
283
|
+
}
|
|
284
|
+
catch (err) {
|
|
285
|
+
const message = `Download failed: ${err.message}`;
|
|
286
|
+
if (spinner)
|
|
287
|
+
spinner.fail(message);
|
|
288
|
+
else
|
|
289
|
+
error(message);
|
|
64
290
|
}
|
|
65
291
|
}
|
|
66
292
|
export async function videoStatusAction(taskId, options) {
|
|
67
293
|
const key = requireApiKey();
|
|
68
294
|
const spinner = ora("Checking status...").start();
|
|
69
|
-
const res = await apiRequest(key,
|
|
70
|
-
query: { task_id: taskId },
|
|
71
|
-
domain: true,
|
|
72
|
-
});
|
|
295
|
+
const res = await apiRequest(key, `video/generations/${taskId}`);
|
|
73
296
|
if (!res.success || !res.data) {
|
|
74
297
|
spinner.fail("Failed to check status");
|
|
75
298
|
error(res.error || "Unknown error");
|
|
76
299
|
return;
|
|
77
300
|
}
|
|
78
301
|
spinner.stop();
|
|
302
|
+
// --raw selects the output format; it must not skip a requested download.
|
|
303
|
+
if (options.output) {
|
|
304
|
+
await download(findVideoUrl(res.data.result), options.output, options.raw);
|
|
305
|
+
}
|
|
79
306
|
if (options.raw) {
|
|
80
307
|
console.log(JSON.stringify(res.data));
|
|
308
|
+
return;
|
|
81
309
|
}
|
|
82
|
-
|
|
83
|
-
const d = res.data;
|
|
84
|
-
const status = d.output?.task_status || d.status || "unknown";
|
|
85
|
-
const id = d.output?.task_id || d.taskId || taskId;
|
|
86
|
-
const videoUrl = d.output?.video_url || d.resultUrl;
|
|
87
|
-
console.log(` Task: ${id}`);
|
|
88
|
-
console.log(` Status: ${status}`);
|
|
89
|
-
if (videoUrl)
|
|
90
|
-
console.log(` URL: ${chalk.cyan(videoUrl)}`);
|
|
91
|
-
if (d.request_id)
|
|
92
|
-
console.log(` Request: ${chalk.gray(d.request_id)}`);
|
|
93
|
-
}
|
|
310
|
+
printTask(res.data, taskId);
|
|
94
311
|
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { Command } from "commander";
|
|
2
|
+
/**
|
|
3
|
+
* Shell completion.
|
|
4
|
+
*
|
|
5
|
+
* The shell scripts are thin: they hand the words typed so far to a hidden
|
|
6
|
+
* `__complete` subcommand and let the CLI decide. That keeps the command tree in
|
|
7
|
+
* one place instead of duplicating it into three shell dialects.
|
|
8
|
+
*
|
|
9
|
+
* Everything dynamic (provider slugs, skill names, model ids) is read from the
|
|
10
|
+
* on-disk cache only. A completion that made a network call would hang the
|
|
11
|
+
* terminal on every Tab press, so a cold cache simply completes nothing.
|
|
12
|
+
*/
|
|
13
|
+
export interface Candidate {
|
|
14
|
+
value: string;
|
|
15
|
+
description?: string;
|
|
16
|
+
}
|
|
17
|
+
declare const SHELLS: readonly ["bash", "zsh", "fish"];
|
|
18
|
+
export type Shell = (typeof SHELLS)[number];
|
|
19
|
+
export declare function isShell(value: string): value is Shell;
|
|
20
|
+
/**
|
|
21
|
+
* Candidates for the position after `words` (which excludes the partial word
|
|
22
|
+
* currently being typed — the shell filters by prefix itself).
|
|
23
|
+
*/
|
|
24
|
+
export declare function complete(program: Command, words: string[]): Candidate[];
|
|
25
|
+
export declare function completionScript(shell: Shell, binary?: string): string;
|
|
26
|
+
export {};
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
import { readCache } from "./cache.js";
|
|
2
|
+
import { cacheScope } from "./api.js";
|
|
3
|
+
import { AGENT_DIRS, API_CATEGORIES, COINGECKO_IDS, MCP_CONFIGS, MEDIA_TYPES, VIDEO_MODELS, } from "./constants.js";
|
|
4
|
+
const SHELLS = ["bash", "zsh", "fish"];
|
|
5
|
+
export function isShell(value) {
|
|
6
|
+
return SHELLS.includes(value);
|
|
7
|
+
}
|
|
8
|
+
// ── Dynamic candidates, all cache-only ───────────────────────────────────────
|
|
9
|
+
function cachedProviders() {
|
|
10
|
+
return readCache(`catalog/${cacheScope()}/category.json`)?.data.apis || [];
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Slugs usable with `aisa run`. A provider's id is not always its URL slug
|
|
14
|
+
* (`brave-search` serves `/apis/v1/brave/...`), so prefer the real slug from any
|
|
15
|
+
* cached detail and fall back to the id.
|
|
16
|
+
*/
|
|
17
|
+
function runSlugs() {
|
|
18
|
+
const out = new Map();
|
|
19
|
+
for (const provider of cachedProviders()) {
|
|
20
|
+
const detail = readCache(`catalog/${cacheScope()}/${provider.id.replace(/[^\w.-]/g, "_")}.json`)?.data.api;
|
|
21
|
+
const first = detail?.endpoint_groups?.flatMap((g) => g.endpoints || [])[0];
|
|
22
|
+
const slug = first
|
|
23
|
+
? first.path.replace(/^\/apis\/v1\//, "").split("/")[0]
|
|
24
|
+
: provider.id;
|
|
25
|
+
out.set(slug, `${provider.endpoint_count} endpoints`);
|
|
26
|
+
}
|
|
27
|
+
return [...out].map(([value, description]) => ({ value, description }));
|
|
28
|
+
}
|
|
29
|
+
function providerIds() {
|
|
30
|
+
return cachedProviders().map((p) => ({
|
|
31
|
+
value: p.id,
|
|
32
|
+
description: `${p.endpoint_count} endpoints`,
|
|
33
|
+
}));
|
|
34
|
+
}
|
|
35
|
+
/** Endpoint paths within one provider, for `aisa run <slug> <TAB>`. */
|
|
36
|
+
function endpointPaths(slug) {
|
|
37
|
+
for (const provider of cachedProviders()) {
|
|
38
|
+
const detail = readCache(`catalog/${cacheScope()}/${provider.id.replace(/[^\w.-]/g, "_")}.json`)?.data.api;
|
|
39
|
+
if (!detail)
|
|
40
|
+
continue;
|
|
41
|
+
const endpoints = (detail.endpoint_groups || []).flatMap((g) => g.endpoints || []);
|
|
42
|
+
const matching = endpoints.filter((e) => e.path.replace(/^\/apis\/v1\//, "").startsWith(`${slug}/`));
|
|
43
|
+
if (matching.length > 0) {
|
|
44
|
+
return matching.map((e) => ({
|
|
45
|
+
value: `/${e.path.replace(/^\/apis\/v1\//, "").split("/").slice(1).join("/")}`,
|
|
46
|
+
description: e.name,
|
|
47
|
+
}));
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return [];
|
|
51
|
+
}
|
|
52
|
+
function skillSlugs() {
|
|
53
|
+
const index = readCache("skills/tree.json")?.data;
|
|
54
|
+
if (!index)
|
|
55
|
+
return [];
|
|
56
|
+
// Leaf names are what install/remove operate on; the canonical slug is the hint.
|
|
57
|
+
return index.slugs.map((slug) => ({
|
|
58
|
+
value: slug.split("/").pop(),
|
|
59
|
+
description: slug,
|
|
60
|
+
}));
|
|
61
|
+
}
|
|
62
|
+
function skillCategories() {
|
|
63
|
+
const index = readCache("skills/tree.json")?.data;
|
|
64
|
+
if (!index)
|
|
65
|
+
return [];
|
|
66
|
+
return [...new Set(index.slugs.map((s) => s.split("/")[0]))].map((value) => ({ value }));
|
|
67
|
+
}
|
|
68
|
+
function modelIds() {
|
|
69
|
+
const models = readCache(`models/${cacheScope()}.json`)?.data;
|
|
70
|
+
return (models || []).map((m) => ({ value: m.id, description: m.owned_by }));
|
|
71
|
+
}
|
|
72
|
+
// ── Static candidate sets ────────────────────────────────────────────────────
|
|
73
|
+
const STATIC = {
|
|
74
|
+
agent: Object.keys(AGENT_DIRS).concat("all").map((value) => ({ value })),
|
|
75
|
+
mcpAgent: Object.keys(MCP_CONFIGS).concat("all").map((value) => ({ value })),
|
|
76
|
+
lang: ["curl", "python", "node", "typescript"].map((value) => ({ value })),
|
|
77
|
+
searchType: [
|
|
78
|
+
{ value: "tavily" },
|
|
79
|
+
{ value: "youtube" },
|
|
80
|
+
{ value: "scholar" },
|
|
81
|
+
{ value: "smart", description: "currently degraded" },
|
|
82
|
+
{ value: "full", description: "currently degraded" },
|
|
83
|
+
],
|
|
84
|
+
stockField: [
|
|
85
|
+
"info",
|
|
86
|
+
"estimates",
|
|
87
|
+
"financials",
|
|
88
|
+
"filings",
|
|
89
|
+
"insider",
|
|
90
|
+
"institutional",
|
|
91
|
+
"news",
|
|
92
|
+
].map((value) => ({ value })),
|
|
93
|
+
configKey: ["defaultModel", "baseUrl", "outputFormat", "twitterCookies", "twitterProxy"].map((value) => ({ value })),
|
|
94
|
+
template: ["default", "llm", "search", "finance", "twitter", "video"].map((value) => ({ value })),
|
|
95
|
+
videoModel: Object.entries(VIDEO_MODELS).map(([value, spec]) => ({
|
|
96
|
+
value,
|
|
97
|
+
description: spec.requiresMedia ? `needs --media ${spec.requiresMedia}` : undefined,
|
|
98
|
+
})),
|
|
99
|
+
mediaType: MEDIA_TYPES.map((value) => ({ value: `${value}=` })),
|
|
100
|
+
apiCategory: API_CATEGORIES.map((value) => ({ value })),
|
|
101
|
+
period: ["current", "1d", "7d", "30d", "90d", "1y"].map((value) => ({ value })),
|
|
102
|
+
cryptoSource: ["coingecko", "financial"].map((value) => ({ value })),
|
|
103
|
+
shell: SHELLS.map((value) => ({ value })),
|
|
104
|
+
crypto: Object.keys(COINGECKO_IDS).map((value) => ({
|
|
105
|
+
value: value.toUpperCase(),
|
|
106
|
+
description: COINGECKO_IDS[value],
|
|
107
|
+
})),
|
|
108
|
+
};
|
|
109
|
+
/**
|
|
110
|
+
* Values for an option that takes an argument, keyed by "command path --option".
|
|
111
|
+
* Matched longest-prefix-first so `skills install --agent` and `mcp setup
|
|
112
|
+
* --agent` can differ.
|
|
113
|
+
*/
|
|
114
|
+
const OPTION_VALUES = [
|
|
115
|
+
["mcp setup --agent", () => STATIC.mcpAgent],
|
|
116
|
+
["skills --agent", () => STATIC.agent],
|
|
117
|
+
["skills list --category", () => skillCategories()],
|
|
118
|
+
["skills init --template", () => STATIC.template],
|
|
119
|
+
["api list --category", () => STATIC.apiCategory],
|
|
120
|
+
["api search --provider", () => providerIds()],
|
|
121
|
+
["api code --lang", () => STATIC.lang],
|
|
122
|
+
["code --lang", () => STATIC.lang],
|
|
123
|
+
["video create --model", () => STATIC.videoModel],
|
|
124
|
+
["video create --media", () => STATIC.mediaType],
|
|
125
|
+
["chat --model", () => modelIds()],
|
|
126
|
+
["web-search --type", () => STATIC.searchType],
|
|
127
|
+
["stock --field", () => STATIC.stockField],
|
|
128
|
+
["crypto --period", () => STATIC.period],
|
|
129
|
+
["crypto --source", () => STATIC.cryptoSource],
|
|
130
|
+
["models --provider", () => [...new Set(modelIds().map((m) => m.description))].filter(Boolean).map((value) => ({ value: value }))],
|
|
131
|
+
];
|
|
132
|
+
/**
|
|
133
|
+
* Positional-argument candidates, keyed by command path and argument index.
|
|
134
|
+
* Callbacks receive the positionals parsed so far — never the raw word list,
|
|
135
|
+
* whose last entry may be an option or an option's value
|
|
136
|
+
* (`run financial --raw <TAB>`).
|
|
137
|
+
*/
|
|
138
|
+
const POSITIONAL = [
|
|
139
|
+
["run", 0, () => runSlugs()],
|
|
140
|
+
["run", 1, (positionals) => endpointPaths(positionals[0])],
|
|
141
|
+
["api show", 0, () => providerIds()],
|
|
142
|
+
["api code", 0, () => runSlugs()],
|
|
143
|
+
["api code", 1, (positionals) => endpointPaths(positionals[0])],
|
|
144
|
+
["code", 0, () => runSlugs()],
|
|
145
|
+
["models show", 0, () => modelIds()],
|
|
146
|
+
["skills show", 0, () => skillSlugs()],
|
|
147
|
+
["skills install", 0, () => skillSlugs()],
|
|
148
|
+
["skills remove", 0, () => skillSlugs()],
|
|
149
|
+
["crypto", 0, () => STATIC.crypto],
|
|
150
|
+
["config set", 0, () => STATIC.configKey],
|
|
151
|
+
["config get", 0, () => STATIC.configKey],
|
|
152
|
+
["completion", 0, () => STATIC.shell],
|
|
153
|
+
];
|
|
154
|
+
// ── Command tree walking ─────────────────────────────────────────────────────
|
|
155
|
+
function visibleSubcommands(cmd) {
|
|
156
|
+
return cmd.commands.filter((c) => !c._hidden && !c.name().startsWith("__"));
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* The subcommand commander runs when none is named — `aisa models --provider x`
|
|
160
|
+
* is really `aisa models list --provider x`, so its options belong to the parent
|
|
161
|
+
* position too.
|
|
162
|
+
*/
|
|
163
|
+
function defaultSubcommand(cmd) {
|
|
164
|
+
const name = cmd._defaultCommandName;
|
|
165
|
+
return name ? visibleSubcommands(cmd).find((c) => c.name() === name) : undefined;
|
|
166
|
+
}
|
|
167
|
+
/** The commands whose options apply at this position: the command, plus its default child. */
|
|
168
|
+
function optionScope(cmd) {
|
|
169
|
+
const fallback = defaultSubcommand(cmd);
|
|
170
|
+
return fallback ? [cmd, fallback] : [cmd];
|
|
171
|
+
}
|
|
172
|
+
function optionCandidates(cmd) {
|
|
173
|
+
const out = [];
|
|
174
|
+
const seen = new Set();
|
|
175
|
+
for (const scoped of optionScope(cmd)) {
|
|
176
|
+
for (const option of scoped.options) {
|
|
177
|
+
if (option.hidden || !option.long || seen.has(option.long))
|
|
178
|
+
continue;
|
|
179
|
+
seen.add(option.long);
|
|
180
|
+
out.push({ value: option.long, description: option.description });
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
out.push({ value: "--help", description: "display help" });
|
|
184
|
+
return out;
|
|
185
|
+
}
|
|
186
|
+
function findOption(cmd, flag) {
|
|
187
|
+
for (const scoped of optionScope(cmd)) {
|
|
188
|
+
const option = scoped.options.find((o) => o.long === flag || o.short === flag);
|
|
189
|
+
if (option)
|
|
190
|
+
return option;
|
|
191
|
+
}
|
|
192
|
+
return undefined;
|
|
193
|
+
}
|
|
194
|
+
function takesValue(cmd, flag) {
|
|
195
|
+
const option = findOption(cmd, flag);
|
|
196
|
+
return Boolean(option && (option.required || option.optional));
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* Candidates for the position after `words` (which excludes the partial word
|
|
200
|
+
* currently being typed — the shell filters by prefix itself).
|
|
201
|
+
*/
|
|
202
|
+
export function complete(program, words) {
|
|
203
|
+
let cmd = program;
|
|
204
|
+
const path = [];
|
|
205
|
+
const positionals = [];
|
|
206
|
+
let i = 0;
|
|
207
|
+
// Walk down the command tree, collecting positional args along the way.
|
|
208
|
+
for (; i < words.length; i++) {
|
|
209
|
+
const word = words[i];
|
|
210
|
+
if (word.startsWith("-"))
|
|
211
|
+
break;
|
|
212
|
+
const child = visibleSubcommands(cmd).find((c) => c.name() === word || c.aliases().includes(word));
|
|
213
|
+
if (!child)
|
|
214
|
+
break;
|
|
215
|
+
cmd = child;
|
|
216
|
+
path.push(child.name());
|
|
217
|
+
}
|
|
218
|
+
// Remaining words are options and positional arguments.
|
|
219
|
+
for (; i < words.length; i++) {
|
|
220
|
+
const word = words[i];
|
|
221
|
+
if (word.startsWith("-")) {
|
|
222
|
+
if (takesValue(cmd, word)) {
|
|
223
|
+
// The word being typed is this option's value.
|
|
224
|
+
if (i === words.length - 1) {
|
|
225
|
+
const key = `${path.join(" ")} ${word}`;
|
|
226
|
+
const match = OPTION_VALUES.filter(([prefix]) => {
|
|
227
|
+
const [prefixPath, prefixFlag] = [
|
|
228
|
+
prefix.slice(0, prefix.lastIndexOf(" ")),
|
|
229
|
+
prefix.slice(prefix.lastIndexOf(" ") + 1),
|
|
230
|
+
];
|
|
231
|
+
return prefixFlag === word && key.startsWith(prefixPath);
|
|
232
|
+
}).sort((a, b) => b[0].length - a[0].length)[0];
|
|
233
|
+
return match ? match[1]() : [];
|
|
234
|
+
}
|
|
235
|
+
i++; // skip the value
|
|
236
|
+
}
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
positionals.push(word);
|
|
240
|
+
}
|
|
241
|
+
const commandPath = path.join(" ");
|
|
242
|
+
const subcommands = visibleSubcommands(cmd).map((c) => ({
|
|
243
|
+
value: c.name(),
|
|
244
|
+
description: c.description(),
|
|
245
|
+
}));
|
|
246
|
+
const positional = POSITIONAL.filter(([prefix, index]) => prefix === commandPath && index === positionals.length)
|
|
247
|
+
.flatMap(([, , produce]) => produce(positionals))
|
|
248
|
+
.filter(Boolean);
|
|
249
|
+
return [...subcommands, ...positional, ...optionCandidates(cmd)];
|
|
250
|
+
}
|
|
251
|
+
// ── Shell scripts ────────────────────────────────────────────────────────────
|
|
252
|
+
export function completionScript(shell, binary = "aisa") {
|
|
253
|
+
switch (shell) {
|
|
254
|
+
case "bash":
|
|
255
|
+
return `# aisa bash completion
|
|
256
|
+
# Install: aisa completion bash > /usr/local/etc/bash_completion.d/aisa
|
|
257
|
+
# or: eval "$(aisa completion bash)"
|
|
258
|
+
_${binary}_complete() {
|
|
259
|
+
local cur words
|
|
260
|
+
cur="\${COMP_WORDS[COMP_CWORD]}"
|
|
261
|
+
words=("\${COMP_WORDS[@]:1:COMP_CWORD-1}")
|
|
262
|
+
local candidates
|
|
263
|
+
candidates=$(${binary} __complete -- "\${words[@]}" 2>/dev/null | cut -f1)
|
|
264
|
+
COMPREPLY=($(compgen -W "\${candidates}" -- "\${cur}"))
|
|
265
|
+
}
|
|
266
|
+
complete -o default -F _${binary}_complete ${binary}
|
|
267
|
+
`;
|
|
268
|
+
case "zsh":
|
|
269
|
+
return `#compdef ${binary}
|
|
270
|
+
# aisa zsh completion
|
|
271
|
+
# Install: aisa completion zsh > "\${fpath[1]}/_aisa"
|
|
272
|
+
# or: eval "$(aisa completion zsh)"
|
|
273
|
+
_${binary}() {
|
|
274
|
+
local -a candidates
|
|
275
|
+
local -a prior
|
|
276
|
+
prior=(\${words[2,CURRENT-1]})
|
|
277
|
+
# Output is "value<TAB>description"; _describe wants "value:description".
|
|
278
|
+
candidates=(\${(f)"$(${binary} __complete -- \${prior} 2>/dev/null | sed 's/\\t/:/')"})
|
|
279
|
+
_describe -t commands '${binary}' candidates
|
|
280
|
+
}
|
|
281
|
+
compdef _${binary} ${binary}
|
|
282
|
+
`;
|
|
283
|
+
case "fish":
|
|
284
|
+
return `# aisa fish completion
|
|
285
|
+
# Install: aisa completion fish > ~/.config/fish/completions/aisa.fish
|
|
286
|
+
function __${binary}_complete
|
|
287
|
+
set -l tokens (commandline -opc)
|
|
288
|
+
${binary} __complete -- $tokens[2..-1] 2>/dev/null
|
|
289
|
+
end
|
|
290
|
+
complete -c ${binary} -f -a '(__${binary}_complete)'
|
|
291
|
+
`;
|
|
292
|
+
}
|
|
293
|
+
}
|