@aexhq/cli 0.36.0 → 0.37.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/README.md CHANGED
@@ -1,12 +1,35 @@
1
- # @aexhq/cli
1
+ # Aex CLI
2
+
3
+ Node.js 22 or newer. The CLI uses `@aexhq/sdk` and the same public Aex HTTP API as
4
+ the dashboard. No dashboard cookies, site credentials or operator credentials are needed.
2
5
 
3
6
  ```sh
4
- npm install --global @aexhq/cli
7
+ npm install -g @aexhq/cli
5
8
  aex login
6
- aex session list
7
- aex session send ses_... "Summarise the repository."
8
- aex session events ses_...
9
+ aex keys create "My application"
10
+ aex keys list
11
+ aex keys rename KEY_ID "New name"
12
+ aex keys revoke KEY_ID
13
+ aex account
14
+ aex billing
15
+ aex usage
16
+ aex docs
17
+ aex logout
9
18
  ```
10
19
 
11
- The CLI stores the pasted API key in `~/.aex/config.json`, requests mode `0600` on POSIX systems,
12
- and uses `https://api.aex.dev` by default.
20
+ `login` opens your browser for Google sign-in or registration. Confirm the account on
21
+ the Aex page, then return to your terminal. The browser must run on the same computer
22
+ as the CLI. `--no-browser` prints the URL instead of opening it. A loopback listener,
23
+ state and S256 PKCE bind a one-use, 60-second code to the initiating CLI. Account
24
+ credentials expire after seven days; run `login` again. `logout` revokes this session.
25
+
26
+ Results are JSON on stdout; errors and login progress go to stderr. A created key's
27
+ token is returned once. Existing keys expose only their metadata and prefix.
28
+ Billing currently reports free preview hosting with customer-paid model usage.
29
+
30
+ The credential file is `~/.aex/account.json` on Unix or `%LOCALAPPDATA%/.aex/account.json`
31
+ on Windows, restricted to the current user (and Windows SYSTEM). `AEX_CONFIG_DIR`
32
+ overrides its directory. For automation, supply `AEX_ACCOUNT_TOKEN`; a workload API
33
+ key cannot manage account credentials. `AEX_API_URL` or `--api-url` changes the API
34
+ origin; stored credentials cannot be reused against a different origin. `--site-url`
35
+ selects the matching login website for a self-hosted installation.
package/package.json CHANGED
@@ -1,39 +1,14 @@
1
1
  {
2
2
  "name": "@aexhq/cli",
3
- "version": "0.36.0",
4
- "description": "Command-line client for Aex sessions",
5
- "license": "Apache-2.0",
6
- "homepage": "https://aex.dev",
7
- "repository": {
8
- "type": "git",
9
- "url": "git+https://github.com/aexhq/aex.git",
10
- "directory": "packages/cli"
11
- },
3
+ "version": "0.37.0",
4
+ "description": "Aex account and API key management from the terminal",
5
+ "license": "MIT",
12
6
  "type": "module",
13
- "engines": {
14
- "node": ">=22.0.0"
15
- },
16
- "publishConfig": {
17
- "access": "public",
18
- "provenance": false,
19
- "tag": "next"
20
- },
21
- "bin": {
22
- "aex": "./dist/index.js"
23
- },
24
- "files": [
25
- "dist",
26
- "README.md"
27
- ],
28
- "scripts": {
29
- "build": "tsc -p tsconfig.json",
30
- "test": "tsc -p tsconfig.json && node --test"
31
- },
32
- "dependencies": {
33
- "@aexhq/sdk": "0.68.0"
34
- },
35
- "devDependencies": {
36
- "@types/node": "^24.0.0",
37
- "typescript": "5.9.3"
38
- }
7
+ "engines": { "node": ">=22" },
8
+ "bin": { "aex": "src/index.mjs" },
9
+ "files": ["src", "README.md"],
10
+ "repository": { "type": "git", "url": "git+https://github.com/aexhq/aex.git", "directory": "packages/cli" },
11
+ "publishConfig": { "access": "public", "tag": "next" },
12
+ "dependencies": { "@aexhq/sdk": "0.70.0" },
13
+ "scripts": { "test": "node --test test/*.test.mjs" }
39
14
  }
package/src/config.mjs ADDED
@@ -0,0 +1,34 @@
1
+ import { mkdir, chmod, writeFile, rename, readFile, rm } from "node:fs/promises";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { execFile } from "node:child_process";
5
+ import { promisify } from "node:util";
6
+ import { randomUUID } from "node:crypto";
7
+
8
+ const directory = () => process.env.AEX_CONFIG_DIR ?? join(process.platform === "win32" ? process.env.LOCALAPPDATA ?? homedir() : homedir(), ".aex");
9
+ export function origin(value) {
10
+ const url = new URL(value);
11
+ if ((url.protocol !== "https:" && !(url.protocol === "http:" && url.hostname === "127.0.0.1")) || url.username || url.password || url.pathname !== "/" || url.search || url.hash) throw new Error("Use an HTTPS origin (or http://127.0.0.1:PORT for local testing).");
12
+ return url.origin;
13
+ }
14
+ export async function saveSession(session) {
15
+ const dir = directory();
16
+ await mkdir(dir, { recursive: true, mode: 0o700 });
17
+ if (process.platform === "win32") {
18
+ const exec = promisify(execFile);
19
+ const { stdout } = await exec("whoami.exe", ["/user", "/fo", "csv", "/nh"], { windowsHide: true });
20
+ const sid = stdout.match(/S-1-5-\d+(?:-\d+)+/)?.[0];
21
+ if (!sid) throw new Error("Cannot determine Windows account for credential permissions.");
22
+ await exec("icacls.exe", [dir, "/inheritance:r", "/grant:r", `*${sid}:(OI)(CI)F`, "*S-1-5-18:(OI)(CI)F"], { windowsHide: true });
23
+ } else await chmod(dir, 0o700);
24
+ const temporary = join(dir, `${randomUUID()}.tmp`);
25
+ try {
26
+ await writeFile(temporary, JSON.stringify(session) + "\n", { mode: 0o600, flag: "wx" });
27
+ await rename(temporary, join(dir, "account.json"));
28
+ } finally { await rm(temporary, { force: true }); }
29
+ }
30
+ export async function readSession() {
31
+ try { return JSON.parse(await readFile(join(directory(), "account.json"), "utf8")); }
32
+ catch (error) { if (error.code === "ENOENT") throw new Error("Sign in first with aex login."); throw error; }
33
+ }
34
+ export async function removeSession() { await rm(join(directory(), "account.json"), { force: true }); }
package/src/index.mjs ADDED
@@ -0,0 +1,72 @@
1
+ #!/usr/bin/env node
2
+ import { parseArgs } from "node:util";
3
+ import { Aex } from "@aexhq/sdk";
4
+ import { login, openBrowser } from "./login.mjs";
5
+ import { origin, readSession, saveSession, removeSession } from "./config.mjs";
6
+
7
+ const help = `Usage: aex <command>
8
+
9
+ login Open browser sign-in and save an account session
10
+ logout Revoke this session and remove local credentials
11
+ keys list List API keys (secrets are never returned)
12
+ keys create <name> Create a key; save the returned token now
13
+ keys rename <id> <name> Rename a key
14
+ keys revoke <id> Revoke a key
15
+ account Show account, limits and billing status
16
+ billing Show billing status
17
+ usage Show current usage
18
+ docs Open the documentation
19
+
20
+ Results are JSON on stdout; login progress and errors go to stderr.
21
+ Options: --no-browser, --api-url <origin>, --site-url <origin>, --help
22
+ Automation: AEX_ACCOUNT_TOKEN and optional AEX_API_URL.
23
+ `;
24
+
25
+ async function main() {
26
+ const { values, positionals } = parseArgs({ allowPositionals: true, options: { help: { type: "boolean", short: "h" }, "no-browser": { type: "boolean" }, "api-url": { type: "string" }, "site-url": { type: "string" } } });
27
+ if (values.help || !positionals.length) { process.stdout.write(help); return; }
28
+ const [command, operation, ...args] = positionals;
29
+ const expected = { "keys list": 0, "keys create": 1, "keys rename": 2, "keys revoke": 1 };
30
+ if (command === "keys" ? expected[`keys ${operation}`] !== args.length : !["login", "logout", "account", "billing", "usage", "docs"].includes(command) || positionals.length !== 1) throw new Error("Unknown command or incorrect arguments. Run aex --help.");
31
+ const explicitApi = values["api-url"] ?? process.env.AEX_API_URL;
32
+ const siteUrl = origin(values["site-url"] ?? "https://aex.dev");
33
+ if (command === "docs") {
34
+ const url = `${siteUrl}/docs`; process.stdout.write(url + "\n");
35
+ if (!values["no-browser"]) await openBrowser(url);
36
+ return;
37
+ }
38
+ if (command === "login") {
39
+ const apiUrl = origin(explicitApi ?? "https://api.aex.dev");
40
+ const controller = new AbortController(), timeout = setTimeout(() => controller.abort(), 600_000);
41
+ const cancel = () => controller.abort(); process.once("SIGINT", cancel);
42
+ try {
43
+ const session = await login({ apiUrl, siteUrl, signal: controller.signal, onReady: async url => {
44
+ process.stderr.write(`Sign in using this browser on this computer:\n${url}\n`);
45
+ if (!values["no-browser"]) await openBrowser(url).catch(() => process.stderr.write("Could not open a browser. Open the URL above to continue.\n"));
46
+ } });
47
+ const client = new Aex({ accountToken: session.token, baseUrl: apiUrl, timeoutMs: 15_000 });
48
+ try { await saveSession({ ...session, apiUrl }); }
49
+ catch (error) { await client.account.logout(); throw error; }
50
+ process.stdout.write(JSON.stringify(await client.account.get(), null, 2) + "\n");
51
+ } finally { clearTimeout(timeout); process.removeListener("SIGINT", cancel); }
52
+ return;
53
+ }
54
+ const session = process.env.AEX_ACCOUNT_TOKEN ? { token: process.env.AEX_ACCOUNT_TOKEN, apiUrl: origin(explicitApi ?? "https://api.aex.dev") } : await readSession();
55
+ const apiUrl = origin(explicitApi ?? session.apiUrl);
56
+ if (apiUrl !== session.apiUrl) throw new Error("Saved credentials belong to a different API origin. Run aex login for this origin.");
57
+ const client = new Aex({ accountToken: session.token, baseUrl: apiUrl, timeoutMs: 15_000 });
58
+ let result;
59
+ if (command === "logout") {
60
+ try { await client.account.logout(); } catch (error) { if (error.status !== 401) throw error; }
61
+ if (!process.env.AEX_ACCOUNT_TOKEN) await removeSession();
62
+ result = { signed_out: true };
63
+ } else if (command === "account") result = await client.account.get();
64
+ else if (command === "billing") result = { billing: (await client.account.get()).billing };
65
+ else if (command === "usage") result = await client.account.usage();
66
+ else if (operation === "list") result = await client.keys.list();
67
+ else if (operation === "create") result = await client.keys.create({ name: args[0] });
68
+ else if (operation === "rename") result = await client.keys.update(args[0], { name: args[1] });
69
+ else { await client.keys.delete(args[0]); result = { revoked: args[0] }; }
70
+ process.stdout.write(JSON.stringify(result, null, 2) + "\n");
71
+ }
72
+ main().catch(error => { process.stderr.write(`aex: ${error.message}\n`); process.exitCode = 1; });
package/src/login.mjs ADDED
@@ -0,0 +1,47 @@
1
+ import { createServer } from "node:http";
2
+ import { createHash, randomBytes } from "node:crypto";
3
+ import { execFile } from "node:child_process";
4
+ import { promisify } from "node:util";
5
+ import { Aex } from "@aexhq/sdk";
6
+
7
+ const exec = promisify(execFile);
8
+ export async function openBrowser(url) {
9
+ const [command, args] = process.platform === "win32"
10
+ ? ["rundll32.exe", ["url.dll,FileProtocolHandler", url]]
11
+ : process.platform === "darwin" ? ["open", [url]] : ["xdg-open", [url]];
12
+ await exec(command, args, { windowsHide: true });
13
+ }
14
+
15
+ export async function login({ apiUrl, siteUrl, onReady, signal = AbortSignal.timeout(600_000) }) {
16
+ const verifier = randomBytes(32).toString("base64url"), state = randomBytes(32).toString("base64url");
17
+ const server = createServer();
18
+ await new Promise((resolve, reject) => { server.once("error", reject); server.listen(0, "127.0.0.1", resolve); });
19
+ const redirect = `http://127.0.0.1:${server.address().port}/callback`;
20
+ const url = new URL("/cli", siteUrl);
21
+ url.search = new URLSearchParams({ redirect_uri: redirect, state, code_challenge: createHash("sha256").update(verifier).digest("base64url") }).toString();
22
+ try {
23
+ return await new Promise((resolve, reject) => {
24
+ let exchanging = false;
25
+ const abort = () => reject(new Error("Login cancelled or timed out. Run aex login again."));
26
+ signal.addEventListener("abort", abort, { once: true });
27
+ server.once("close", () => signal.removeEventListener("abort", abort));
28
+ server.on("request", async (request, response) => {
29
+ response.setHeader("content-type", "text/plain; charset=utf-8");
30
+ response.setHeader("cache-control", "no-store");
31
+ response.setHeader("referrer-policy", "no-referrer");
32
+ const callback = new URL(request.url, redirect);
33
+ if (request.method !== "GET" || request.headers.host !== new URL(redirect).host || callback.pathname !== "/callback" || callback.searchParams.get("state") !== state || !callback.searchParams.get("code") || exchanging) {
34
+ response.writeHead(400).end("Invalid login callback."); return;
35
+ }
36
+ exchanging = true;
37
+ try {
38
+ const session = await Aex.exchangeLogin({ code: callback.searchParams.get("code"), code_verifier: verifier, redirect_uri: redirect }, { baseUrl: apiUrl, timeoutMs: 15_000 });
39
+ response.end("Signed in to Aex. You can close this tab and return to your terminal.");
40
+ resolve(session);
41
+ } catch (error) { response.writeHead(400).end("Login failed. Return to your terminal and try again."); reject(error); }
42
+ });
43
+ if (signal.aborted) abort();
44
+ else Promise.resolve().then(() => onReady(url.toString())).catch(reject);
45
+ });
46
+ } finally { server.closeAllConnections(); await new Promise(resolve => server.close(resolve)); }
47
+ }
package/dist/index.d.ts DELETED
@@ -1,2 +0,0 @@
1
- #!/usr/bin/env node
2
- export {};
package/dist/index.js DELETED
@@ -1,203 +0,0 @@
1
- #!/usr/bin/env node
2
- import { chmod, mkdir, readFile, rename, writeFile } from "node:fs/promises";
3
- import { homedir } from "node:os";
4
- import { dirname, join } from "node:path";
5
- import process from "node:process";
6
- import { Aex, AexError } from "@aexhq/sdk";
7
- const HELP = `Aex — the session backend for AI apps
8
-
9
- Usage:
10
- aex login
11
- aex doctor
12
- aex session list
13
- aex session get <session-id>
14
- aex session send <session-id> <message>
15
- aex session events <session-id>
16
- aex session cancel <session-id>
17
- aex session end <session-id>
18
- aex session delete <session-id>
19
-
20
- The API defaults to https://api.aex.dev.`;
21
- async function main(argv) {
22
- const [command, ...rest] = argv;
23
- if (command === undefined || command === "help" || command === "--help" || command === "-h") {
24
- process.stdout.write(`${HELP}\n`);
25
- return;
26
- }
27
- if (command === "login") {
28
- await login();
29
- return;
30
- }
31
- const aex = await client();
32
- if (command === "doctor") {
33
- await aex.sessions.list();
34
- process.stdout.write("Aex API: ok\n");
35
- return;
36
- }
37
- if (command !== "session")
38
- usage(`Unknown command: ${command}`);
39
- await sessionCommand(aex, rest);
40
- }
41
- async function sessionCommand(aex, argv) {
42
- const [command, id, ...tail] = argv;
43
- switch (command) {
44
- case "list": {
45
- print(await aex.sessions.list());
46
- return;
47
- }
48
- case "get": {
49
- requireId(id, command);
50
- print((await aex.sessions.get(id)).state);
51
- return;
52
- }
53
- case "send": {
54
- requireId(id, command);
55
- const message = tail.join(" ").trim();
56
- if (message === "")
57
- usage("session send requires a message");
58
- const session = await aex.sessions.get(id);
59
- print(await session.send(message));
60
- return;
61
- }
62
- case "events": {
63
- requireId(id, command);
64
- const session = await aex.sessions.get(id);
65
- const controller = new AbortController();
66
- process.once("SIGINT", () => controller.abort());
67
- try {
68
- for await (const event of session.events()) {
69
- process.stdout.write(`${JSON.stringify(event)}\n`);
70
- }
71
- }
72
- catch (error) {
73
- if (!controller.signal.aborted)
74
- throw error;
75
- }
76
- return;
77
- }
78
- case "cancel": {
79
- requireId(id, command);
80
- const session = await aex.sessions.get(id);
81
- await session.cancel();
82
- print(session.state);
83
- return;
84
- }
85
- case "end": {
86
- requireId(id, command);
87
- const session = await aex.sessions.get(id);
88
- print(await session.end());
89
- return;
90
- }
91
- case "delete": {
92
- requireId(id, command);
93
- const session = await aex.sessions.get(id);
94
- await session.delete();
95
- return;
96
- }
97
- default:
98
- usage(command === undefined ? "Missing session command" : `Unknown session command: ${command}`);
99
- }
100
- }
101
- async function client() {
102
- const apiKey = process.env.AEX_API_KEY ?? (await readConfig())?.apiKey;
103
- if (apiKey === undefined || apiKey === "") {
104
- throw new Error("No Aex API key. Run `aex login` first.");
105
- }
106
- return new Aex({
107
- apiKey,
108
- ...(process.env.AEX_BASE_URL === undefined ? {} : { baseUrl: process.env.AEX_BASE_URL }),
109
- });
110
- }
111
- async function login() {
112
- const apiKey = (await readSecret("Paste Aex API key: ")).trim();
113
- if (!/^aex_sk_[A-Za-z0-9]{40,64}$/.test(apiKey)) {
114
- throw new Error("That does not look like an Aex session API key.");
115
- }
116
- const path = configPath();
117
- await mkdir(dirname(path), { recursive: true, mode: 0o700 });
118
- const temporary = `${path}.${process.pid}.tmp`;
119
- await writeFile(temporary, `${JSON.stringify({ apiKey }, null, 2)}\n`, {
120
- encoding: "utf8",
121
- mode: 0o600,
122
- });
123
- await rename(temporary, path);
124
- await chmod(path, 0o600).catch(() => undefined);
125
- process.stdout.write("Saved.\n");
126
- }
127
- async function readConfig() {
128
- try {
129
- const value = JSON.parse(await readFile(configPath(), "utf8"));
130
- return typeof value.apiKey === "string" ? { apiKey: value.apiKey } : undefined;
131
- }
132
- catch (error) {
133
- if (error.code === "ENOENT")
134
- return undefined;
135
- throw error;
136
- }
137
- }
138
- function configPath() {
139
- return process.env.AEX_CONFIG ?? join(homedir(), ".aex", "config.json");
140
- }
141
- async function readSecret(prompt) {
142
- if (!process.stdin.isTTY) {
143
- const chunks = [];
144
- for await (const chunk of process.stdin)
145
- chunks.push(Buffer.from(chunk));
146
- return Buffer.concat(chunks).toString("utf8");
147
- }
148
- process.stdout.write(prompt);
149
- process.stdin.setRawMode(true);
150
- process.stdin.resume();
151
- return new Promise((resolveValue, reject) => {
152
- let value = "";
153
- const cleanup = () => {
154
- process.stdin.off("data", onData);
155
- process.stdin.setRawMode(false);
156
- process.stdin.pause();
157
- process.stdout.write("\n");
158
- };
159
- const onData = (chunk) => {
160
- for (const byte of chunk) {
161
- if (byte === 3) {
162
- cleanup();
163
- reject(new Error("Login cancelled."));
164
- return;
165
- }
166
- if (byte === 13 || byte === 10) {
167
- cleanup();
168
- resolveValue(value);
169
- return;
170
- }
171
- if (byte === 8 || byte === 127) {
172
- if (value.length > 0) {
173
- value = value.slice(0, -1);
174
- process.stdout.write("\b \b");
175
- }
176
- continue;
177
- }
178
- value += String.fromCharCode(byte);
179
- process.stdout.write("*");
180
- }
181
- };
182
- process.stdin.on("data", onData);
183
- });
184
- }
185
- function requireId(id, command) {
186
- if (id === undefined || id === "")
187
- usage(`session ${command} requires a session id`);
188
- }
189
- function print(value) {
190
- process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
191
- }
192
- function usage(message) {
193
- throw new Error(`${message}\n\n${HELP}`);
194
- }
195
- main(process.argv.slice(2)).catch((error) => {
196
- if (error instanceof AexError) {
197
- process.stderr.write(`${error.name}: ${error.message}${error.code === undefined ? "" : ` (${error.code})`}\n`);
198
- }
199
- else {
200
- process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
201
- }
202
- process.exitCode = 1;
203
- });