@jiayunxie/aerial 0.2.5 → 0.2.7

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.
@@ -1,7 +1,7 @@
1
1
  import fs from "node:fs";
2
2
  import { loadConfig } from "../shared/config.js";
3
3
  import { logEvent } from "../shared/log.js";
4
- import { atomicWriteFile } from "../shared/file-utils.js";
4
+ import { atomicWriteFile } from "../shared/utils.js";
5
5
  import {
6
6
  SERVICE_LABEL,
7
7
  WIN_TASK_NAME,
@@ -4,10 +4,10 @@ import path from "node:path";
4
4
  import { parse as parseToml } from "smol-toml";
5
5
  import { ensureApiKey, loadConfig, saveConfig } from "../shared/config.js";
6
6
  import { logEvent } from "../shared/log.js";
7
- import { atomicWriteFile } from "../shared/file-utils.js";
8
- import { assertValidEffort, normalizeEffort } from "../shared/effort.js";
9
- import { backupIfExists, backupPathsFor } from "./backup.js";
10
- import { setTomlRootString, upsertTomlSection } from "./toml.js";
7
+ import { atomicWriteFile } from "../shared/utils.js";
8
+ import { assertValidCodexEffort, assertValidEffort, normalizeCodexEffort, normalizeEffort } from "../shared/effort.js";
9
+ import { backupIfExists, backupPathsFor } from "./restore.js";
10
+ import { setTomlRootString, upsertTomlSection, removeTomlSection } from "./toml.js";
11
11
 
12
12
  const DEFAULT_CODEX_AUTH = Object.freeze({
13
13
  command: "aerial",
@@ -38,7 +38,7 @@ function claudeEnvForAerial(currentEnv, config) {
38
38
  }
39
39
 
40
40
  export function setupCodex({ model, effort, authCommand = DEFAULT_CODEX_AUTH } = {}) {
41
- const normalizedEffort = effort === undefined ? undefined : assertValidEffort(effort);
41
+ const normalizedEffort = effort === undefined ? undefined : assertValidCodexEffort(effort);
42
42
  ensureApiKey();
43
43
  const config = loadConfig();
44
44
  const selectedModel = model || config.defaultModel;
@@ -51,6 +51,7 @@ export function setupCodex({ model, effort, authCommand = DEFAULT_CODEX_AUTH } =
51
51
  let content = fs.existsSync(file) ? fs.readFileSync(file, "utf8") : "";
52
52
  content = setTomlRootString(content, "model_provider", "aerial");
53
53
  content = setTomlRootString(content, "model", selectedModel);
54
+ if (normalizedEffort) content = setTomlRootString(content, "model_reasoning_effort", normalizedEffort);
54
55
  content = upsertTomlSection(content, "model_providers.aerial", {
55
56
  name: "Aerial",
56
57
  base_url: `http://${config.host}:${config.port}/v1`,
@@ -62,13 +63,8 @@ export function setupCodex({ model, effort, authCommand = DEFAULT_CODEX_AUTH } =
62
63
  timeout_ms: authCommand.timeout_ms || DEFAULT_CODEX_AUTH.timeout_ms,
63
64
  refresh_interval_ms: authCommand.refresh_interval_ms ?? DEFAULT_CODEX_AUTH.refresh_interval_ms
64
65
  });
65
- const profileValues = { model_provider: "aerial", model: selectedModel };
66
- if (normalizedEffort) profileValues.model_reasoning_effort = normalizedEffort;
67
- content = upsertTomlSection(content, "profiles.aerial", profileValues);
66
+ content = removeTomlSection(content, "profiles.aerial");
68
67
  atomicWriteFile(file, content);
69
- if (normalizedEffort && config.defaultEffort !== normalizedEffort) {
70
- saveConfig({ ...config, defaultEffort: normalizedEffort });
71
- }
72
68
  logEvent("setup_write", { target: "codex", file, backup, auth: "command", effort: normalizedEffort });
73
69
  return { file, backup, model: selectedModel, effort: normalizedEffort, auth: { type: "command", command: authCommand.command, args: authCommand.args || [] } };
74
70
  }
@@ -146,9 +142,23 @@ export function codexStatus() {
146
142
  const baseUrl = typeof doc?.model_providers?.aerial?.base_url === "string"
147
143
  ? doc.model_providers.aerial.base_url
148
144
  : undefined;
149
- const profileEffort = doc?.profiles?.aerial?.model_reasoning_effort;
150
- const effort = typeof profileEffort === "string" ? (normalizeEffort(profileEffort) || "missing") : "missing";
151
- return { target: "codex", state, file, backups, model, baseUrl, effort };
145
+ const rootEffort = doc?.model_reasoning_effort;
146
+ const effort = typeof rootEffort === "string" ? (normalizeCodexEffort(rootEffort) || "missing") : "missing";
147
+ const legacyProfile = doc?.profiles?.aerial && typeof doc.profiles.aerial === "object";
148
+ const legacyProfileLooksAerial = legacyProfile
149
+ && (doc.profiles.aerial.model_provider === "aerial"
150
+ || typeof doc.profiles.aerial.model_reasoning_effort === "string");
151
+ const needsMigration = legacyProfile && (state !== "not-aerial" || legacyProfileLooksAerial);
152
+ return {
153
+ target: "codex",
154
+ state,
155
+ file,
156
+ backups,
157
+ model,
158
+ baseUrl,
159
+ effort,
160
+ ...(needsMigration ? { migration: "run aerial setup codex" } : {})
161
+ };
152
162
  }
153
163
 
154
164
  function claudeStateFromDoc(doc, expectedBaseUrl) {
@@ -1,4 +1,27 @@
1
- export { findLatestBackup } from "./backup.js";
1
+ import fs from "node:fs";
2
+ import { loadConfig } from "../shared/config.js";
3
+ import { apiKeyPath, githubTokenPath } from "../shared/paths.js";
4
+ import { gitHubTokenSource } from "../shared/auth.js";
5
+ import { CLIENTS, setupCodex, setupClaude, codexStatus, claudeStatus } from "./clients.js";
6
+
2
7
  export { setupCodex, setupClaude, codexStatus, claudeStatus } from "./clients.js";
3
- export { setupStatus } from "./status.js";
4
- export { restoreClient, restoreAllClients } from "./restore.js";
8
+ export { findLatestBackup, restoreClient, restoreAllClients } from "./restore.js";
9
+
10
+ export function setupStatus() {
11
+ const config = loadConfig();
12
+ const apiKeyFile = apiKeyPath();
13
+ const githubTokenFile = githubTokenPath();
14
+ return {
15
+ schema: "aerial.setup-status.v1",
16
+ platform: process.platform,
17
+ config: { host: config.host, port: config.port },
18
+ auth: {
19
+ api_key: { file: apiKeyFile, exists: fs.existsSync(apiKeyFile) },
20
+ github_token: (() => {
21
+ const source = gitHubTokenSource();
22
+ return { file: githubTokenFile, exists: source !== "missing", source };
23
+ })()
24
+ },
25
+ clients: Object.fromEntries(Object.entries(CLIENTS).map(([target, client]) => [target, client.status()]))
26
+ };
27
+ }
@@ -1,9 +1,45 @@
1
1
  import fs from "node:fs";
2
+ import path from "node:path";
2
3
  import { logEvent } from "../shared/log.js";
3
- import { atomicWriteFile } from "../shared/file-utils.js";
4
- import { findLatestBackup } from "./backup.js";
4
+ import { atomicWriteFile } from "../shared/utils.js";
5
5
  import { CLIENTS } from "./clients.js";
6
6
 
7
+ const BACKUP_PREFIX = ".aerial-backup-";
8
+ const ISO_STAMP_RE = /^\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3}Z$/;
9
+
10
+ export function backupIfExists(file) {
11
+ if (!fs.existsSync(file)) return undefined;
12
+ const backup = `${file}.aerial-backup-${new Date().toISOString().replace(/[:.]/g, "-")}`;
13
+ fs.copyFileSync(file, backup);
14
+ return backup;
15
+ }
16
+
17
+ function listBackups(file) {
18
+ const dir = path.dirname(file);
19
+ const base = path.basename(file);
20
+ if (!fs.existsSync(dir)) return [];
21
+ const prefix = `${base}${BACKUP_PREFIX}`;
22
+ const entries = fs.readdirSync(dir);
23
+ const matches = [];
24
+ for (const entry of entries) {
25
+ if (!entry.startsWith(prefix)) continue;
26
+ const stamp = entry.slice(prefix.length);
27
+ if (!ISO_STAMP_RE.test(stamp)) continue;
28
+ matches.push({ name: entry, path: path.join(dir, entry), stamp });
29
+ }
30
+ matches.sort((a, b) => (a.stamp < b.stamp ? -1 : a.stamp > b.stamp ? 1 : 0));
31
+ return matches;
32
+ }
33
+
34
+ export function findLatestBackup(file) {
35
+ const all = listBackups(file);
36
+ return all.length ? all[all.length - 1] : undefined;
37
+ }
38
+
39
+ export function backupPathsFor(file) {
40
+ return listBackups(file).map((entry) => entry.path);
41
+ }
42
+
7
43
  const PRE_RESTORE_PREFIX = ".aerial-pre-restore-";
8
44
 
9
45
  function clientDescriptor(target) {
package/src/setup/toml.js CHANGED
@@ -1,41 +1,92 @@
1
- function tomlValue(value) {
2
- if (Array.isArray(value)) return `[${value.map(tomlValue).join(", ")}]`;
3
- if (typeof value === "number" || typeof value === "boolean") return String(value);
4
- return JSON.stringify(String(value));
5
- }
1
+ import { stringify as stringifyToml } from "smol-toml";
6
2
 
7
3
  function escapeRegExp(value) {
8
4
  return String(value).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
9
5
  }
10
6
 
7
+ function tomlScalar(value) {
8
+ return stringifyToml({ value }).trim().replace(/^value\s*=\s*/, "");
9
+ }
10
+
11
+ function serializeBlock(values) {
12
+ const text = stringifyToml(values).trimEnd();
13
+ return text ? text.split("\n") : [];
14
+ }
15
+
16
+ function sectionName(line) {
17
+ const match = /^\s*\[\[?(.+?)\]?\]\s*(?:#.*)?$/.exec(line);
18
+ if (!match) return undefined;
19
+ const inner = match[1].trim();
20
+ if (!inner) return undefined;
21
+ const segment = `(?:"[^"]*"|'[^']*'|[A-Za-z0-9_-]+)`;
22
+ return new RegExp(`^${segment}(?:\\s*\\.\\s*${segment})*$`).test(inner) ? inner : undefined;
23
+ }
24
+
25
+ function isSectionHeader(line) {
26
+ return sectionName(line) !== undefined;
27
+ }
28
+
29
+ function isSectionOrChild(line, section) {
30
+ const name = sectionName(line);
31
+ return name === section || name?.startsWith(`${section}.`);
32
+ }
33
+
11
34
  export function setTomlRootString(content, key, value) {
12
- const line = `${key} = ${tomlValue(value)}`;
35
+ const line = `${key} = ${tomlScalar(value)}`;
13
36
  const source = content.split(/\r?\n/);
14
- const firstSection = source.findIndex((sourceLine) => /^\s*\[.*\]\s*(?:#.*)?$/.test(sourceLine));
37
+ const firstSection = source.findIndex(isSectionHeader);
15
38
  const rootLines = firstSection === -1 ? source : source.slice(0, firstSection);
16
39
  const restLines = firstSection === -1 ? [] : source.slice(firstSection);
17
- const root = rootLines.join("\n");
18
- const rest = restLines.join("\n");
19
- const re = new RegExp(`^\\s*${escapeRegExp(key)}\\s*=.*$`, "m");
20
- const nextRoot = re.test(root) ? root.replace(re, line) : `${root.trimEnd()}${root.trimEnd() ? "\n" : ""}${line}`;
21
- if (!rest) return `${nextRoot.trimEnd()}\n`;
22
- return `${nextRoot.trimEnd()}\n${rest}`;
40
+ const re = new RegExp(`^\\s*${escapeRegExp(key)}\\s*=`);
41
+ const existing = rootLines.findIndex((rootLine) => re.test(rootLine));
42
+ if (existing === -1) {
43
+ let insertAt = rootLines.length;
44
+ while (insertAt > 0 && !rootLines[insertAt - 1].trim()) insertAt -= 1;
45
+ rootLines.splice(insertAt, 0, line);
46
+ } else {
47
+ rootLines[existing] = line;
48
+ }
49
+ const merged = [...rootLines, ...restLines].join("\n");
50
+ return `${merged.trimEnd()}\n`;
23
51
  }
24
52
 
25
53
  export function upsertTomlSection(content, section, values) {
26
54
  const heading = `[${section}]`;
27
- const lines = Object.entries(values).map(([key, value]) => `${key} = ${tomlValue(value)}`).join("\n");
28
- const block = `${heading}\n${lines}\n`;
55
+ const block = [heading, ...serializeBlock(values)];
29
56
  const source = content.split(/\r?\n/);
30
- const start = source.findIndex((line) => line.trim() === heading);
31
- if (start === -1) return `${content.trimEnd()}\n\n${block}`;
57
+ const start = source.findIndex((line) => sectionName(line) === section);
58
+ if (start === -1) {
59
+ const base = content.trimEnd();
60
+ return `${base ? `${base}\n\n` : ""}${block.join("\n")}\n`;
61
+ }
32
62
  let end = source.length;
33
63
  for (let i = start + 1; i < source.length; i += 1) {
34
- if (/^\s*\[.*\]\s*$/.test(source[i])) {
64
+ if (isSectionHeader(source[i])) {
35
65
  end = i;
36
66
  break;
37
67
  }
38
68
  }
39
- source.splice(start, end - start, ...block.trimEnd().split("\n"));
69
+ while (end > start + 1 && !source[end - 1].trim()) end -= 1;
70
+ source.splice(start, end - start, ...block);
40
71
  return `${source.join("\n").trimEnd()}\n`;
41
72
  }
73
+
74
+ export function removeTomlSection(content, section) {
75
+ const source = content.split(/\r?\n/);
76
+ const kept = [];
77
+ let removed = false;
78
+ for (let i = 0; i < source.length;) {
79
+ if (isSectionOrChild(source[i], section)) {
80
+ removed = true;
81
+ i += 1;
82
+ while (i < source.length && !isSectionHeader(source[i])) i += 1;
83
+ while (i < source.length && !source[i].trim()) i += 1;
84
+ } else {
85
+ kept.push(source[i]);
86
+ i += 1;
87
+ }
88
+ }
89
+ if (!removed) return content.trim() ? `${content.trimEnd()}\n` : "";
90
+ const merged = kept.join("\n").trim();
91
+ return merged ? `${merged}\n` : "";
92
+ }
@@ -1,15 +1,29 @@
1
1
  export const EFFORT_VALUES = Object.freeze(["low", "medium", "high", "xhigh"]);
2
+ export const CODEX_EFFORT_VALUES = Object.freeze(["minimal", "low", "medium", "high", "xhigh", "max", "ultra"]);
2
3
  export const DEFAULT_EFFORT = "medium";
3
4
 
4
- export function normalizeEffort(value) {
5
+ function lower(value) {
5
6
  if (value === undefined || value === null) return undefined;
6
7
  const trimmed = String(value).trim().toLowerCase();
8
+ return trimmed || undefined;
9
+ }
10
+
11
+ export function normalizeEffort(value) {
12
+ const trimmed = lower(value);
7
13
  if (!trimmed) return undefined;
8
14
  if (trimmed === "max") return "xhigh";
9
15
  if (EFFORT_VALUES.includes(trimmed)) return trimmed;
10
16
  return undefined;
11
17
  }
12
18
 
19
+ export function normalizeCodexEffort(value) {
20
+ const trimmed = lower(value);
21
+ if (!trimmed) return undefined;
22
+ if (trimmed === "none") return "minimal";
23
+ if (CODEX_EFFORT_VALUES.includes(trimmed)) return trimmed;
24
+ return undefined;
25
+ }
26
+
13
27
  export function assertValidEffort(raw) {
14
28
  const normalized = normalizeEffort(raw);
15
29
  if (!normalized) {
@@ -17,3 +31,58 @@ export function assertValidEffort(raw) {
17
31
  }
18
32
  return normalized;
19
33
  }
34
+
35
+ export function assertValidCodexEffort(raw) {
36
+ const normalized = normalizeCodexEffort(raw);
37
+ if (!normalized) {
38
+ throw new Error(`Invalid --effort ${JSON.stringify(raw)} for Codex. Allowed: ${CODEX_EFFORT_VALUES.join(", ")} (or alias 'none' for minimal).`);
39
+ }
40
+ return normalized;
41
+ }
42
+
43
+ function codexCatalogEfforts(values) {
44
+ if (!Array.isArray(values)) return [];
45
+ const seen = new Set();
46
+ const efforts = [];
47
+ for (const value of values) {
48
+ const wireEffort = lower(value);
49
+ const resolvedEffort = normalizeCodexEffort(wireEffort);
50
+ if (!resolvedEffort || seen.has(resolvedEffort)) continue;
51
+ seen.add(resolvedEffort);
52
+ efforts.push({ resolvedEffort, wireEffort, rank: CODEX_EFFORT_VALUES.indexOf(resolvedEffort) });
53
+ }
54
+ return efforts.sort((a, b) => a.rank - b.rank);
55
+ }
56
+
57
+ export function resolveCodexEffort(requested, supportedEfforts) {
58
+ const requestedEffort = normalizeCodexEffort(requested);
59
+ if (!requestedEffort) return undefined;
60
+ const requestedRank = CODEX_EFFORT_VALUES.indexOf(requestedEffort);
61
+ const supported = codexCatalogEfforts(supportedEfforts);
62
+
63
+ if (supported.length) {
64
+ const exact = supported.find((entry) => entry.resolvedEffort === requestedEffort);
65
+ const lowerOrEqual = supported.filter((entry) => entry.rank <= requestedRank);
66
+ const selected = exact || lowerOrEqual.at(-1) || supported[0];
67
+ let reason = "exact";
68
+ if (selected.resolvedEffort !== requestedEffort) {
69
+ reason = selected.rank < requestedRank ? "downgraded" : "nearest_supported";
70
+ } else if (selected.wireEffort !== requestedEffort) {
71
+ reason = "alias";
72
+ }
73
+ return {
74
+ requestedEffort,
75
+ resolvedEffort: selected.resolvedEffort,
76
+ wireEffort: selected.wireEffort,
77
+ reason
78
+ };
79
+ }
80
+
81
+ if (requestedEffort === "minimal") {
82
+ return { requestedEffort, resolvedEffort: "minimal", wireEffort: "none", reason: "alias" };
83
+ }
84
+ if (requestedEffort === "ultra") {
85
+ return { requestedEffort, resolvedEffort: "max", wireEffort: "max", reason: "fallback" };
86
+ }
87
+ return { requestedEffort, resolvedEffort: requestedEffort, wireEffort: requestedEffort, reason: "exact" };
88
+ }
@@ -1,7 +1,7 @@
1
1
  import os from "node:os";
2
2
  import path from "node:path";
3
3
  import fs from "node:fs";
4
- import { atomicWriteFile } from "./file-utils.js";
4
+ import { atomicWriteFile } from "./utils.js";
5
5
 
6
6
  export function configDir() {
7
7
  if (process.env.AERIAL_CONFIG_DIR) return process.env.AERIAL_CONFIG_DIR;
@@ -1,6 +1,25 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
3
 
4
+ export function parseNumberChoice(value, { max, defaultIndex = 0, oneBased = false } = {}) {
5
+ const trimmed = String(value || "").trim();
6
+ if (!trimmed) return defaultIndex;
7
+ if (!/^\d+$/.test(trimmed)) return undefined;
8
+ const n = Number(trimmed);
9
+ if (n < 1 || n > max) return undefined;
10
+ return oneBased ? n : n - 1;
11
+ }
12
+
13
+ export async function readJsonSafely(response) {
14
+ const text = await response.text();
15
+ if (!text) return {};
16
+ try {
17
+ return JSON.parse(text);
18
+ } catch {
19
+ return { raw: text };
20
+ }
21
+ }
22
+
4
23
  function ensureParentDir(file) {
5
24
  fs.mkdirSync(path.dirname(file), { recursive: true });
6
25
  }
@@ -1,23 +0,0 @@
1
- export function computeAppStatus(setup, service) {
2
- const githubTokenPresent = setup.auth.github_token.source !== "missing";
3
- const apiKeyPresent = setup.auth.api_key.exists;
4
- const hasAerialClient = setup.clients.codex.state === "aerial" || setup.clients.claude.state === "aerial";
5
- const serviceHealthy = service.supported !== false && service.health?.aerial === true;
6
- const ok = apiKeyPresent && githubTokenPresent && hasAerialClient && serviceHealthy;
7
- const nextSteps = [];
8
- const hints = [];
9
- if (!githubTokenPresent) nextSteps.push("run: aerial login");
10
- if (!hasAerialClient) nextSteps.push("run: aerial setup codex or aerial setup claude");
11
- if (hasAerialClient && !apiKeyPresent) nextSteps.push("run: aerial setup codex or aerial setup claude to recreate the local Aerial key");
12
- if (service.supported !== false && !service.service?.loaded) {
13
- if (serviceHealthy) {
14
- hints.push("Aerial is running in the foreground but no background service is installed; run `aerial service install` so it starts on reboot.");
15
- } else {
16
- nextSteps.push("run: aerial service install");
17
- }
18
- }
19
- if (setup.auth.github_token.source === "env") {
20
- hints.push("AERIAL_GITHUB_TOKEN is set for this process only; run aerial login without that env var to persist a service-readable login.");
21
- }
22
- return { schema: "aerial.status.v1", ok, setup, service, nextSteps, hints };
23
- }
package/src/cli/args.js DELETED
@@ -1,28 +0,0 @@
1
- export function argValue(args, name) {
2
- const index = args.indexOf(name);
3
- return index >= 0 ? args[index + 1] : undefined;
4
- }
5
-
6
- export function requiredArgValue(args, name) {
7
- const index = args.indexOf(name);
8
- if (index < 0) return undefined;
9
- const value = args[index + 1];
10
- if (value === undefined || value.startsWith("--")) {
11
- throw new Error(`${name} requires a value.`);
12
- }
13
- return value;
14
- }
15
-
16
- export function parseConfigPort(value) {
17
- const text = String(value).trim();
18
- if (!/^\d+$/.test(text)) throw new Error("port must be an integer between 1 and 65535");
19
- const port = Number(text);
20
- if (port < 1 || port > 65535) throw new Error("port must be an integer between 1 and 65535");
21
- return port;
22
- }
23
-
24
- export function parseConfigHost(value) {
25
- const host = String(value).trim().toLowerCase();
26
- if (host === "127.0.0.1" || host === "localhost" || host === "::1") return host;
27
- throw new Error("host must be a loopback address: 127.0.0.1, localhost, or ::1");
28
- }
package/src/cli/help.js DELETED
@@ -1,23 +0,0 @@
1
- export function printHelp() {
2
- console.log(`Aerial local Copilot proxy
3
-
4
- Usage:
5
- aerial --version
6
- aerial login
7
- aerial setup codex [--model <id>] [--effort <low|medium|high|xhigh|max>]
8
- aerial setup claude [--model <id>] [--effort <low|medium|high|xhigh|max>]
9
- aerial service install
10
- aerial status [--json]
11
- aerial proxy status|enable|disable [--json]
12
-
13
- Diagnostics and rollback:
14
- aerial setup status [--json]
15
- aerial setup restore <codex|claude|all> --latest
16
- aerial service status [--json]
17
- aerial disable
18
- aerial doctor
19
- aerial probe [--live] [--json]
20
-
21
- Debug:
22
- aerial start [--host 127.0.0.1] [--port 18181]`);
23
- }
@@ -1,119 +0,0 @@
1
- import { createInterface } from "node:readline/promises";
2
- import { stdin as input, stdout as output } from "node:process";
3
- import { proxyModels } from "../proxy/index.js";
4
- import { readJsonSafely } from "../shared/http-utils.js";
5
- import { modelsForRoute } from "../proxy/model-utils.js";
6
- import { parseNumberChoice } from "../shared/prompt-utils.js";
7
-
8
- const MAX_LISTED_MODELS = 20;
9
- const GPT_VERSION_RE = /^gpt-(\d+)(?:\.(\d+))?/i;
10
- const STABLE_GPT_RE = /^gpt-\d+(?:\.\d+)?$/i;
11
-
12
- export { modelsForRoute } from "../proxy/model-utils.js";
13
-
14
- function gptVersionScore(id) {
15
- const match = GPT_VERSION_RE.exec(id);
16
- if (!match) return undefined;
17
- const major = Number(match[1]);
18
- const minor = match[2] === undefined ? 0 : Number(match[2]);
19
- return major * 1000 + minor;
20
- }
21
-
22
- export function rankModels(choices) {
23
- return [...choices]
24
- .map((choice, index) => ({ choice, index, score: gptVersionScore(choice.id) }))
25
- .sort((a, b) => {
26
- if (a.score !== undefined && b.score !== undefined && a.score !== b.score) return b.score - a.score;
27
- if (a.score !== undefined && b.score === undefined) return -1;
28
- if (a.score === undefined && b.score !== undefined) return 1;
29
- return a.index - b.index;
30
- })
31
- .map((entry) => entry.choice);
32
- }
33
-
34
- export function pickRecommended(choices) {
35
- if (!choices.length) return { recommended: undefined, source: "first_available" };
36
- const ranked = rankModels(choices);
37
- const stable = ranked.filter((c) => STABLE_GPT_RE.test(c.id));
38
- if (stable.length) return { recommended: stable[0].id, source: "recommended_stable", ranked };
39
- return { recommended: ranked[0].id, source: "recommended_fallback", ranked };
40
- }
41
-
42
- export function orderForPrompt(ranked, recommended) {
43
- if (!recommended) return [...ranked];
44
- const found = ranked.find((c) => c.id === recommended);
45
- if (!found) return [...ranked];
46
- return [found, ...ranked.filter((c) => c.id !== recommended)];
47
- }
48
-
49
- export async function discoverModelsForRoute(route) {
50
- const response = await proxyModels(new Request("http://aerial.local/v1/models", { method: "GET" }));
51
- const payload = await readJsonSafely(response);
52
- if (!response.ok) {
53
- const detail = payload.error || payload.raw || JSON.stringify(payload);
54
- throw new Error(`Could not load Copilot models (${response.status}): ${detail}`);
55
- }
56
- const models = Array.isArray(payload.data) ? payload.data : [];
57
- return modelsForRoute(models, route);
58
- }
59
-
60
- export async function chooseSetupModel({ target, route, explicitModel, prompt = input.isTTY }) {
61
- if (explicitModel) return { model: explicitModel, choices: [], source: "explicit" };
62
- let raw;
63
- try {
64
- raw = await discoverModelsForRoute(route);
65
- } catch (err) {
66
- throw new Error(`${err.message}\nRun \`aerial login\` first, or pass \`--model <id>\` if you already know which ${target} model to use.`);
67
- }
68
- if (!raw.length) {
69
- throw new Error(`No Copilot models currently advertise the ${route} route needed by ${target}. Run \`aerial probe\` to inspect the full model list.`);
70
- }
71
- const { recommended, source: recommendedSource, ranked } = pickRecommended(raw);
72
- const choices = ranked;
73
- const promptListed = orderForPrompt(ranked, recommended).slice(0, MAX_LISTED_MODELS);
74
- if (!prompt) return { model: recommended, choices, source: recommendedSource, recommended };
75
-
76
- const rl = createInterface({ input, output });
77
- try {
78
- output.write(`Available ${target} models (${route} route):\n`);
79
- if (recommendedSource === "recommended_fallback") {
80
- output.write(` No stable gpt-N.M model available; recommending ${recommended} as a fallback. Pass --model <id> to override.\n`);
81
- }
82
- for (const [index, choice] of promptListed.entries()) {
83
- const marker = choice.id === recommended ? " (recommended)" : "";
84
- output.write(` ${index + 1}. ${choice.id}${marker}\n`);
85
- }
86
- if (choices.length > MAX_LISTED_MODELS) output.write(` ... ${choices.length - MAX_LISTED_MODELS} more\n`);
87
- while (true) {
88
- const answer = await rl.question(`Choose ${target} model [1-${promptListed.length}, default 1 = ${recommended}]: `);
89
- const selected = parseNumberChoice(answer, { max: promptListed.length, defaultIndex: 1, oneBased: true });
90
- if (selected) return { model: promptListed[selected - 1].id, choices, source: "prompt", displayed: true, recommended };
91
- output.write(`Enter a number from 1 to ${promptListed.length}, or press Enter for 1.\n`);
92
- }
93
- } finally {
94
- rl.close();
95
- }
96
- }
97
-
98
- export function formatModelChoices({ target, route, choices, selectedModel, source, recommended }) {
99
- if (!choices.length) return [];
100
- const lines = [
101
- `Available ${target} models (${route} route):`
102
- ];
103
- for (const [index, choice] of choices.slice(0, MAX_LISTED_MODELS).entries()) {
104
- const markers = [];
105
- if (choice.id === recommended) markers.push("recommended");
106
- if (choice.id === selectedModel) markers.push("selected");
107
- const suffix = markers.length ? ` (${markers.join(", ")})` : "";
108
- lines.push(` ${index + 1}. ${choice.id}${suffix}`);
109
- }
110
- if (choices.length > MAX_LISTED_MODELS) lines.push(` ... ${choices.length - MAX_LISTED_MODELS} more`);
111
- if (source === "first_available" || source === "recommended_stable") {
112
- lines.push(`No interactive terminal detected; selected ${selectedModel}. Pass --model <id> to choose a different model.`);
113
- } else if (source === "recommended_fallback") {
114
- lines.push(`No stable gpt-N.M model available; selected ${selectedModel}. Pass --model <id> to override.`);
115
- } else {
116
- lines.push(`Selected ${target} model: ${selectedModel}`);
117
- }
118
- return lines;
119
- }
@@ -1,21 +0,0 @@
1
- import path from "node:path";
2
- import { fileURLToPath } from "node:url";
3
-
4
- const CLI_ENTRY = path.join(path.dirname(fileURLToPath(import.meta.url)), "index.js");
5
-
6
- export function codexAuthCommand() {
7
- return {
8
- command: process.execPath,
9
- args: [CLI_ENTRY, "key", "print"],
10
- timeout_ms: 5000,
11
- refresh_interval_ms: 0
12
- };
13
- }
14
-
15
- function quoteCommandPart(value) {
16
- return `"${String(value).replace(/"/g, '\\"')}"`;
17
- }
18
-
19
- export function claudeApiKeyHelper() {
20
- return [process.execPath, CLI_ENTRY, "key", "print"].map(quoteCommandPart).join(" ");
21
- }
@@ -1,47 +0,0 @@
1
- import { createInterface } from "node:readline/promises";
2
- import { stdin as input, stdout as output } from "node:process";
3
- import { parseNumberChoice } from "../shared/prompt-utils.js";
4
- import { DEFAULT_EFFORT, EFFORT_VALUES, assertValidEffort } from "../shared/effort.js";
5
-
6
- export { DEFAULT_EFFORT, EFFORT_VALUES, normalizeEffort, assertValidEffort } from "../shared/effort.js";
7
-
8
- export async function chooseSetupEffort({
9
- target,
10
- explicitEffort,
11
- prompt,
12
- input: inputStream = input,
13
- output: outputStream = output
14
- } = {}) {
15
- if (explicitEffort !== undefined) {
16
- return { effort: assertValidEffort(explicitEffort), source: "explicit", displayed: false };
17
- }
18
- const shouldPrompt = prompt === undefined ? Boolean(inputStream.isTTY) : prompt;
19
- if (!shouldPrompt) {
20
- return { effort: DEFAULT_EFFORT, source: "default_non_tty", displayed: false };
21
- }
22
- const defaultIndex = EFFORT_VALUES.indexOf(DEFAULT_EFFORT);
23
- const rl = createInterface({ input: inputStream, output: outputStream });
24
- try {
25
- outputStream.write(`Choose ${target} reasoning effort:\n`);
26
- for (const [index, value] of EFFORT_VALUES.entries()) {
27
- const marker = index === defaultIndex ? " (default)" : "";
28
- outputStream.write(` ${index + 1}. ${value}${marker}\n`);
29
- }
30
- while (true) {
31
- const answer = await rl.question(`Choose effort [1-${EFFORT_VALUES.length}, default ${defaultIndex + 1} = ${DEFAULT_EFFORT}]: `);
32
- const selectedIndex = parseNumberChoice(answer, { max: EFFORT_VALUES.length, defaultIndex });
33
- if (selectedIndex !== undefined) {
34
- return { effort: EFFORT_VALUES[selectedIndex], source: "prompt", displayed: true };
35
- }
36
- outputStream.write(`Enter a number from 1 to ${EFFORT_VALUES.length}, or press Enter for ${defaultIndex + 1}.\n`);
37
- }
38
- } finally {
39
- rl.close();
40
- }
41
- }
42
-
43
- export function formatEffortSelection({ target, effort, source }) {
44
- if (source === "explicit") return `Selected ${target} effort: ${effort}`;
45
- if (source === "prompt") return `Selected ${target} effort: ${effort}`;
46
- return `No interactive terminal detected; selected ${target} effort: ${effort}. Pass --effort <low|medium|high|xhigh|max> to choose a different effort.`;
47
- }