@aisa-one/cli 0.2.4 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +172 -1
- package/LICENSE +21 -0
- package/README.md +163 -169
- package/dist/api.d.ts +10 -0
- package/dist/api.js +14 -3
- package/dist/catalog.d.ts +1 -18
- package/dist/catalog.js +1 -24
- package/dist/cli-error.d.ts +17 -0
- package/dist/cli-error.js +25 -0
- package/dist/cli-guidance.d.ts +14 -0
- package/dist/cli-guidance.js +55 -0
- package/dist/commands/account.d.ts +17 -0
- package/dist/commands/account.js +45 -1
- package/dist/commands/api.d.ts +0 -11
- package/dist/commands/api.js +6 -175
- package/dist/commands/auth.d.ts +2 -1
- package/dist/commands/auth.js +43 -9
- package/dist/commands/brand-logos.d.ts +6 -0
- package/dist/commands/brand-logos.js +21 -0
- package/dist/commands/configCmd.js +8 -3
- package/dist/commands/connect-shared.d.ts +136 -0
- package/dist/commands/connect-shared.js +143 -0
- package/dist/commands/connect-t2.d.ts +18 -0
- package/dist/commands/connect-t2.js +1599 -0
- package/dist/commands/connect-terminal.d.ts +65 -0
- package/dist/commands/connect-terminal.js +822 -0
- package/dist/commands/connect.d.ts +31 -0
- package/dist/commands/connect.js +3181 -0
- package/dist/commands/flow.d.ts +685 -0
- package/dist/commands/flow.js +697 -0
- package/dist/commands/install.d.ts +91 -0
- package/dist/commands/install.js +258 -0
- package/dist/commands/llm-config.d.ts +103 -0
- package/dist/commands/llm-config.js +452 -0
- package/dist/commands/manifest.d.ts +68 -0
- package/dist/commands/manifest.js +90 -0
- package/dist/commands/mcp.d.ts +79 -2
- package/dist/commands/mcp.js +262 -48
- package/dist/commands/oauth-login.d.ts +33 -0
- package/dist/commands/oauth-login.js +333 -0
- package/dist/commands/prompt.d.ts +96 -0
- package/dist/commands/prompt.js +397 -0
- package/dist/commands/serve-results.d.ts +61 -0
- package/dist/commands/serve-results.js +245 -0
- package/dist/commands/serve-signin.d.ts +28 -0
- package/dist/commands/serve-signin.js +90 -0
- package/dist/commands/signin-page.d.ts +49 -0
- package/dist/commands/signin-page.js +250 -0
- package/dist/commands/skills.js +87 -50
- package/dist/commands/tool-help.d.ts +69 -0
- package/dist/commands/tool-help.js +189 -0
- package/dist/commands/tool-input.d.ts +22 -0
- package/dist/commands/tool-input.js +220 -0
- package/dist/commands/tools.d.ts +12 -0
- package/dist/commands/tools.js +307 -0
- package/dist/commands/update.d.ts +11 -0
- package/dist/commands/update.js +92 -0
- package/dist/commands/vscode.d.ts +65 -0
- package/dist/commands/vscode.js +173 -0
- package/dist/commands/wrappers.d.ts +23 -0
- package/dist/commands/wrappers.js +102 -0
- package/dist/completion.js +3 -80
- package/dist/config.js +76 -2
- package/dist/constants.d.ts +78 -38
- package/dist/constants.js +80 -102
- package/dist/index.js +155 -357
- package/dist/json-text.d.ts +12 -0
- package/dist/json-text.js +60 -0
- package/dist/router.d.ts +39 -0
- package/dist/router.js +59 -0
- package/dist/skills-registry.js +12 -3
- package/dist/utils/banner.d.ts +9 -0
- package/dist/utils/banner.js +41 -0
- package/dist/utils/browser.d.ts +19 -0
- package/dist/utils/browser.js +31 -0
- package/dist/utils/exec.d.ts +43 -0
- package/dist/utils/exec.js +61 -0
- package/dist/utils/http.d.ts +53 -0
- package/dist/utils/http.js +124 -0
- package/dist/utils/install-method.d.ts +36 -0
- package/dist/utils/install-method.js +48 -0
- package/dist/utils/journal.d.ts +57 -0
- package/dist/utils/journal.js +188 -0
- package/dist/utils/key-check.d.ts +20 -0
- package/dist/utils/key-check.js +24 -0
- package/dist/utils/update-check.d.ts +23 -0
- package/dist/utils/update-check.js +116 -0
- package/dist/utils/width.d.ts +32 -0
- package/dist/utils/width.js +73 -0
- package/package.json +8 -4
- package/vscode-extension/README.md +6 -0
- package/vscode-extension/aisa-vscode.vsix +0 -0
- package/dist/commands/finance.d.ts +0 -17
- package/dist/commands/finance.js +0 -342
- package/dist/commands/run.d.ts +0 -28
- package/dist/commands/run.js +0 -182
- package/dist/commands/search.d.ts +0 -9
- package/dist/commands/search.js +0 -81
- package/dist/commands/twitter.d.ts +0 -138
- package/dist/commands/twitter.js +0 -374
- package/dist/commands/video.d.ts +0 -49
- package/dist/commands/video.js +0 -311
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parse application JSON while keeping number tokens as strings at their
|
|
3
|
+
* structural locations. Strings are copied with escapes so digits inside
|
|
4
|
+
* quotes are not treated as numbers.
|
|
5
|
+
*
|
|
6
|
+
* `--json` must keep using the original raw body; this is only for human
|
|
7
|
+
* rendering and local inspection.
|
|
8
|
+
*/
|
|
9
|
+
export function parseJsonKeepingNumberTokens(source) {
|
|
10
|
+
return JSON.parse(quoteNumberTokens(source));
|
|
11
|
+
}
|
|
12
|
+
export function quoteNumberTokens(source) {
|
|
13
|
+
let out = "";
|
|
14
|
+
let i = 0;
|
|
15
|
+
while (i < source.length) {
|
|
16
|
+
const c = source[i];
|
|
17
|
+
if (c === '"') {
|
|
18
|
+
const copied = copyJsonString(source, i);
|
|
19
|
+
out += copied.text;
|
|
20
|
+
i = copied.end;
|
|
21
|
+
continue;
|
|
22
|
+
}
|
|
23
|
+
if (c === "-" || (c >= "0" && c <= "9")) {
|
|
24
|
+
const match = source.slice(i).match(/^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?/);
|
|
25
|
+
if (match) {
|
|
26
|
+
out += JSON.stringify(match[0]);
|
|
27
|
+
i += match[0].length;
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
out += c;
|
|
32
|
+
i++;
|
|
33
|
+
}
|
|
34
|
+
return out;
|
|
35
|
+
}
|
|
36
|
+
function copyJsonString(source, start) {
|
|
37
|
+
let i = start;
|
|
38
|
+
let text = source[i++];
|
|
39
|
+
while (i < source.length) {
|
|
40
|
+
const c = source[i++];
|
|
41
|
+
text += c;
|
|
42
|
+
if (c === "\\" && i < source.length) {
|
|
43
|
+
text += source[i++];
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
if (c === '"')
|
|
47
|
+
break;
|
|
48
|
+
}
|
|
49
|
+
return { text, end: i };
|
|
50
|
+
}
|
|
51
|
+
/** Exact token from a value produced by parseJsonKeepingNumberTokens. */
|
|
52
|
+
export function numberToken(value) {
|
|
53
|
+
if (typeof value === "string" && /^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?$/.test(value)) {
|
|
54
|
+
return value;
|
|
55
|
+
}
|
|
56
|
+
if (typeof value === "number" && Number.isFinite(value)) {
|
|
57
|
+
return Number.isInteger(value) ? value.toString() : undefined;
|
|
58
|
+
}
|
|
59
|
+
return undefined;
|
|
60
|
+
}
|
package/dist/router.d.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deployed Tool Router origin (nginx / tools.aisa.one). Paths already include
|
|
3
|
+
* `/v1/tool-router/...`, so this is an origin, not the LLM `/v1` base.
|
|
4
|
+
* OpenAPI still lists api.aisa.one; that host 404s these paths.
|
|
5
|
+
*/
|
|
6
|
+
export declare const DEFAULT_ROUTER_URL = "https://tools.aisa.one";
|
|
7
|
+
/** Origin/prefix before `/v1/tool-router/...`. Harness and tests set this. */
|
|
8
|
+
export declare const ROUTER_URL_ENV = "AISA_ROUTER_BASE_URL";
|
|
9
|
+
export declare const ROUTER_PATHS: {
|
|
10
|
+
readonly search: "/v1/tool-router/aisa-search-tool";
|
|
11
|
+
readonly schema: "/v1/tool-router/aisa-batch-get-schema";
|
|
12
|
+
readonly quote: "/v1/tool-router/aisa-batch-quote";
|
|
13
|
+
readonly call: "/v1/tool-router/aisa-batch-use";
|
|
14
|
+
};
|
|
15
|
+
export type RouterOperation = keyof typeof ROUTER_PATHS;
|
|
16
|
+
/**
|
|
17
|
+
* Origin of the shared Tool Router HTTP service.
|
|
18
|
+
*
|
|
19
|
+
* Independent of resolveBases(): those rebuild `/v1` and `/apis/v1` for the
|
|
20
|
+
* LLM gateway and integration catalog. A test harness points this at a
|
|
21
|
+
* controlled Router via AISA_ROUTER_BASE_URL or config routerUrl.
|
|
22
|
+
*/
|
|
23
|
+
export declare function resolveRouterBase(): string;
|
|
24
|
+
export interface RouterRequest {
|
|
25
|
+
operation: RouterOperation;
|
|
26
|
+
/** Raw application JSON. Sent unchanged so numeric tokens survive. */
|
|
27
|
+
body: string;
|
|
28
|
+
/** Bearer credential without the "Bearer " prefix, if any. */
|
|
29
|
+
apiKey?: string;
|
|
30
|
+
}
|
|
31
|
+
export interface RouterHttpResult {
|
|
32
|
+
status: number;
|
|
33
|
+
raw: string;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* POST one Router operation. Never retries: quote and use are not safe to
|
|
37
|
+
* replay, and search/schema must not hide a failed attempt.
|
|
38
|
+
*/
|
|
39
|
+
export declare function routerPost(request: RouterRequest): Promise<RouterHttpResult>;
|
package/dist/router.js
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { getConfig } from "./config.js";
|
|
2
|
+
import { httpFetch } from "./utils/http.js";
|
|
3
|
+
/**
|
|
4
|
+
* Deployed Tool Router origin (nginx / tools.aisa.one). Paths already include
|
|
5
|
+
* `/v1/tool-router/...`, so this is an origin, not the LLM `/v1` base.
|
|
6
|
+
* OpenAPI still lists api.aisa.one; that host 404s these paths.
|
|
7
|
+
*/
|
|
8
|
+
export const DEFAULT_ROUTER_URL = "https://tools.aisa.one";
|
|
9
|
+
/** Origin/prefix before `/v1/tool-router/...`. Harness and tests set this. */
|
|
10
|
+
export const ROUTER_URL_ENV = "AISA_ROUTER_BASE_URL";
|
|
11
|
+
export const ROUTER_PATHS = {
|
|
12
|
+
search: "/v1/tool-router/aisa-search-tool",
|
|
13
|
+
schema: "/v1/tool-router/aisa-batch-get-schema",
|
|
14
|
+
quote: "/v1/tool-router/aisa-batch-quote",
|
|
15
|
+
call: "/v1/tool-router/aisa-batch-use",
|
|
16
|
+
};
|
|
17
|
+
/**
|
|
18
|
+
* Origin of the shared Tool Router HTTP service.
|
|
19
|
+
*
|
|
20
|
+
* Independent of resolveBases(): those rebuild `/v1` and `/apis/v1` for the
|
|
21
|
+
* LLM gateway and integration catalog. A test harness points this at a
|
|
22
|
+
* controlled Router via AISA_ROUTER_BASE_URL or config routerUrl.
|
|
23
|
+
*/
|
|
24
|
+
export function resolveRouterBase() {
|
|
25
|
+
const env = process.env[ROUTER_URL_ENV];
|
|
26
|
+
if (env && env.trim())
|
|
27
|
+
return stripTrailingSlash(env.trim());
|
|
28
|
+
const configured = getConfig("routerUrl");
|
|
29
|
+
if (typeof configured === "string" && configured.trim()) {
|
|
30
|
+
return stripTrailingSlash(configured.trim());
|
|
31
|
+
}
|
|
32
|
+
return DEFAULT_ROUTER_URL;
|
|
33
|
+
}
|
|
34
|
+
function stripTrailingSlash(url) {
|
|
35
|
+
return url.replace(/\/+$/, "");
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* POST one Router operation. Never retries: quote and use are not safe to
|
|
39
|
+
* replay, and search/schema must not hide a failed attempt.
|
|
40
|
+
*/
|
|
41
|
+
export async function routerPost(request) {
|
|
42
|
+
const url = `${resolveRouterBase()}${ROUTER_PATHS[request.operation]}`;
|
|
43
|
+
const headers = {
|
|
44
|
+
"Content-Type": "application/json",
|
|
45
|
+
"x-aisa-source": "cli",
|
|
46
|
+
};
|
|
47
|
+
if (request.apiKey) {
|
|
48
|
+
headers.Authorization = `Bearer ${request.apiKey}`;
|
|
49
|
+
}
|
|
50
|
+
const res = await httpFetch(url, {
|
|
51
|
+
method: "POST",
|
|
52
|
+
headers,
|
|
53
|
+
body: request.body,
|
|
54
|
+
idempotent: false,
|
|
55
|
+
// Never follow: a 307/308 to aisa-batch-use would keep POST+body+auth.
|
|
56
|
+
redirect: "manual",
|
|
57
|
+
});
|
|
58
|
+
return { status: res.status, raw: await res.text() };
|
|
59
|
+
}
|
package/dist/skills-registry.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { readCache, writeCache, touchCache } from "./cache.js";
|
|
2
2
|
import { pool } from "./utils/pool.js";
|
|
3
|
+
import { httpFetch, INFO_TIMEOUT_MS } from "./utils/http.js";
|
|
3
4
|
/**
|
|
4
5
|
* GitHub-backed skill registry.
|
|
5
6
|
*
|
|
@@ -76,8 +77,10 @@ async function loadSkillIndex(refresh) {
|
|
|
76
77
|
return cached.data;
|
|
77
78
|
let res;
|
|
78
79
|
try {
|
|
79
|
-
res = await
|
|
80
|
+
res = await httpFetch(`${GH_API}/git/trees/main?recursive=1`, {
|
|
80
81
|
headers: ghHeaders(refresh ? undefined : cached?.etag),
|
|
82
|
+
idempotent: true,
|
|
83
|
+
timeoutMs: INFO_TIMEOUT_MS,
|
|
81
84
|
});
|
|
82
85
|
}
|
|
83
86
|
catch (err) {
|
|
@@ -151,7 +154,10 @@ function skillMdBlob(slug, index) {
|
|
|
151
154
|
return index.blobs[slug]?.find((b) => b.path === `${slug}/SKILL.md`);
|
|
152
155
|
}
|
|
153
156
|
export async function fetchSkillMarkdown(slug) {
|
|
154
|
-
const res = await
|
|
157
|
+
const res = await httpFetch(`${GH_RAW}/${slug}/SKILL.md`, {
|
|
158
|
+
idempotent: true,
|
|
159
|
+
timeoutMs: INFO_TIMEOUT_MS,
|
|
160
|
+
});
|
|
155
161
|
if (!res.ok)
|
|
156
162
|
throw new Error(`Cannot read ${slug}/SKILL.md: ${res.status}`);
|
|
157
163
|
return res.text();
|
|
@@ -203,7 +209,10 @@ export async function fetchSkillFiles(slug, index) {
|
|
|
203
209
|
const blobs = index.blobs[slug] || [];
|
|
204
210
|
const prefix = `${slug}/`;
|
|
205
211
|
const results = await pool(blobs.map((blob) => async () => {
|
|
206
|
-
const res = await
|
|
212
|
+
const res = await httpFetch(`${GH_RAW}/${blob.path}`, {
|
|
213
|
+
idempotent: true,
|
|
214
|
+
timeoutMs: INFO_TIMEOUT_MS,
|
|
215
|
+
});
|
|
207
216
|
if (!res.ok)
|
|
208
217
|
throw new Error(`${blob.path}: ${res.status}`);
|
|
209
218
|
return {
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Print it, or don't — silence is the right answer more often than it looks.
|
|
3
|
+
*
|
|
4
|
+
* Nothing is drawn into a pipe: a banner in a log file or a CI transcript is
|
|
5
|
+
* noise that someone has to scroll past, and the escape codes make it worse.
|
|
6
|
+
* Nothing is drawn into a window too narrow to hold it either, because a
|
|
7
|
+
* wrapped banner is a smear, which is worse than no banner at all.
|
|
8
|
+
*/
|
|
9
|
+
export declare function printBanner(): void;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import chalk from "chalk";
|
|
2
|
+
/**
|
|
3
|
+
* The two lines that open `aisa connect`.
|
|
4
|
+
*
|
|
5
|
+
* A banner is seen once with pleasure and then several hundred times, so the
|
|
6
|
+
* question is not which one looks best the first time but which one is still
|
|
7
|
+
* bearable the twentieth. That rules out the tall ones: six lines of block
|
|
8
|
+
* letters push the thing a person actually came to read below the fold, every
|
|
9
|
+
* single run.
|
|
10
|
+
*
|
|
11
|
+
* Two lines, 46 columns. AIsa in the brand orange, `connect` in grey beside
|
|
12
|
+
* it — the same order of importance the page gives them. Half-block glyphs,
|
|
13
|
+
* which every monospace font has, rather than a face that needs one.
|
|
14
|
+
*/
|
|
15
|
+
const AISA = ["▄▀█ █ █▀ ▄▀█", "█▀█ █ ▄█ █▀█"];
|
|
16
|
+
const CONNECT = ["█▀▀ █▀█ █▄░█ █▄░█ █▀▀ █▀▀ ▀█▀", "█▄▄ █▄█ █░▀█ █░▀█ ██▄ █▄▄ ░█░"];
|
|
17
|
+
/** Both halves plus the gap and the leading space. */
|
|
18
|
+
const WIDTH = 1 + 12 + 3 + 29;
|
|
19
|
+
/**
|
|
20
|
+
* Print it, or don't — silence is the right answer more often than it looks.
|
|
21
|
+
*
|
|
22
|
+
* Nothing is drawn into a pipe: a banner in a log file or a CI transcript is
|
|
23
|
+
* noise that someone has to scroll past, and the escape codes make it worse.
|
|
24
|
+
* Nothing is drawn into a window too narrow to hold it either, because a
|
|
25
|
+
* wrapped banner is a smear, which is worse than no banner at all.
|
|
26
|
+
*/
|
|
27
|
+
export function printBanner() {
|
|
28
|
+
if (!process.stdout.isTTY)
|
|
29
|
+
return;
|
|
30
|
+
if (process.env.NO_COLOR || process.env.CI)
|
|
31
|
+
return;
|
|
32
|
+
if ((process.stdout.columns || 80) < WIDTH + 2)
|
|
33
|
+
return;
|
|
34
|
+
const orange = chalk.hex("#c2410c");
|
|
35
|
+
const grey = chalk.hex("#a8a29e");
|
|
36
|
+
console.log("");
|
|
37
|
+
for (let i = 0; i < 2; i++) {
|
|
38
|
+
console.log(" " + orange.bold(AISA[i]) + " " + grey(CONNECT[i]));
|
|
39
|
+
}
|
|
40
|
+
console.log("");
|
|
41
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Can this machine put a page in front of the person using it?
|
|
3
|
+
*
|
|
4
|
+
* Nobody should have to tell a tool it is on a server. Asking the user to
|
|
5
|
+
* pass a flag for it means asking them to know a word — "headless" — for a
|
|
6
|
+
* situation the machine can read for itself, and to know it before anything
|
|
7
|
+
* has gone wrong. The signals are unambiguous enough:
|
|
8
|
+
*
|
|
9
|
+
* · a session that arrived over SSH has its browser at the other end of
|
|
10
|
+
* the connection; opening one here puts it on a screen nobody is at
|
|
11
|
+
* · a Linux box with no display server has no browser to open at all
|
|
12
|
+
* · CI has nobody watching by definition
|
|
13
|
+
*
|
|
14
|
+
* Being wrong is cheap in one direction and not the other. Deciding we
|
|
15
|
+
* cannot open a browser when we could costs one printed URL. Deciding we
|
|
16
|
+
* can when we cannot leaves a person watching a terminal that is waiting
|
|
17
|
+
* for a click nobody will ever make.
|
|
18
|
+
*/
|
|
19
|
+
export declare function canOpenBrowser(): boolean;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Can this machine put a page in front of the person using it?
|
|
3
|
+
*
|
|
4
|
+
* Nobody should have to tell a tool it is on a server. Asking the user to
|
|
5
|
+
* pass a flag for it means asking them to know a word — "headless" — for a
|
|
6
|
+
* situation the machine can read for itself, and to know it before anything
|
|
7
|
+
* has gone wrong. The signals are unambiguous enough:
|
|
8
|
+
*
|
|
9
|
+
* · a session that arrived over SSH has its browser at the other end of
|
|
10
|
+
* the connection; opening one here puts it on a screen nobody is at
|
|
11
|
+
* · a Linux box with no display server has no browser to open at all
|
|
12
|
+
* · CI has nobody watching by definition
|
|
13
|
+
*
|
|
14
|
+
* Being wrong is cheap in one direction and not the other. Deciding we
|
|
15
|
+
* cannot open a browser when we could costs one printed URL. Deciding we
|
|
16
|
+
* can when we cannot leaves a person watching a terminal that is waiting
|
|
17
|
+
* for a click nobody will ever make.
|
|
18
|
+
*/
|
|
19
|
+
export function canOpenBrowser() {
|
|
20
|
+
// An explicit answer always wins — scripts, containers, and anyone whose
|
|
21
|
+
// setup we guessed wrong about.
|
|
22
|
+
if (process.env.AISA_NO_BROWSER)
|
|
23
|
+
return false;
|
|
24
|
+
if (process.env.SSH_CONNECTION || process.env.SSH_TTY || process.env.SSH_CLIENT)
|
|
25
|
+
return false;
|
|
26
|
+
if (process.env.CI)
|
|
27
|
+
return false;
|
|
28
|
+
if (process.platform === "darwin" || process.platform === "win32")
|
|
29
|
+
return true;
|
|
30
|
+
return Boolean(process.env.DISPLAY || process.env.WAYLAND_DISPLAY);
|
|
31
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { type SpawnSyncReturns } from "node:child_process";
|
|
2
|
+
/**
|
|
3
|
+
* Every child process this CLI starts goes through here.
|
|
4
|
+
*
|
|
5
|
+
* The reason is one bug we shipped twice: Node's `execFile` and `spawnSync`
|
|
6
|
+
* default to a 1 MB output buffer, and a global npm install prints more than
|
|
7
|
+
* that. On overflow Node kills the child and reports an error — after the
|
|
8
|
+
* package is already on disk — so a successful install read as a failure and
|
|
9
|
+
* the run went into a pointless retry (or hung waiting on it). Anything that
|
|
10
|
+
* shells out to a package manager, a vendor installer or an agent's own CLI
|
|
11
|
+
* can exceed 1 MB, so the buffer is raised in one place rather than
|
|
12
|
+
* remembered at 21 call sites.
|
|
13
|
+
*
|
|
14
|
+
* Timeouts live here too: a child with no timeout is a run that can hang for
|
|
15
|
+
* ever. Callers that legitimately need longer pass their own.
|
|
16
|
+
*/
|
|
17
|
+
/** Beyond anything an installer or a client CLI prints. */
|
|
18
|
+
export declare const MAX_OUTPUT_BYTES: number;
|
|
19
|
+
/** Enough for an agent install over a slow link; short of forever. */
|
|
20
|
+
export declare const DEFAULT_TIMEOUT_MS = 300000;
|
|
21
|
+
/** For a quick question to a local binary (`--version`, `mcp list`). */
|
|
22
|
+
export declare const QUICK_TIMEOUT_MS = 20000;
|
|
23
|
+
export interface RunOptions {
|
|
24
|
+
timeout?: number;
|
|
25
|
+
/** Passed through when a command must see specific variables. */
|
|
26
|
+
env?: NodeJS.ProcessEnv;
|
|
27
|
+
cwd?: string;
|
|
28
|
+
}
|
|
29
|
+
/** Run a binary with arguments. Rejects on a non-zero exit, like execFile. */
|
|
30
|
+
export declare function run(file: string, args: readonly string[], options?: RunOptions): Promise<{
|
|
31
|
+
stdout: string;
|
|
32
|
+
stderr: string;
|
|
33
|
+
}>;
|
|
34
|
+
/** Run a shell command line (pipelines, `a; b`). Never rejects: the caller
|
|
35
|
+
* decides what a non-zero exit means, which is almost never "give up". */
|
|
36
|
+
export declare function runShell(command: string, options?: RunOptions): Promise<{
|
|
37
|
+
ok: boolean;
|
|
38
|
+
stdout: string;
|
|
39
|
+
stderr: string;
|
|
40
|
+
detail: string;
|
|
41
|
+
}>;
|
|
42
|
+
/** Ask a local binary something and wait for the answer. */
|
|
43
|
+
export declare function runSync(file: string, args: readonly string[], options?: RunOptions): SpawnSyncReturns<string>;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { execFile, spawnSync } from "node:child_process";
|
|
2
|
+
import { promisify } from "node:util";
|
|
3
|
+
/**
|
|
4
|
+
* Every child process this CLI starts goes through here.
|
|
5
|
+
*
|
|
6
|
+
* The reason is one bug we shipped twice: Node's `execFile` and `spawnSync`
|
|
7
|
+
* default to a 1 MB output buffer, and a global npm install prints more than
|
|
8
|
+
* that. On overflow Node kills the child and reports an error — after the
|
|
9
|
+
* package is already on disk — so a successful install read as a failure and
|
|
10
|
+
* the run went into a pointless retry (or hung waiting on it). Anything that
|
|
11
|
+
* shells out to a package manager, a vendor installer or an agent's own CLI
|
|
12
|
+
* can exceed 1 MB, so the buffer is raised in one place rather than
|
|
13
|
+
* remembered at 21 call sites.
|
|
14
|
+
*
|
|
15
|
+
* Timeouts live here too: a child with no timeout is a run that can hang for
|
|
16
|
+
* ever. Callers that legitimately need longer pass their own.
|
|
17
|
+
*/
|
|
18
|
+
/** Beyond anything an installer or a client CLI prints. */
|
|
19
|
+
export const MAX_OUTPUT_BYTES = 32 * 1024 * 1024;
|
|
20
|
+
/** Enough for an agent install over a slow link; short of forever. */
|
|
21
|
+
export const DEFAULT_TIMEOUT_MS = 300_000;
|
|
22
|
+
/** For a quick question to a local binary (`--version`, `mcp list`). */
|
|
23
|
+
export const QUICK_TIMEOUT_MS = 20_000;
|
|
24
|
+
const execFileRaw = promisify(execFile);
|
|
25
|
+
/** Run a binary with arguments. Rejects on a non-zero exit, like execFile. */
|
|
26
|
+
export function run(file, args, options = {}) {
|
|
27
|
+
return execFileRaw(file, [...args], {
|
|
28
|
+
timeout: options.timeout ?? DEFAULT_TIMEOUT_MS,
|
|
29
|
+
maxBuffer: MAX_OUTPUT_BYTES,
|
|
30
|
+
env: options.env,
|
|
31
|
+
cwd: options.cwd,
|
|
32
|
+
encoding: "utf8",
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
/** Run a shell command line (pipelines, `a; b`). Never rejects: the caller
|
|
36
|
+
* decides what a non-zero exit means, which is almost never "give up". */
|
|
37
|
+
export async function runShell(command, options = {}) {
|
|
38
|
+
try {
|
|
39
|
+
const { stdout, stderr } = await run("/bin/sh", ["-c", command], options);
|
|
40
|
+
return { ok: true, stdout, stderr, detail: "" };
|
|
41
|
+
}
|
|
42
|
+
catch (e) {
|
|
43
|
+
const err = e;
|
|
44
|
+
return {
|
|
45
|
+
ok: false,
|
|
46
|
+
stdout: err.stdout ?? "",
|
|
47
|
+
stderr: err.stderr ?? "",
|
|
48
|
+
detail: err.message.split("\n")[0],
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
/** Ask a local binary something and wait for the answer. */
|
|
53
|
+
export function runSync(file, args, options = {}) {
|
|
54
|
+
return spawnSync(file, [...args], {
|
|
55
|
+
timeout: options.timeout ?? QUICK_TIMEOUT_MS,
|
|
56
|
+
maxBuffer: MAX_OUTPUT_BYTES,
|
|
57
|
+
env: options.env,
|
|
58
|
+
cwd: options.cwd,
|
|
59
|
+
encoding: "utf8",
|
|
60
|
+
});
|
|
61
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Every HTTP request this CLI makes goes through here.
|
|
3
|
+
*
|
|
4
|
+
* Two things were missing from the bare `fetch` calls this replaces, and both
|
|
5
|
+
* were felt: no timeout, so a stalled connection hung the command with no way
|
|
6
|
+
* out; and no retry, so a single dropped packet surfaced as `Error: fetch
|
|
7
|
+
* failed` with nothing else to go on. Measured against the live gateway on
|
|
8
|
+
* 2026-08-24, roughly one call in three failed that way on a flaky link.
|
|
9
|
+
*
|
|
10
|
+
* This is the HTTP twin of utils/exec.ts, and exists for the same reason: the
|
|
11
|
+
* fix belongs in one place rather than being remembered at every call site.
|
|
12
|
+
*
|
|
13
|
+
* ── What is retried ────────────────────────────────────────────────────────
|
|
14
|
+
*
|
|
15
|
+
* Only requests the caller marks idempotent. That is not the same as "GET":
|
|
16
|
+
* this CLI sends POST for reads (tavily/scholar search, screener, MCP
|
|
17
|
+
* tools/list) *and* for writes (posting a tweet, minting a key, submitting a
|
|
18
|
+
* video job). Retrying the second group posts twice, mints two keys, or bills
|
|
19
|
+
* a second generation, so the default is one attempt and callers opt in.
|
|
20
|
+
*
|
|
21
|
+
* `chat` is deliberately left out of the opt-in even though a completion is
|
|
22
|
+
* semantically a read: it is billed per call, and a response lost after the
|
|
23
|
+
* server already produced it would charge the user twice. Better to let them
|
|
24
|
+
* decide to send it again.
|
|
25
|
+
*
|
|
26
|
+
* The proper fix for that last case is an `Idempotency-Key` header the gateway
|
|
27
|
+
* dedupes on (the Stripe model). That needs server-side support, so it is a
|
|
28
|
+
* cross-repo change for later, not something to fake here.
|
|
29
|
+
*/
|
|
30
|
+
/** 1 initial attempt + 2 retries. At the observed ~1/3 transient failure rate
|
|
31
|
+
* this takes the odds of a command failing from ~33% to ~4%; a fourth attempt
|
|
32
|
+
* only reaches ~1% and makes every genuine failure slower to report. */
|
|
33
|
+
export declare const MAX_ATTEMPTS = 3;
|
|
34
|
+
/** Catalog, health and other /info reads — small payloads from a CDN edge. */
|
|
35
|
+
export declare const INFO_TIMEOUT_MS = 10000;
|
|
36
|
+
/** A normal authenticated API call. */
|
|
37
|
+
export declare const DEFAULT_TIMEOUT_MS = 30000;
|
|
38
|
+
export interface HttpOptions extends RequestInit {
|
|
39
|
+
timeoutMs?: number;
|
|
40
|
+
/** Opt in to retries. See the note above: this must mean "safe to send
|
|
41
|
+
* twice", not merely "does not modify anything". */
|
|
42
|
+
idempotent?: boolean;
|
|
43
|
+
/** Overrides MAX_ATTEMPTS; ignored unless idempotent. */
|
|
44
|
+
maxAttempts?: number;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Fetch with a timeout, and retries when the caller says it is safe.
|
|
48
|
+
*
|
|
49
|
+
* Throws the last error if every attempt failed at the network level; returns
|
|
50
|
+
* the last Response if the failures were HTTP statuses, so callers keep their
|
|
51
|
+
* existing error handling for 4xx/5xx bodies.
|
|
52
|
+
*/
|
|
53
|
+
export declare function httpFetch(url: string, options?: HttpOptions): Promise<Response>;
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Every HTTP request this CLI makes goes through here.
|
|
3
|
+
*
|
|
4
|
+
* Two things were missing from the bare `fetch` calls this replaces, and both
|
|
5
|
+
* were felt: no timeout, so a stalled connection hung the command with no way
|
|
6
|
+
* out; and no retry, so a single dropped packet surfaced as `Error: fetch
|
|
7
|
+
* failed` with nothing else to go on. Measured against the live gateway on
|
|
8
|
+
* 2026-08-24, roughly one call in three failed that way on a flaky link.
|
|
9
|
+
*
|
|
10
|
+
* This is the HTTP twin of utils/exec.ts, and exists for the same reason: the
|
|
11
|
+
* fix belongs in one place rather than being remembered at every call site.
|
|
12
|
+
*
|
|
13
|
+
* ── What is retried ────────────────────────────────────────────────────────
|
|
14
|
+
*
|
|
15
|
+
* Only requests the caller marks idempotent. That is not the same as "GET":
|
|
16
|
+
* this CLI sends POST for reads (tavily/scholar search, screener, MCP
|
|
17
|
+
* tools/list) *and* for writes (posting a tweet, minting a key, submitting a
|
|
18
|
+
* video job). Retrying the second group posts twice, mints two keys, or bills
|
|
19
|
+
* a second generation, so the default is one attempt and callers opt in.
|
|
20
|
+
*
|
|
21
|
+
* `chat` is deliberately left out of the opt-in even though a completion is
|
|
22
|
+
* semantically a read: it is billed per call, and a response lost after the
|
|
23
|
+
* server already produced it would charge the user twice. Better to let them
|
|
24
|
+
* decide to send it again.
|
|
25
|
+
*
|
|
26
|
+
* The proper fix for that last case is an `Idempotency-Key` header the gateway
|
|
27
|
+
* dedupes on (the Stripe model). That needs server-side support, so it is a
|
|
28
|
+
* cross-repo change for later, not something to fake here.
|
|
29
|
+
*/
|
|
30
|
+
/** 1 initial attempt + 2 retries. At the observed ~1/3 transient failure rate
|
|
31
|
+
* this takes the odds of a command failing from ~33% to ~4%; a fourth attempt
|
|
32
|
+
* only reaches ~1% and makes every genuine failure slower to report. */
|
|
33
|
+
export const MAX_ATTEMPTS = 3;
|
|
34
|
+
/** Catalog, health and other /info reads — small payloads from a CDN edge. */
|
|
35
|
+
export const INFO_TIMEOUT_MS = 10_000;
|
|
36
|
+
/** A normal authenticated API call. */
|
|
37
|
+
export const DEFAULT_TIMEOUT_MS = 30_000;
|
|
38
|
+
const BASE_BACKOFF_MS = 300;
|
|
39
|
+
/** Agents fan out many calls at once; identical backoff would resynchronise
|
|
40
|
+
* them into a second wave against a gateway that is already struggling. */
|
|
41
|
+
const JITTER = 0.3;
|
|
42
|
+
/** Transient by nature: the request never reached a decision, or the server
|
|
43
|
+
* said it could not decide right now. 4xx is excluded on purpose — a bad key
|
|
44
|
+
* or a malformed parameter fails the same way every time. */
|
|
45
|
+
const RETRYABLE_STATUS = new Set([408, 429, 500, 502, 503, 504]);
|
|
46
|
+
function backoffMs(attempt) {
|
|
47
|
+
const base = BASE_BACKOFF_MS * Math.pow(3, attempt - 1); // 300, 900
|
|
48
|
+
const spread = base * JITTER;
|
|
49
|
+
return Math.round(base - spread + Math.random() * spread * 2);
|
|
50
|
+
}
|
|
51
|
+
/** Honour the server's own instruction when it gives one — it knows how
|
|
52
|
+
* overloaded it is far better than a fixed schedule does. Both the seconds
|
|
53
|
+
* and the HTTP-date forms are allowed by RFC 9110. */
|
|
54
|
+
function retryAfterMs(res) {
|
|
55
|
+
const raw = res.headers.get("retry-after");
|
|
56
|
+
if (!raw)
|
|
57
|
+
return undefined;
|
|
58
|
+
const seconds = Number(raw);
|
|
59
|
+
if (Number.isFinite(seconds))
|
|
60
|
+
return Math.max(0, seconds * 1000);
|
|
61
|
+
const date = Date.parse(raw);
|
|
62
|
+
return Number.isNaN(date) ? undefined : Math.max(0, date - Date.now());
|
|
63
|
+
}
|
|
64
|
+
/** Never wait longer than this on a Retry-After — a gateway asking for an hour
|
|
65
|
+
* should end the command, not silently park it. */
|
|
66
|
+
const MAX_RETRY_AFTER_MS = 30_000;
|
|
67
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
68
|
+
/**
|
|
69
|
+
* Fetch with a timeout, and retries when the caller says it is safe.
|
|
70
|
+
*
|
|
71
|
+
* Throws the last error if every attempt failed at the network level; returns
|
|
72
|
+
* the last Response if the failures were HTTP statuses, so callers keep their
|
|
73
|
+
* existing error handling for 4xx/5xx bodies.
|
|
74
|
+
*/
|
|
75
|
+
export async function httpFetch(url, options = {}) {
|
|
76
|
+
const { timeoutMs = DEFAULT_TIMEOUT_MS, idempotent = false, maxAttempts, ...init } = options;
|
|
77
|
+
const attempts = idempotent ? (maxAttempts ?? MAX_ATTEMPTS) : 1;
|
|
78
|
+
let lastError;
|
|
79
|
+
for (let attempt = 1; attempt <= attempts; attempt++) {
|
|
80
|
+
try {
|
|
81
|
+
const res = await fetch(url, { ...init, signal: AbortSignal.timeout(timeoutMs) });
|
|
82
|
+
if (attempt === attempts || !RETRYABLE_STATUS.has(res.status))
|
|
83
|
+
return res;
|
|
84
|
+
const after = retryAfterMs(res);
|
|
85
|
+
if (after !== undefined && after > MAX_RETRY_AFTER_MS)
|
|
86
|
+
return res;
|
|
87
|
+
await sleep(after ?? backoffMs(attempt));
|
|
88
|
+
}
|
|
89
|
+
catch (e) {
|
|
90
|
+
lastError = e;
|
|
91
|
+
if (attempt === attempts)
|
|
92
|
+
break;
|
|
93
|
+
await sleep(backoffMs(attempt));
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
throw asHelpfulError(lastError, url, attempts);
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* `TypeError: fetch failed` is what undici reports for DNS failures, refused
|
|
100
|
+
* connections, TLS errors and resets alike, and on its own it tells the user
|
|
101
|
+
* nothing they can act on. Name the host, say how many attempts were made,
|
|
102
|
+
* and keep the underlying cause attached.
|
|
103
|
+
*/
|
|
104
|
+
function asHelpfulError(cause, url, attempts) {
|
|
105
|
+
const host = (() => {
|
|
106
|
+
try {
|
|
107
|
+
return new URL(url).host;
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
return url;
|
|
111
|
+
}
|
|
112
|
+
})();
|
|
113
|
+
const err = cause;
|
|
114
|
+
const detail = err?.name === "TimeoutError" || err?.name === "AbortError"
|
|
115
|
+
? "timed out"
|
|
116
|
+
: err?.cause?.code
|
|
117
|
+
? `${err.cause.code}`
|
|
118
|
+
: (err?.message ?? "network error");
|
|
119
|
+
const tries = attempts > 1 ? ` after ${attempts} attempts` : "";
|
|
120
|
+
const wrapped = new Error(`Could not reach ${host}${tries} — ${detail}`);
|
|
121
|
+
if (cause instanceof Error)
|
|
122
|
+
wrapped.cause = cause;
|
|
123
|
+
return wrapped;
|
|
124
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How this copy of the CLI got onto the machine.
|
|
3
|
+
*
|
|
4
|
+
* `aisa update` used to run `npm install -g @aisa-one/cli@latest` no matter
|
|
5
|
+
* how the caller had installed it. For a pnpm or bun user that does not
|
|
6
|
+
* update anything — it installs a *second* copy under npm's prefix, which
|
|
7
|
+
* then shadows theirs or is shadowed by it depending on PATH order, and the
|
|
8
|
+
* version they see afterwards is whichever won. For someone running through
|
|
9
|
+
* `npx` it is stranger still: npx already resolves the latest published
|
|
10
|
+
* version on every invocation, so there was nothing to update in the first
|
|
11
|
+
* place and the command silently installed a global they never asked for.
|
|
12
|
+
*
|
|
13
|
+
* There is no manifest recording the installer, so this reads the one thing
|
|
14
|
+
* that does remember: where the running file sits on disk. Each package
|
|
15
|
+
* manager has its own place, and none of them look alike.
|
|
16
|
+
*/
|
|
17
|
+
export type InstallKind = "npx" | "npm" | "pnpm" | "bun" | "yarn" | "source" | "unknown";
|
|
18
|
+
export interface InstallMethod {
|
|
19
|
+
kind: InstallKind;
|
|
20
|
+
/** The path the verdict was read from — worth printing when it is unknown. */
|
|
21
|
+
path: string;
|
|
22
|
+
/**
|
|
23
|
+
* What would update it, or undefined when nothing would: npx is current by
|
|
24
|
+
* definition, and a working copy is updated with git.
|
|
25
|
+
*/
|
|
26
|
+
command?: string;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Read the verdict off a path.
|
|
30
|
+
*
|
|
31
|
+
* Separate from the caller so it can be tested against the shapes each
|
|
32
|
+
* manager produces without installing five package managers.
|
|
33
|
+
*/
|
|
34
|
+
export declare function classifyPath(p: string): InstallKind;
|
|
35
|
+
/** Where this process is running from, and what would update it. */
|
|
36
|
+
export declare function detectInstall(from?: string): InstallMethod;
|