@aisa-one/cli 0.2.1 → 0.2.3

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 (52) hide show
  1. package/CHANGELOG.md +144 -1
  2. package/README.md +249 -102
  3. package/dist/api.d.ts +46 -1
  4. package/dist/api.js +77 -8
  5. package/dist/cache.d.ts +18 -0
  6. package/dist/cache.js +65 -0
  7. package/dist/catalog.d.ts +90 -0
  8. package/dist/catalog.js +101 -0
  9. package/dist/commands/account.d.ts +4 -1
  10. package/dist/commands/account.js +36 -6
  11. package/dist/commands/api.d.ts +18 -4
  12. package/dist/commands/api.js +357 -24
  13. package/dist/commands/cacheCmd.d.ts +2 -0
  14. package/dist/commands/cacheCmd.js +10 -0
  15. package/dist/commands/completionCmd.d.ts +7 -0
  16. package/dist/commands/completionCmd.js +37 -0
  17. package/dist/commands/configCmd.js +19 -1
  18. package/dist/commands/finance.d.ts +4 -0
  19. package/dist/commands/finance.js +212 -29
  20. package/dist/commands/models.js +6 -0
  21. package/dist/commands/run.d.ts +21 -0
  22. package/dist/commands/run.js +113 -6
  23. package/dist/commands/search.js +8 -3
  24. package/dist/commands/skills.d.ts +6 -2
  25. package/dist/commands/skills.js +189 -134
  26. package/dist/commands/twitter.js +2 -3
  27. package/dist/commands/video.d.ts +40 -0
  28. package/dist/commands/video.js +272 -55
  29. package/dist/completion.d.ts +26 -0
  30. package/dist/completion.js +293 -0
  31. package/dist/constants.d.ts +55 -5
  32. package/dist/constants.js +136 -11
  33. package/dist/index.js +86 -23
  34. package/dist/skills-registry.d.ts +64 -0
  35. package/dist/skills-registry.js +219 -0
  36. package/dist/types.d.ts +32 -1
  37. package/dist/utils/file.d.ts +5 -1
  38. package/dist/utils/file.js +5 -1
  39. package/dist/utils/pool.d.ts +8 -0
  40. package/dist/utils/pool.js +27 -0
  41. package/package.json +9 -3
  42. package/dist/agent/env-setup.d.ts +0 -5
  43. package/dist/agent/env-setup.js +0 -49
  44. package/dist/agent/index-client.d.ts +0 -7
  45. package/dist/agent/index-client.js +0 -22
  46. package/dist/agent/installer.d.ts +0 -19
  47. package/dist/agent/installer.js +0 -70
  48. package/dist/agent/setup-python.d.ts +0 -15
  49. package/dist/agent/setup-python.js +0 -51
  50. package/dist/commands/agent.d.ts +0 -37
  51. package/dist/commands/agent.js +0 -129
  52. package/tests/streaming.test.ts +0 -73
@@ -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 body = {
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.output?.task_id || res.data.taskId;
27
- if (!taskId) {
28
- console.log(formatJson(res.data));
29
- return;
30
- }
31
- success(`Task created: ${taskId}`);
32
- if (!options.wait) {
33
- console.log(chalk.gray(` Check status: aisa video status ${taskId}`));
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
- // Poll for completion
37
- const pollSpinner = ora("Generating video...").start();
38
- let status = "PENDING";
39
- let lastPollData;
40
- while (status === "PENDING" || status === "RUNNING") {
41
- await new Promise((r) => setTimeout(r, 5000));
42
- const pollRes = await apiRequest(key, "services/aigc/tasks", {
43
- query: { task_id: taskId },
44
- domain: true,
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.fail("Failed to check status");
242
+ pollSpinner?.fail("Failed to check status");
48
243
  error(pollRes.error || "Unknown error");
49
244
  return;
50
245
  }
51
- lastPollData = pollRes.data;
52
- status = lastPollData.output?.task_status || lastPollData.status || "UNKNOWN";
53
- pollSpinner.text = `Generating video... (${status})`;
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
- else {
63
- pollSpinner.fail(`Video generation failed: ${status}`);
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, "services/aigc/tasks", {
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
- else {
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
+ }