aether-code 0.12.0 → 0.13.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/LICENSE +21 -21
- package/bin/aether-code.js +457 -457
- package/package.json +69 -68
- package/src/agent.js +197 -197
- package/src/api.js +287 -234
- package/src/config.js +38 -38
- package/src/diff.js +48 -48
- package/src/render.js +58 -58
- package/src/repl.js +247 -247
- package/src/setup.js +139 -139
- package/src/skills.js +3 -0
- package/src/tools.js +803 -621
package/src/api.js
CHANGED
|
@@ -1,234 +1,287 @@
|
|
|
1
|
-
// API client.
|
|
2
|
-
|
|
3
|
-
import
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
*
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
);
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
1
|
+
// API client.
|
|
2
|
+
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { getConfig } from "./config.js";
|
|
7
|
+
|
|
8
|
+
// Resolve our own version once for the User-Agent header — read from
|
|
9
|
+
// package.json so it can't drift (the file previously sent three different
|
|
10
|
+
// hardcoded versions across three calls).
|
|
11
|
+
function readVersion() {
|
|
12
|
+
try {
|
|
13
|
+
const pkgPath = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "package.json");
|
|
14
|
+
return JSON.parse(fs.readFileSync(pkgPath, "utf8")).version || "0";
|
|
15
|
+
} catch {
|
|
16
|
+
return "0";
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
const USER_AGENT = `aether-code/${readVersion()}`;
|
|
20
|
+
|
|
21
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* fetch() with bounded exponential-backoff retries. Retries on thrown network
|
|
25
|
+
* errors (DNS/reset/offline blip) and transient 5xx; never retries 4xx
|
|
26
|
+
* (auth/credit/validation — a retry won't help). Body is always a small JSON
|
|
27
|
+
* string, so resending is safe. For the streaming endpoint only the initial
|
|
28
|
+
* connection is retried.
|
|
29
|
+
*/
|
|
30
|
+
export async function fetchWithRetry(url, options, { retries = 2, baseDelay = 500, onRetry } = {}) {
|
|
31
|
+
let attempt = 0;
|
|
32
|
+
while (true) {
|
|
33
|
+
try {
|
|
34
|
+
const res = await fetch(url, options);
|
|
35
|
+
if (res.status >= 500 && attempt < retries) {
|
|
36
|
+
attempt++;
|
|
37
|
+
if (onRetry) onRetry(attempt, `HTTP ${res.status}`);
|
|
38
|
+
await sleep(baseDelay * 2 ** (attempt - 1));
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
return res;
|
|
42
|
+
} catch (e) {
|
|
43
|
+
if (attempt < retries) {
|
|
44
|
+
attempt++;
|
|
45
|
+
if (onRetry) onRetry(attempt, e.message);
|
|
46
|
+
await sleep(baseDelay * 2 ** (attempt - 1));
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
throw e;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function defaultOnRetry(attempt, why) {
|
|
55
|
+
process.stderr.write(` ⟳ connection issue (${why}) — retry ${attempt}…\n`);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Free balance + plan check via /api/v1/me. Doesn't charge credits.
|
|
60
|
+
*/
|
|
61
|
+
export async function fetchBalance() {
|
|
62
|
+
const { apiKey, baseUrl } = getConfig();
|
|
63
|
+
if (!apiKey) {
|
|
64
|
+
throw new AetherError(
|
|
65
|
+
"No API key. Set AETHER_API_KEY or run `aether config set <key>`.",
|
|
66
|
+
"NO_API_KEY",
|
|
67
|
+
0,
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
let res;
|
|
71
|
+
try {
|
|
72
|
+
res = await fetchWithRetry(`${baseUrl}/api/v1/me`, {
|
|
73
|
+
method: "GET",
|
|
74
|
+
headers: {
|
|
75
|
+
Accept: "application/json",
|
|
76
|
+
Authorization: `Bearer ${apiKey}`,
|
|
77
|
+
"User-Agent": USER_AGENT,
|
|
78
|
+
},
|
|
79
|
+
}, { onRetry: defaultOnRetry });
|
|
80
|
+
} catch (e) {
|
|
81
|
+
throw new AetherError(`Network error: ${e.message}`, "NETWORK", 0);
|
|
82
|
+
}
|
|
83
|
+
let data = null;
|
|
84
|
+
try { data = await res.json(); } catch { /* non-JSON */ }
|
|
85
|
+
if (!res.ok) {
|
|
86
|
+
const code = data?.code || `HTTP_${res.status}`;
|
|
87
|
+
const msg = data?.error || `${res.status} ${res.statusText}`;
|
|
88
|
+
throw new AetherError(msg, code, res.status, data);
|
|
89
|
+
}
|
|
90
|
+
return data; // { plan, role, planCredits, topupCredits, balance, isSuspended, rate }
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export class AetherError extends Error {
|
|
94
|
+
constructor(message, code, status, data) {
|
|
95
|
+
super(message);
|
|
96
|
+
this.name = "AetherError";
|
|
97
|
+
this.code = code;
|
|
98
|
+
this.status = status;
|
|
99
|
+
this.data = data;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Streaming agent turn. Calls /api/v1/agent/stream and invokes the supplied
|
|
105
|
+
* callbacks as events arrive. Returns the assembled assistant message + final
|
|
106
|
+
* usage/credit info once the stream ends.
|
|
107
|
+
*
|
|
108
|
+
* Event handlers (all optional):
|
|
109
|
+
* onDelta(text) — text fragment from the model
|
|
110
|
+
* onToolCallDelta(part) — partial tool call: { index, id?, name?, args_delta? }
|
|
111
|
+
* onFinish(reason) — per-choice finish reason ("stop" | "tool_calls" | "length")
|
|
112
|
+
*
|
|
113
|
+
* Returns:
|
|
114
|
+
* { message: { role:"assistant", content, tool_calls }, finish_reason,
|
|
115
|
+
* creditsCharged, balanceAfter, usage }
|
|
116
|
+
*/
|
|
117
|
+
export async function agentTurnStream({
|
|
118
|
+
messages,
|
|
119
|
+
tools,
|
|
120
|
+
maxTokens,
|
|
121
|
+
temperature,
|
|
122
|
+
onDelta,
|
|
123
|
+
onToolCallDelta,
|
|
124
|
+
onFinish,
|
|
125
|
+
}) {
|
|
126
|
+
const { apiKey, baseUrl } = getConfig();
|
|
127
|
+
if (!apiKey) {
|
|
128
|
+
throw new AetherError(
|
|
129
|
+
"No API key. Set AETHER_API_KEY or run `aether config set <key>`.",
|
|
130
|
+
"NO_API_KEY",
|
|
131
|
+
0,
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
let res;
|
|
136
|
+
try {
|
|
137
|
+
res = await fetchWithRetry(`${baseUrl}/api/v1/agent/stream`, {
|
|
138
|
+
method: "POST",
|
|
139
|
+
headers: {
|
|
140
|
+
"Content-Type": "application/json",
|
|
141
|
+
Accept: "text/event-stream",
|
|
142
|
+
Authorization: `Bearer ${apiKey}`,
|
|
143
|
+
"User-Agent": USER_AGENT,
|
|
144
|
+
},
|
|
145
|
+
body: JSON.stringify({
|
|
146
|
+
messages,
|
|
147
|
+
tools,
|
|
148
|
+
max_tokens: maxTokens,
|
|
149
|
+
temperature,
|
|
150
|
+
}),
|
|
151
|
+
}, { onRetry: defaultOnRetry });
|
|
152
|
+
} catch (e) {
|
|
153
|
+
throw new AetherError(`Network error: ${e.message}`, "NETWORK", 0);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Non-streaming JSON error response (auth, credit, validation failures)
|
|
157
|
+
if (!res.ok) {
|
|
158
|
+
let data = null;
|
|
159
|
+
try { data = await res.json(); } catch { /* non-JSON */ }
|
|
160
|
+
const code = data?.code || `HTTP_${res.status}`;
|
|
161
|
+
const msg = data?.error || `${res.status} ${res.statusText}`;
|
|
162
|
+
throw new AetherError(msg, code, res.status, data);
|
|
163
|
+
}
|
|
164
|
+
if (!res.body) throw new AetherError("Response has no body", "NO_BODY", res.status);
|
|
165
|
+
|
|
166
|
+
// Accumulate streaming state
|
|
167
|
+
let textBuf = "";
|
|
168
|
+
const toolBuf = new Map(); // index -> { id, name, args }
|
|
169
|
+
let finishReason = "stop";
|
|
170
|
+
let creditsCharged = 0;
|
|
171
|
+
let balanceAfter = null;
|
|
172
|
+
let usage = { prompt_tokens: 0, completion_tokens: 0 };
|
|
173
|
+
let streamError = null;
|
|
174
|
+
|
|
175
|
+
const reader = res.body.getReader();
|
|
176
|
+
const decoder = new TextDecoder();
|
|
177
|
+
let buffer = "";
|
|
178
|
+
while (true) {
|
|
179
|
+
const { value, done } = await reader.read();
|
|
180
|
+
if (done) break;
|
|
181
|
+
buffer += decoder.decode(value, { stream: true });
|
|
182
|
+
const lines = buffer.split("\n");
|
|
183
|
+
buffer = lines.pop() ?? "";
|
|
184
|
+
for (const line of lines) {
|
|
185
|
+
if (!line.startsWith("data:")) continue;
|
|
186
|
+
const json = line.slice(5).trim();
|
|
187
|
+
if (!json) continue;
|
|
188
|
+
let evt;
|
|
189
|
+
try { evt = JSON.parse(json); } catch { continue; }
|
|
190
|
+
if (evt.kind === "delta") {
|
|
191
|
+
textBuf += evt.content;
|
|
192
|
+
if (onDelta) onDelta(evt.content);
|
|
193
|
+
} else if (evt.kind === "tool_call_delta") {
|
|
194
|
+
const slot = toolBuf.get(evt.index) || { id: undefined, name: undefined, args: "" };
|
|
195
|
+
if (evt.id) slot.id = evt.id;
|
|
196
|
+
if (evt.name) slot.name = evt.name;
|
|
197
|
+
if (evt.args_delta) slot.args += evt.args_delta;
|
|
198
|
+
toolBuf.set(evt.index, slot);
|
|
199
|
+
if (onToolCallDelta) onToolCallDelta(evt);
|
|
200
|
+
} else if (evt.kind === "finish") {
|
|
201
|
+
finishReason = evt.reason;
|
|
202
|
+
if (onFinish) onFinish(evt.reason);
|
|
203
|
+
} else if (evt.kind === "done") {
|
|
204
|
+
creditsCharged = evt.creditsCharged ?? 0;
|
|
205
|
+
balanceAfter = evt.balanceAfter ?? null;
|
|
206
|
+
usage = evt.usage ?? usage;
|
|
207
|
+
} else if (evt.kind === "error") {
|
|
208
|
+
streamError = evt.error;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
if (streamError) {
|
|
214
|
+
throw new AetherError(streamError, "STREAM_ERROR", 0);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Assemble final tool_calls in stable index order
|
|
218
|
+
const tool_calls = [...toolBuf.entries()]
|
|
219
|
+
.sort((a, b) => a[0] - b[0])
|
|
220
|
+
.map(([, slot]) => ({
|
|
221
|
+
id: slot.id || `call_${Math.random().toString(36).slice(2, 10)}`,
|
|
222
|
+
type: "function",
|
|
223
|
+
function: { name: slot.name || "", arguments: slot.args || "" },
|
|
224
|
+
}));
|
|
225
|
+
|
|
226
|
+
return {
|
|
227
|
+
message: {
|
|
228
|
+
role: "assistant",
|
|
229
|
+
content: textBuf || null,
|
|
230
|
+
tool_calls: tool_calls.length > 0 ? tool_calls : undefined,
|
|
231
|
+
},
|
|
232
|
+
finish_reason: finishReason,
|
|
233
|
+
creditsCharged,
|
|
234
|
+
balanceAfter,
|
|
235
|
+
usage,
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Non-streaming variant — kept as a fallback for environments where SSE is
|
|
241
|
+
* problematic (corporate proxies, weird client setups). The CLI defaults to
|
|
242
|
+
* streaming.
|
|
243
|
+
*/
|
|
244
|
+
export async function agentTurn({ messages, tools, maxTokens, temperature }) {
|
|
245
|
+
const { apiKey, baseUrl } = getConfig();
|
|
246
|
+
if (!apiKey) {
|
|
247
|
+
throw new AetherError(
|
|
248
|
+
"No API key. Set AETHER_API_KEY env var or run `aether-cli config set <key>`.",
|
|
249
|
+
"NO_API_KEY",
|
|
250
|
+
0,
|
|
251
|
+
);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
let res;
|
|
255
|
+
try {
|
|
256
|
+
res = await fetchWithRetry(`${baseUrl}/api/v1/agent`, {
|
|
257
|
+
method: "POST",
|
|
258
|
+
headers: {
|
|
259
|
+
"Content-Type": "application/json",
|
|
260
|
+
Authorization: `Bearer ${apiKey}`,
|
|
261
|
+
"User-Agent": USER_AGENT,
|
|
262
|
+
},
|
|
263
|
+
body: JSON.stringify({
|
|
264
|
+
messages,
|
|
265
|
+
tools,
|
|
266
|
+
max_tokens: maxTokens,
|
|
267
|
+
temperature,
|
|
268
|
+
}),
|
|
269
|
+
}, { onRetry: defaultOnRetry });
|
|
270
|
+
} catch (e) {
|
|
271
|
+
throw new AetherError(`Network error: ${e.message}`, "NETWORK", 0);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
let data = null;
|
|
275
|
+
try {
|
|
276
|
+
data = await res.json();
|
|
277
|
+
} catch {
|
|
278
|
+
/* non-JSON */
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
if (!res.ok) {
|
|
282
|
+
const code = (data && data.code) || `HTTP_${res.status}`;
|
|
283
|
+
const msg = (data && data.error) || `${res.status} ${res.statusText}`;
|
|
284
|
+
throw new AetherError(msg, code, res.status, data);
|
|
285
|
+
}
|
|
286
|
+
return data;
|
|
287
|
+
}
|
package/src/config.js
CHANGED
|
@@ -1,38 +1,38 @@
|
|
|
1
|
-
// Config — reads ~/.aetherrc (shared format with aether-cli) and env vars.
|
|
2
|
-
// Same precedence: env wins over file.
|
|
3
|
-
|
|
4
|
-
import fs from "node:fs";
|
|
5
|
-
import path from "node:path";
|
|
6
|
-
import os from "node:os";
|
|
7
|
-
|
|
8
|
-
const FILE = path.join(os.homedir(), ".aetherrc");
|
|
9
|
-
const DEFAULT_BASE = "https://trynoguard.com";
|
|
10
|
-
|
|
11
|
-
export function readConfigFile() {
|
|
12
|
-
try {
|
|
13
|
-
const raw = fs.readFileSync(FILE, "utf8");
|
|
14
|
-
return JSON.parse(raw);
|
|
15
|
-
} catch {
|
|
16
|
-
return {};
|
|
17
|
-
}
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
export function writeConfigFile(patch) {
|
|
21
|
-
const current = readConfigFile();
|
|
22
|
-
const next = { ...current, ...patch };
|
|
23
|
-
// 0600 — readable only by the user. Mirrors what ssh, gnupg, etc. enforce
|
|
24
|
-
// on credential files. Treat the API key like an SSH key.
|
|
25
|
-
fs.writeFileSync(FILE, JSON.stringify(next, null, 2), { mode: 0o600 });
|
|
26
|
-
try { fs.chmodSync(FILE, 0o600); } catch { /* windows ignores */ }
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
export function getConfig() {
|
|
30
|
-
const file = readConfigFile();
|
|
31
|
-
return {
|
|
32
|
-
apiKey: process.env.AETHER_API_KEY || file.apiKey || "",
|
|
33
|
-
baseUrl: (process.env.AETHER_BASE_URL || file.baseUrl || DEFAULT_BASE).replace(/\/+$/, ""),
|
|
34
|
-
configPath: FILE,
|
|
35
|
-
};
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
export const CONFIG_PATH = FILE;
|
|
1
|
+
// Config — reads ~/.aetherrc (shared format with aether-cli) and env vars.
|
|
2
|
+
// Same precedence: env wins over file.
|
|
3
|
+
|
|
4
|
+
import fs from "node:fs";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import os from "node:os";
|
|
7
|
+
|
|
8
|
+
const FILE = path.join(os.homedir(), ".aetherrc");
|
|
9
|
+
const DEFAULT_BASE = "https://trynoguard.com";
|
|
10
|
+
|
|
11
|
+
export function readConfigFile() {
|
|
12
|
+
try {
|
|
13
|
+
const raw = fs.readFileSync(FILE, "utf8");
|
|
14
|
+
return JSON.parse(raw);
|
|
15
|
+
} catch {
|
|
16
|
+
return {};
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function writeConfigFile(patch) {
|
|
21
|
+
const current = readConfigFile();
|
|
22
|
+
const next = { ...current, ...patch };
|
|
23
|
+
// 0600 — readable only by the user. Mirrors what ssh, gnupg, etc. enforce
|
|
24
|
+
// on credential files. Treat the API key like an SSH key.
|
|
25
|
+
fs.writeFileSync(FILE, JSON.stringify(next, null, 2), { mode: 0o600 });
|
|
26
|
+
try { fs.chmodSync(FILE, 0o600); } catch { /* windows ignores */ }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function getConfig() {
|
|
30
|
+
const file = readConfigFile();
|
|
31
|
+
return {
|
|
32
|
+
apiKey: process.env.AETHER_API_KEY || file.apiKey || "",
|
|
33
|
+
baseUrl: (process.env.AETHER_BASE_URL || file.baseUrl || DEFAULT_BASE).replace(/\/+$/, ""),
|
|
34
|
+
configPath: FILE,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export const CONFIG_PATH = FILE;
|