@jiayunxie/aerial 0.2.5 → 0.2.6
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 +2 -2
- package/docs/usage.md +4 -4
- package/package.json +1 -1
- package/src/cli/commands/config.js +1 -1
- package/src/cli/commands/setup.js +3 -4
- package/src/cli/commands/start.js +1 -1
- package/src/cli/commands/status.js +1 -1
- package/src/cli/commands/{disable.js → teardown.js} +2 -2
- package/src/cli/doctor.js +1 -1
- package/src/cli/helpers.js +92 -0
- package/src/cli/index.js +41 -9
- package/src/cli/output.js +1 -0
- package/src/cli/probe.js +2 -2
- package/src/cli/select.js +340 -0
- package/src/proxy/models.js +21 -0
- package/src/service/platform.js +1 -1
- package/src/setup/clients.js +22 -9
- package/src/setup/index.js +26 -3
- package/src/setup/restore.js +38 -2
- package/src/setup/toml.js +70 -19
- package/src/shared/paths.js +1 -1
- package/src/shared/{file-utils.js → utils.js} +19 -0
- package/src/cli/app-status.js +0 -23
- package/src/cli/args.js +0 -28
- package/src/cli/help.js +0 -23
- package/src/cli/model-selection.js +0 -119
- package/src/cli/runtime-auth.js +0 -21
- package/src/cli/setup-selection.js +0 -47
- package/src/cli/version.js +0 -19
- package/src/proxy/model-utils.js +0 -20
- package/src/setup/backup.js +0 -38
- package/src/setup/status.js +0 -24
- package/src/shared/http-utils.js +0 -9
- package/src/shared/prompt-utils.js +0 -8
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
import readline from "node:readline";
|
|
2
|
+
import { stdin as input, stdout as output } from "node:process";
|
|
3
|
+
import { proxyModels } from "../proxy/index.js";
|
|
4
|
+
import { readJsonSafely } from "../shared/utils.js";
|
|
5
|
+
import { modelsForRoute } from "../proxy/models.js";
|
|
6
|
+
import { codexStatus, claudeStatus } from "../setup/clients.js";
|
|
7
|
+
import { DEFAULT_EFFORT, EFFORT_VALUES, assertValidEffort, normalizeEffort } from "../shared/effort.js";
|
|
8
|
+
|
|
9
|
+
export { modelsForRoute } from "../proxy/models.js";
|
|
10
|
+
export { DEFAULT_EFFORT, EFFORT_VALUES, normalizeEffort, assertValidEffort } from "../shared/effort.js";
|
|
11
|
+
|
|
12
|
+
const MAX_LISTED_MODELS = 20;
|
|
13
|
+
const GPT_VERSION_RE = /^gpt-(\d+)(?:\.(\d+))?/i;
|
|
14
|
+
const STABLE_GPT_RE = /^gpt-\d+(?:\.\d+)?$/i;
|
|
15
|
+
|
|
16
|
+
const CLEAR_LINE = "\x1b[2K";
|
|
17
|
+
const CURSOR_UP = "\x1b[1A";
|
|
18
|
+
const HIDE_CURSOR = "\x1b[?25l";
|
|
19
|
+
const SHOW_CURSOR = "\x1b[?25h";
|
|
20
|
+
|
|
21
|
+
const MAX_VISIBLE = 10;
|
|
22
|
+
|
|
23
|
+
// Color is opt-out via NO_COLOR and gated on a real TTY, so piped output and
|
|
24
|
+
// dumb terminals get clean ASCII instead of escape-code soup.
|
|
25
|
+
const COLOR = process.env.NO_COLOR ? false : Boolean(output.isTTY);
|
|
26
|
+
|
|
27
|
+
const sgr = (code) => (COLOR ? `\x1b[${code}m` : "");
|
|
28
|
+
const RESET = sgr(0);
|
|
29
|
+
const BOLD = sgr(1);
|
|
30
|
+
const DIM = sgr(2);
|
|
31
|
+
const CYAN = sgr(36);
|
|
32
|
+
const BRIGHT_CYAN = sgr(96);
|
|
33
|
+
const GREEN = sgr(32);
|
|
34
|
+
const BLUE = sgr(34);
|
|
35
|
+
const YELLOW = sgr(33);
|
|
36
|
+
|
|
37
|
+
const GLYPHS = COLOR
|
|
38
|
+
? { bar: "▌", on: "●", off: "○", arrow: "↑", darrow: "↓", enter: "⏎" }
|
|
39
|
+
: { bar: "|", on: "*", off: " ", arrow: "^", darrow: "v", enter: "<-" };
|
|
40
|
+
|
|
41
|
+
const TAG_COLORS = {
|
|
42
|
+
recommended: GREEN,
|
|
43
|
+
current: BLUE,
|
|
44
|
+
selected: BLUE,
|
|
45
|
+
default: YELLOW
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
function paintTags(tags) {
|
|
49
|
+
if (!tags.length) return "";
|
|
50
|
+
const painted = tags.map((t) => `${TAG_COLORS[t] || DIM}${t}${RESET}`).join(`${DIM}, ${RESET}`);
|
|
51
|
+
return ` ${painted}`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function helpBar() {
|
|
55
|
+
const key = (s) => `${DIM}${s}${RESET}`;
|
|
56
|
+
return `${key(`${GLYPHS.arrow}${GLYPHS.darrow}`)} move ${key(GLYPHS.enter)} select ${key("q")} cancel`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function viewportStart(cursor, total, maxVisible) {
|
|
60
|
+
if (total <= maxVisible) return 0;
|
|
61
|
+
const half = Math.floor(maxVisible / 2);
|
|
62
|
+
const start = cursor - half;
|
|
63
|
+
if (start < 0) return 0;
|
|
64
|
+
if (start + maxVisible > total) return total - maxVisible;
|
|
65
|
+
return start;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function buildFrame({ title, items, cursor, getTags, maxVisible = MAX_VISIBLE }) {
|
|
69
|
+
const lines = [`${BOLD}${title}${RESET}`, ""];
|
|
70
|
+
const total = items.length;
|
|
71
|
+
const start = viewportStart(cursor, total, maxVisible);
|
|
72
|
+
const end = Math.min(start + maxVisible, total);
|
|
73
|
+
// Overflow hints occupy a fixed line whether or not there is overflow, so the
|
|
74
|
+
// frame height stays constant and incremental redraws never leave stale rows.
|
|
75
|
+
lines.push(start > 0 ? `${DIM} ${GLYPHS.arrow} ${start} more${RESET}` : "");
|
|
76
|
+
for (let index = start; index < end; index++) {
|
|
77
|
+
const item = items[index];
|
|
78
|
+
const active = index === cursor;
|
|
79
|
+
const tagText = paintTags(getTags ? getTags(item, index) : []);
|
|
80
|
+
if (active) {
|
|
81
|
+
const bar = `${BRIGHT_CYAN}${GLYPHS.bar}${RESET}`;
|
|
82
|
+
const dot = `${BRIGHT_CYAN}${GLYPHS.on}${RESET}`;
|
|
83
|
+
lines.push(`${bar} ${dot} ${CYAN}${BOLD}${item.label}${RESET}${tagText}`);
|
|
84
|
+
} else {
|
|
85
|
+
lines.push(` ${DIM}${GLYPHS.off}${RESET} ${item.label}${tagText}`);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
lines.push(end < total ? `${DIM} ${GLYPHS.darrow} ${total - end} more${RESET}` : "");
|
|
89
|
+
lines.push("");
|
|
90
|
+
lines.push(helpBar());
|
|
91
|
+
return lines;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Arrow-key list selector. TTY-only; callers gate on isTTY and supply their own
|
|
96
|
+
* non-interactive path. Number keys jump to that 1-based row and Enter confirms,
|
|
97
|
+
* so tests can drive it by feeding "3\n".
|
|
98
|
+
*/
|
|
99
|
+
async function select({
|
|
100
|
+
title,
|
|
101
|
+
items,
|
|
102
|
+
initialIndex = 0,
|
|
103
|
+
getTags,
|
|
104
|
+
maxVisible = MAX_VISIBLE,
|
|
105
|
+
input: inputStream = input,
|
|
106
|
+
output: outputStream = output
|
|
107
|
+
} = {}) {
|
|
108
|
+
if (!items.length) throw new Error("select requires at least one item");
|
|
109
|
+
let cursor = Math.min(Math.max(initialIndex, 0), items.length - 1);
|
|
110
|
+
let drawn = 0;
|
|
111
|
+
|
|
112
|
+
const render = () => {
|
|
113
|
+
if (drawn) outputStream.write(CURSOR_UP.repeat(drawn - 1) + "\r");
|
|
114
|
+
const lines = buildFrame({ title, items, cursor, getTags, maxVisible });
|
|
115
|
+
outputStream.write(lines.map((line) => CLEAR_LINE + line).join("\n"));
|
|
116
|
+
drawn = lines.length;
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
const wasRaw = Boolean(inputStream.isRaw);
|
|
120
|
+
const wasPaused = inputStream.isPaused();
|
|
121
|
+
readline.emitKeypressEvents(inputStream);
|
|
122
|
+
if (inputStream.isTTY && inputStream.setRawMode) inputStream.setRawMode(true);
|
|
123
|
+
inputStream.resume();
|
|
124
|
+
outputStream.write(HIDE_CURSOR);
|
|
125
|
+
render();
|
|
126
|
+
|
|
127
|
+
return await new Promise((resolve) => {
|
|
128
|
+
const cleanup = () => {
|
|
129
|
+
inputStream.removeListener("keypress", onKeypress);
|
|
130
|
+
if (inputStream.isTTY && inputStream.setRawMode) inputStream.setRawMode(wasRaw);
|
|
131
|
+
// Restore stdin to its prior flow state. emitKeypressEvents/resume left it
|
|
132
|
+
// flowing; if it started paused (the usual CLI case), re-pausing lets the
|
|
133
|
+
// event loop drain so the process exits after the final selection — while
|
|
134
|
+
// still allowing a subsequent select() in the same run to resume cleanly.
|
|
135
|
+
if (wasPaused) inputStream.pause();
|
|
136
|
+
outputStream.write("\n" + SHOW_CURSOR);
|
|
137
|
+
};
|
|
138
|
+
const onKeypress = (str, key) => {
|
|
139
|
+
if (!key) return;
|
|
140
|
+
if (key.name === "up" || key.name === "k") {
|
|
141
|
+
cursor = (cursor - 1 + items.length) % items.length;
|
|
142
|
+
render();
|
|
143
|
+
} else if (key.name === "down" || key.name === "j") {
|
|
144
|
+
cursor = (cursor + 1) % items.length;
|
|
145
|
+
render();
|
|
146
|
+
} else if (key.name === "return" || key.name === "enter") {
|
|
147
|
+
cleanup();
|
|
148
|
+
resolve({ index: cursor, item: items[cursor], cancelled: false });
|
|
149
|
+
} else if (key.name === "q" || key.name === "escape" || (key.ctrl && key.name === "c")) {
|
|
150
|
+
cleanup();
|
|
151
|
+
resolve({ index: cursor, item: items[cursor], cancelled: true });
|
|
152
|
+
} else if (/^[1-9]$/.test(str || "")) {
|
|
153
|
+
const target = Number(str) - 1;
|
|
154
|
+
if (target < items.length) {
|
|
155
|
+
cursor = target;
|
|
156
|
+
render();
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
};
|
|
160
|
+
inputStream.on("keypress", onKeypress);
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function gptVersionScore(id) {
|
|
165
|
+
const match = GPT_VERSION_RE.exec(id);
|
|
166
|
+
if (!match) return undefined;
|
|
167
|
+
const major = Number(match[1]);
|
|
168
|
+
const minor = match[2] === undefined ? 0 : Number(match[2]);
|
|
169
|
+
return major * 1000 + minor;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export function rankModels(choices) {
|
|
173
|
+
return [...choices]
|
|
174
|
+
.map((choice, index) => ({ choice, index, score: gptVersionScore(choice.id) }))
|
|
175
|
+
.sort((a, b) => {
|
|
176
|
+
if (a.score !== undefined && b.score !== undefined && a.score !== b.score) return b.score - a.score;
|
|
177
|
+
if (a.score !== undefined && b.score === undefined) return -1;
|
|
178
|
+
if (a.score === undefined && b.score !== undefined) return 1;
|
|
179
|
+
return a.index - b.index;
|
|
180
|
+
})
|
|
181
|
+
.map((entry) => entry.choice);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export function pickRecommended(choices) {
|
|
185
|
+
if (!choices.length) return { recommended: undefined, source: "first_available" };
|
|
186
|
+
const ranked = rankModels(choices);
|
|
187
|
+
const stable = ranked.filter((c) => STABLE_GPT_RE.test(c.id));
|
|
188
|
+
if (stable.length) return { recommended: stable[0].id, source: "recommended_stable", ranked };
|
|
189
|
+
return { recommended: ranked[0].id, source: "recommended_fallback", ranked };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export function orderForPrompt(ranked, recommended) {
|
|
193
|
+
if (!recommended) return [...ranked];
|
|
194
|
+
const found = ranked.find((c) => c.id === recommended);
|
|
195
|
+
if (!found) return [...ranked];
|
|
196
|
+
return [found, ...ranked.filter((c) => c.id !== recommended)];
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export async function discoverModelsForRoute(route) {
|
|
200
|
+
const response = await proxyModels(new Request("http://aerial.local/v1/models", { method: "GET" }));
|
|
201
|
+
const payload = await readJsonSafely(response);
|
|
202
|
+
if (!response.ok) {
|
|
203
|
+
const detail = payload.error || payload.raw || JSON.stringify(payload);
|
|
204
|
+
throw new Error(`Could not load Copilot models (${response.status}): ${detail}`);
|
|
205
|
+
}
|
|
206
|
+
const models = Array.isArray(payload.data) ? payload.data : [];
|
|
207
|
+
return modelsForRoute(models, route);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function currentModelFor(target) {
|
|
211
|
+
try {
|
|
212
|
+
const status = target === "Codex" ? codexStatus() : claudeStatus();
|
|
213
|
+
return typeof status.model === "string" ? status.model : undefined;
|
|
214
|
+
} catch {
|
|
215
|
+
return undefined;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function currentEffortFor(target) {
|
|
220
|
+
try {
|
|
221
|
+
const status = target === "Codex" ? codexStatus() : claudeStatus();
|
|
222
|
+
return typeof status.effort === "string" ? normalizeEffort(status.effort) : undefined;
|
|
223
|
+
} catch {
|
|
224
|
+
return undefined;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export async function chooseSetupModel({
|
|
229
|
+
target,
|
|
230
|
+
route,
|
|
231
|
+
explicitModel,
|
|
232
|
+
prompt,
|
|
233
|
+
input: inputStream = input,
|
|
234
|
+
output: outputStream = output
|
|
235
|
+
} = {}) {
|
|
236
|
+
if (explicitModel) return { model: explicitModel, choices: [], source: "explicit" };
|
|
237
|
+
let raw;
|
|
238
|
+
try {
|
|
239
|
+
raw = await discoverModelsForRoute(route);
|
|
240
|
+
} catch (err) {
|
|
241
|
+
throw new Error(`${err.message}\nRun \`aerial login\` first, or pass \`--model <id>\` if you already know which ${target} model to use.`);
|
|
242
|
+
}
|
|
243
|
+
if (!raw.length) {
|
|
244
|
+
throw new Error(`No Copilot models currently advertise the ${route} route needed by ${target}. Run \`aerial probe\` to inspect the full model list.`);
|
|
245
|
+
}
|
|
246
|
+
const { recommended, source: recommendedSource, ranked } = pickRecommended(raw);
|
|
247
|
+
const choices = ranked;
|
|
248
|
+
const promptListed = orderForPrompt(ranked, recommended);
|
|
249
|
+
const shouldPrompt = prompt === undefined ? Boolean(inputStream.isTTY) : prompt;
|
|
250
|
+
if (!shouldPrompt) return { model: recommended, choices, source: recommendedSource, recommended };
|
|
251
|
+
|
|
252
|
+
const current = currentModelFor(target);
|
|
253
|
+
const items = promptListed.map((choice) => ({ label: choice.id, id: choice.id }));
|
|
254
|
+
const currentIndex = items.findIndex((item) => item.id === current);
|
|
255
|
+
const initialIndex = currentIndex >= 0 ? currentIndex : 0;
|
|
256
|
+
|
|
257
|
+
outputStream.write(`Available ${target} models (${route} route):\n`);
|
|
258
|
+
if (recommendedSource === "recommended_fallback") {
|
|
259
|
+
outputStream.write(` No stable gpt-N.M model available; ${recommended} is the fallback. Pass --model <id> to override.\n`);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const { item, cancelled } = await select({
|
|
263
|
+
title: `Choose ${target} model:`,
|
|
264
|
+
items,
|
|
265
|
+
initialIndex,
|
|
266
|
+
getTags: (it) => {
|
|
267
|
+
const tags = [];
|
|
268
|
+
if (it.id === recommended) tags.push("recommended");
|
|
269
|
+
if (it.id === current) tags.push("current");
|
|
270
|
+
return tags;
|
|
271
|
+
},
|
|
272
|
+
input: inputStream,
|
|
273
|
+
output: outputStream
|
|
274
|
+
});
|
|
275
|
+
if (cancelled) throw new Error(`${target} setup cancelled.`);
|
|
276
|
+
return { model: item.id, choices, source: "prompt", displayed: true, recommended };
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
export function formatModelChoices({ target, route, choices, selectedModel, source, recommended }) {
|
|
280
|
+
if (!choices.length) return [];
|
|
281
|
+
const lines = [
|
|
282
|
+
`Available ${target} models (${route} route):`
|
|
283
|
+
];
|
|
284
|
+
for (const [index, choice] of choices.slice(0, MAX_LISTED_MODELS).entries()) {
|
|
285
|
+
const markers = [];
|
|
286
|
+
if (choice.id === recommended) markers.push("recommended");
|
|
287
|
+
if (choice.id === selectedModel) markers.push("selected");
|
|
288
|
+
const suffix = markers.length ? ` (${markers.join(", ")})` : "";
|
|
289
|
+
lines.push(` ${index + 1}. ${choice.id}${suffix}`);
|
|
290
|
+
}
|
|
291
|
+
if (choices.length > MAX_LISTED_MODELS) lines.push(` ... ${choices.length - MAX_LISTED_MODELS} more`);
|
|
292
|
+
if (source === "first_available" || source === "recommended_stable") {
|
|
293
|
+
lines.push(`No interactive terminal detected; selected ${selectedModel}. Pass --model <id> to choose a different model.`);
|
|
294
|
+
} else if (source === "recommended_fallback") {
|
|
295
|
+
lines.push(`No stable gpt-N.M model available; selected ${selectedModel}. Pass --model <id> to override.`);
|
|
296
|
+
} else {
|
|
297
|
+
lines.push(`Selected ${target} model: ${selectedModel}`);
|
|
298
|
+
}
|
|
299
|
+
return lines;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
export async function chooseSetupEffort({
|
|
303
|
+
target,
|
|
304
|
+
explicitEffort,
|
|
305
|
+
prompt,
|
|
306
|
+
input: inputStream = input,
|
|
307
|
+
output: outputStream = output
|
|
308
|
+
} = {}) {
|
|
309
|
+
if (explicitEffort !== undefined) {
|
|
310
|
+
return { effort: assertValidEffort(explicitEffort), source: "explicit", displayed: false };
|
|
311
|
+
}
|
|
312
|
+
const shouldPrompt = prompt === undefined ? Boolean(inputStream.isTTY) : prompt;
|
|
313
|
+
if (!shouldPrompt) {
|
|
314
|
+
return { effort: DEFAULT_EFFORT, source: "default_non_tty", displayed: false };
|
|
315
|
+
}
|
|
316
|
+
const current = currentEffortFor(target);
|
|
317
|
+
const currentIndex = EFFORT_VALUES.indexOf(current);
|
|
318
|
+
const initialIndex = currentIndex >= 0 ? currentIndex : EFFORT_VALUES.indexOf(DEFAULT_EFFORT);
|
|
319
|
+
const { item, cancelled } = await select({
|
|
320
|
+
title: `Choose ${target} reasoning effort:`,
|
|
321
|
+
items: EFFORT_VALUES.map((value) => ({ label: value, value })),
|
|
322
|
+
initialIndex,
|
|
323
|
+
getTags: (it) => {
|
|
324
|
+
const tags = [];
|
|
325
|
+
if (it.value === DEFAULT_EFFORT) tags.push("default");
|
|
326
|
+
if (it.value === current) tags.push("current");
|
|
327
|
+
return tags;
|
|
328
|
+
},
|
|
329
|
+
input: inputStream,
|
|
330
|
+
output: outputStream
|
|
331
|
+
});
|
|
332
|
+
if (cancelled) throw new Error(`${target} setup cancelled.`);
|
|
333
|
+
return { effort: item.value, source: "prompt", displayed: true };
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
export function formatEffortSelection({ target, effort, source }) {
|
|
337
|
+
if (source === "explicit") return `Selected ${target} effort: ${effort}`;
|
|
338
|
+
if (source === "prompt") return `Selected ${target} effort: ${effort}`;
|
|
339
|
+
return `No interactive terminal detected; selected ${target} effort: ${effort}. Pass --effort <low|medium|high|xhigh|max> to choose a different effort.`;
|
|
340
|
+
}
|
package/src/proxy/models.js
CHANGED
|
@@ -27,3 +27,24 @@ export async function annotateModelsResponse(response) {
|
|
|
27
27
|
data: payload.data.map((model) => ({ ...model, aerial: aerialSupportForModel(model) }))
|
|
28
28
|
}, { status: response.status, headers: copyResponseHeaders(response) });
|
|
29
29
|
}
|
|
30
|
+
|
|
31
|
+
export function aerialRoutes(model) {
|
|
32
|
+
return Array.isArray(model?.aerial?.routes) ? model.aerial.routes : [];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function modelsForRoute(models, route) {
|
|
36
|
+
return models
|
|
37
|
+
.filter((model) => typeof model?.id === "string" && aerialRoutes(model).includes(route))
|
|
38
|
+
.map((model) => ({ id: model.id, routes: aerialRoutes(model), notes: model.aerial?.notes || [] }));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function usageSummary(payload) {
|
|
42
|
+
const usage = payload?.usage || payload?.response?.usage || {};
|
|
43
|
+
return {
|
|
44
|
+
input: usage.input_tokens ?? usage.prompt_tokens,
|
|
45
|
+
output: usage.output_tokens ?? usage.completion_tokens,
|
|
46
|
+
cached: usage.input_tokens_details?.cached_tokens
|
|
47
|
+
?? usage.prompt_tokens_details?.cached_tokens
|
|
48
|
+
?? usage.cache_read_input_tokens
|
|
49
|
+
};
|
|
50
|
+
}
|
package/src/service/platform.js
CHANGED
|
@@ -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/
|
|
4
|
+
import { atomicWriteFile } from "../shared/utils.js";
|
|
5
5
|
import {
|
|
6
6
|
SERVICE_LABEL,
|
|
7
7
|
WIN_TASK_NAME,
|
package/src/setup/clients.js
CHANGED
|
@@ -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/
|
|
7
|
+
import { atomicWriteFile } from "../shared/utils.js";
|
|
8
8
|
import { assertValidEffort, normalizeEffort } from "../shared/effort.js";
|
|
9
|
-
import { backupIfExists, backupPathsFor } from "./
|
|
10
|
-
import { setTomlRootString, upsertTomlSection } from "./toml.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",
|
|
@@ -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,9 +63,7 @@ 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
|
-
|
|
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
68
|
if (normalizedEffort && config.defaultEffort !== normalizedEffort) {
|
|
70
69
|
saveConfig({ ...config, defaultEffort: normalizedEffort });
|
|
@@ -146,9 +145,23 @@ export function codexStatus() {
|
|
|
146
145
|
const baseUrl = typeof doc?.model_providers?.aerial?.base_url === "string"
|
|
147
146
|
? doc.model_providers.aerial.base_url
|
|
148
147
|
: undefined;
|
|
149
|
-
const
|
|
150
|
-
const effort = typeof
|
|
151
|
-
|
|
148
|
+
const rootEffort = doc?.model_reasoning_effort;
|
|
149
|
+
const effort = typeof rootEffort === "string" ? (normalizeEffort(rootEffort) || "missing") : "missing";
|
|
150
|
+
const legacyProfile = doc?.profiles?.aerial && typeof doc.profiles.aerial === "object";
|
|
151
|
+
const legacyProfileLooksAerial = legacyProfile
|
|
152
|
+
&& (doc.profiles.aerial.model_provider === "aerial"
|
|
153
|
+
|| typeof doc.profiles.aerial.model_reasoning_effort === "string");
|
|
154
|
+
const needsMigration = legacyProfile && (state !== "not-aerial" || legacyProfileLooksAerial);
|
|
155
|
+
return {
|
|
156
|
+
target: "codex",
|
|
157
|
+
state,
|
|
158
|
+
file,
|
|
159
|
+
backups,
|
|
160
|
+
model,
|
|
161
|
+
baseUrl,
|
|
162
|
+
effort,
|
|
163
|
+
...(needsMigration ? { migration: "run aerial setup codex" } : {})
|
|
164
|
+
};
|
|
152
165
|
}
|
|
153
166
|
|
|
154
167
|
function claudeStateFromDoc(doc, expectedBaseUrl) {
|
package/src/setup/index.js
CHANGED
|
@@ -1,4 +1,27 @@
|
|
|
1
|
-
|
|
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 {
|
|
4
|
-
|
|
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
|
+
}
|
package/src/setup/restore.js
CHANGED
|
@@ -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/
|
|
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
|
-
|
|
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} = ${
|
|
35
|
+
const line = `${key} = ${tomlScalar(value)}`;
|
|
13
36
|
const source = content.split(/\r?\n/);
|
|
14
|
-
const firstSection = source.findIndex(
|
|
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
|
|
18
|
-
const
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
|
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
|
|
31
|
-
if (start === -1)
|
|
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 (
|
|
64
|
+
if (isSectionHeader(source[i])) {
|
|
35
65
|
end = i;
|
|
36
66
|
break;
|
|
37
67
|
}
|
|
38
68
|
}
|
|
39
|
-
|
|
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
|
+
}
|
package/src/shared/paths.js
CHANGED
|
@@ -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 "./
|
|
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
|
}
|