niahere 0.4.6 → 0.5.1
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/package.json +2 -2
- package/src/agent/backends/claude-normalize.ts +5 -6
- package/src/agent/backends/claude.ts +6 -4
- package/src/agent/backends/codex-normalize.ts +19 -3
- package/src/agent/backends/codex.ts +100 -35
- package/src/agent/chain.ts +60 -0
- package/src/agent/failure.ts +90 -0
- package/src/agent/health.ts +41 -0
- package/src/agent/index.ts +3 -1
- package/src/agent/mcp-endpoint.ts +5 -3
- package/src/agent/models.ts +59 -0
- package/src/agent/registry.ts +54 -29
- package/src/agent/types.ts +9 -4
- package/src/channels/common/chat-session.ts +60 -0
- package/src/channels/phone/consult.ts +2 -1
- package/src/channels/phone/index.ts +5 -3
- package/src/channels/phone/relay.ts +15 -5
- package/src/channels/slack.ts +24 -34
- package/src/channels/sms.ts +16 -36
- package/src/channels/telegram.ts +22 -34
- package/src/channels/twilio/media-cache.ts +4 -3
- package/src/channels/twilio/rest.ts +0 -31
- package/src/channels/twilio/server.ts +0 -5
- package/src/channels/twilio/shared.ts +36 -0
- package/src/channels/whatsapp.ts +24 -58
- package/src/chat/engine.ts +83 -34
- package/src/chat/gap-marker.ts +63 -0
- package/src/chat/repl.ts +5 -5
- package/src/cli/config.ts +71 -0
- package/src/cli/index.ts +23 -288
- package/src/cli/job.ts +1 -2
- package/src/cli/logs.ts +55 -0
- package/src/cli/run.ts +74 -0
- package/src/cli/skills.ts +13 -0
- package/src/cli/status.ts +0 -1
- package/src/cli/test.ts +32 -0
- package/src/cli/update.ts +79 -0
- package/src/commands/backup.ts +1 -2
- package/src/commands/init.ts +1 -2
- package/src/commands/validate.ts +17 -13
- package/src/core/alive.ts +6 -5
- package/src/core/consolidator.ts +78 -31
- package/src/core/daemon.ts +3 -2
- package/src/core/finalizer.ts +9 -5
- package/src/core/runner.ts +64 -47
- package/src/core/scheduler.ts +19 -4
- package/src/core/skills.ts +0 -4
- package/src/db/migrations/017_sessions_consolidated_count.ts +9 -0
- package/src/db/models/active_engine.ts +0 -7
- package/src/db/models/job.ts +3 -2
- package/src/db/models/message.ts +10 -0
- package/src/db/models/session.ts +12 -0
- package/src/mcp/gate.ts +46 -0
- package/src/mcp/server.ts +2 -1
- package/src/mcp/tools/misc.ts +2 -1
- package/src/mcp/tools/send.ts +5 -4
- package/src/types/config.ts +4 -4
- package/src/utils/config.ts +6 -8
- package/src/utils/errors.ts +26 -0
- package/src/utils/retry.ts +0 -45
package/src/chat/engine.ts
CHANGED
|
@@ -4,15 +4,22 @@ import { buildSystemPrompt, buildContextSuffix, getSessionContext } from "./iden
|
|
|
4
4
|
import { buildEmployeePrompt } from "./employee-prompt";
|
|
5
5
|
import { getEmployee } from "../core/employees";
|
|
6
6
|
import { getAgentDefinitions, scanAgents } from "../core/agents";
|
|
7
|
+
import { gapMarker } from "./gap-marker";
|
|
8
|
+
import { getConfig } from "../utils/config";
|
|
7
9
|
import { Session, Message, ActiveEngine, Job } from "../db/models";
|
|
8
10
|
import type { Attachment, SendResult, SendCallbacks, ChatEngine, EngineOptions } from "../types";
|
|
9
11
|
import { finalizeSession, cancelPending } from "../core/finalizer";
|
|
10
12
|
import { log } from "../utils/log";
|
|
13
|
+
import { asError, errMsg, ignore } from "../utils/errors";
|
|
11
14
|
import { registerActiveHandle, unregisterActiveHandle } from "../core/active-handles";
|
|
12
15
|
import { resolveJobPrompt } from "../core/job-prompt";
|
|
13
|
-
import {
|
|
16
|
+
import { truncate } from "../utils/format-activity";
|
|
17
|
+
import { resolveChain, ChainCursor, describeEntry, type AgentSession, type FailoverScope } from "../agent";
|
|
18
|
+
import { scopeOf, parseFailure } from "../agent/failure";
|
|
14
19
|
|
|
15
20
|
const IDLE_TIMEOUT = 10 * 60 * 1000; // 10 minutes
|
|
21
|
+
const HANDOFF_MESSAGES = 20;
|
|
22
|
+
const HANDOFF_CHARS = 2000;
|
|
16
23
|
const LONG_RUNNING_WARN = 30 * 60 * 1000; // 30 minutes
|
|
17
24
|
const GENERIC_CHAT_ERROR = "💀";
|
|
18
25
|
|
|
@@ -29,8 +36,7 @@ export function formatChatError(rawError: string | null | undefined): string {
|
|
|
29
36
|
}
|
|
30
37
|
|
|
31
38
|
export function getChatErrorSignal(rawError: string | null | undefined): SendResult["signal"] | undefined {
|
|
32
|
-
|
|
33
|
-
return !error || error.toLowerCase() === "unknown error" ? "provider_down" : undefined;
|
|
39
|
+
return scopeOf(parseFailure(rawError), "provider") === "provider" ? "provider_down" : undefined;
|
|
34
40
|
}
|
|
35
41
|
|
|
36
42
|
export async function createChatEngine(opts: EngineOptions): Promise<ChatEngine> {
|
|
@@ -96,11 +102,10 @@ export async function createChatEngine(opts: EngineOptions): Promise<ChatEngine>
|
|
|
96
102
|
systemPrompt += `\n\n## Watch Mode — #${watchChannel}\n\nYou are monitoring this Slack channel. Follow the behavior instructions below.\nRespond with [NO_REPLY] if no action is needed — do not explain why.\n\n${behavior}`;
|
|
97
103
|
}
|
|
98
104
|
|
|
99
|
-
//
|
|
100
|
-
//
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
let backendIndex = 0;
|
|
105
|
+
// A turn that cannot be served moves down the chain and answers the current
|
|
106
|
+
// message there (see send()).
|
|
107
|
+
const chain = resolveChain();
|
|
108
|
+
let cursor = new ChainCursor(chain);
|
|
104
109
|
|
|
105
110
|
let sessionId: string | null = null;
|
|
106
111
|
if (typeof resume === "string") {
|
|
@@ -110,13 +115,15 @@ export async function createChatEngine(opts: EngineOptions): Promise<ChatEngine>
|
|
|
110
115
|
sessionId = await Session.getLatest(room);
|
|
111
116
|
}
|
|
112
117
|
|
|
113
|
-
//
|
|
114
|
-
|
|
115
|
-
if (sessionId && !(await backends[0]!.canResume(sessionId, cwd))) {
|
|
118
|
+
// Only resume if the head of the chain can actually take this session id.
|
|
119
|
+
if (sessionId && !(await cursor.current!.backend.canResume(sessionId, cwd))) {
|
|
116
120
|
sessionId = null;
|
|
117
121
|
}
|
|
118
122
|
|
|
119
123
|
let session: AgentSession | null = null;
|
|
124
|
+
// Prior turns, replayed into the system prompt when a failover starts a fresh
|
|
125
|
+
// session on another backend — the new one has no session to resume.
|
|
126
|
+
let handoff = "";
|
|
120
127
|
let idleTimer: ReturnType<typeof setTimeout> | null = null;
|
|
121
128
|
let longRunningTimer: ReturnType<typeof setTimeout> | null = null;
|
|
122
129
|
let messageCount = 0;
|
|
@@ -165,22 +172,34 @@ export async function createChatEngine(opts: EngineOptions): Promise<ChatEngine>
|
|
|
165
172
|
clearIdleTimer();
|
|
166
173
|
clearLongRunningTimer();
|
|
167
174
|
if (session) {
|
|
168
|
-
await session.close()
|
|
175
|
+
await ignore(session.close(), "close chat session");
|
|
169
176
|
session = null;
|
|
170
177
|
}
|
|
171
178
|
unregisterActiveHandle(room);
|
|
172
179
|
}
|
|
173
180
|
|
|
174
|
-
/**
|
|
181
|
+
/** Transcript of the conversation so far, for a backend that cannot resume it. */
|
|
182
|
+
async function buildHandoff(currentMessage: string): Promise<string> {
|
|
183
|
+
const recent = await Message.getRecent(HANDOFF_MESSAGES, room).catch(() => []);
|
|
184
|
+
const lines = recent
|
|
185
|
+
.filter((m) => m.content !== currentMessage)
|
|
186
|
+
.map((m) => `${m.sender === "nia" ? "Nia" : "User"}: ${truncate(m.content, HANDOFF_CHARS)}`);
|
|
187
|
+
if (lines.length === 0) return "";
|
|
188
|
+
return `\n\n## Conversation So Far\nYou are continuing this conversation after switching models mid-turn.\n\n${lines.join("\n")}`;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Lazily open (and reuse) the current chain entry's session for this engine. */
|
|
175
192
|
async function ensureSession(): Promise<AgentSession> {
|
|
176
193
|
if (session) return session;
|
|
177
|
-
const
|
|
178
|
-
const s = await backend.openSession({
|
|
194
|
+
const entry = cursor.current!;
|
|
195
|
+
const s = await entry.backend.openSession({
|
|
179
196
|
room,
|
|
180
197
|
channel,
|
|
181
|
-
systemPrompt,
|
|
198
|
+
systemPrompt: systemPrompt + handoff,
|
|
182
199
|
cwd,
|
|
183
|
-
model
|
|
200
|
+
// A context override names a model for the configured provider, so it
|
|
201
|
+
// only applies at the head of the chain.
|
|
202
|
+
model: (cursor.atHead ? (contextModel ?? entry.model) : entry.model) ?? undefined,
|
|
184
203
|
mcpServers,
|
|
185
204
|
resume: sessionId ?? false,
|
|
186
205
|
subagents: getAgentDefinitions(),
|
|
@@ -202,7 +221,26 @@ export async function createChatEngine(opts: EngineOptions): Promise<ChatEngine>
|
|
|
202
221
|
return room;
|
|
203
222
|
},
|
|
204
223
|
|
|
205
|
-
async send(
|
|
224
|
+
async send(rawMessage: string, callbacks?: SendCallbacks, attachments?: Attachment[]) {
|
|
225
|
+
// Date the turn when time has visibly passed. Computed before anything is
|
|
226
|
+
// saved, and used for BOTH the send and the save so the stored record is
|
|
227
|
+
// exactly what the model received.
|
|
228
|
+
const marker = gapMarker(new Date(), await Message.getLastAt(room).catch(() => null), getConfig().timezone);
|
|
229
|
+
const userMessage = marker ? `${marker}\n${rawMessage}` : rawMessage;
|
|
230
|
+
|
|
231
|
+
// Re-probe from the top once the failed provider's cooldown lapses, so a
|
|
232
|
+
// brief outage does not pin the conversation to the fallback for good.
|
|
233
|
+
if (!cursor.atHead) {
|
|
234
|
+
const fresh = new ChainCursor(chain);
|
|
235
|
+
if (fresh.current !== cursor.current) {
|
|
236
|
+
log.info({ room, to: fresh.current && describeEntry(fresh.current) }, "chat returning to preferred model");
|
|
237
|
+
cursor = fresh;
|
|
238
|
+
handoff = await buildHandoff("");
|
|
239
|
+
await teardown();
|
|
240
|
+
sessionId = null;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
206
244
|
// Clear idle timer — engine is not idle while processing a request
|
|
207
245
|
clearIdleTimer();
|
|
208
246
|
startLongRunningTimer();
|
|
@@ -210,7 +248,7 @@ export async function createChatEngine(opts: EngineOptions): Promise<ChatEngine>
|
|
|
210
248
|
|
|
211
249
|
// Cancel any pending finalization — session is active again
|
|
212
250
|
if (sessionId) {
|
|
213
|
-
cancelPending(sessionId)
|
|
251
|
+
void ignore(cancelPending(sessionId), "cancel pending finalization");
|
|
214
252
|
}
|
|
215
253
|
|
|
216
254
|
await ActiveEngine.register(room, channel);
|
|
@@ -227,12 +265,22 @@ export async function createChatEngine(opts: EngineOptions): Promise<ChatEngine>
|
|
|
227
265
|
|
|
228
266
|
let result: SendResult = { result: "", costUsd: 0, turns: 0 };
|
|
229
267
|
|
|
230
|
-
// Run the turn on the current
|
|
231
|
-
//
|
|
268
|
+
// Run the turn on the current chain entry; a turn that cannot be served
|
|
269
|
+
// moves down the chain and answers the current message there.
|
|
232
270
|
while (true) {
|
|
233
|
-
|
|
271
|
+
let sess: AgentSession;
|
|
272
|
+
try {
|
|
273
|
+
sess = await ensureSession();
|
|
274
|
+
} catch (err) {
|
|
275
|
+
// A backend that cannot start must not take the turn down.
|
|
276
|
+
const next = cursor.advance("provider");
|
|
277
|
+
log.warn({ room, err: String(err), to: next && describeEntry(next) }, "chat backend failed to start");
|
|
278
|
+
if (!next) throw asError(err);
|
|
279
|
+
handoff = await buildHandoff(userMessage);
|
|
280
|
+
continue;
|
|
281
|
+
}
|
|
234
282
|
let accumulated = "";
|
|
235
|
-
let
|
|
283
|
+
let failover: FailoverScope | undefined;
|
|
236
284
|
|
|
237
285
|
try {
|
|
238
286
|
for await (const ev of sess.send(userMessage, attachments)) {
|
|
@@ -279,13 +327,13 @@ export async function createChatEngine(opts: EngineOptions): Promise<ChatEngine>
|
|
|
279
327
|
messageId = await Message.save({ ...saveParams, metadata: undefined });
|
|
280
328
|
}
|
|
281
329
|
await Session.touch(sessionId);
|
|
282
|
-
Session.accumulateMetadata(sessionId, { ...(ev.metadata ?? {}), channel })
|
|
330
|
+
void ignore(Session.accumulateMetadata(sessionId, { ...(ev.metadata ?? {}), channel }), "accumulate session metadata");
|
|
283
331
|
}
|
|
284
332
|
result = { result: ev.text, costUsd, turns, messageId };
|
|
285
333
|
break;
|
|
286
334
|
}
|
|
287
335
|
case "error": {
|
|
288
|
-
|
|
336
|
+
failover = ev.failover;
|
|
289
337
|
log.error(
|
|
290
338
|
{ room, error: ev.message, terminal_reason: ev.terminalReason },
|
|
291
339
|
"chat send failed with backend error",
|
|
@@ -294,29 +342,30 @@ export async function createChatEngine(opts: EngineOptions): Promise<ChatEngine>
|
|
|
294
342
|
result: formatChatError(ev.message),
|
|
295
343
|
costUsd: 0,
|
|
296
344
|
turns: 0,
|
|
297
|
-
signal: ev.
|
|
345
|
+
signal: ev.failover === "provider" ? "provider_down" : undefined,
|
|
298
346
|
};
|
|
299
347
|
break;
|
|
300
348
|
}
|
|
301
349
|
}
|
|
302
350
|
}
|
|
303
351
|
} catch (err) {
|
|
304
|
-
await ActiveEngine.unregister(room)
|
|
352
|
+
await ignore(ActiveEngine.unregister(room), "unregister active-engine");
|
|
305
353
|
clearLongRunningTimer();
|
|
306
354
|
inFlight = false;
|
|
307
355
|
if (sess.backendSessionId) sessionId = sess.backendSessionId;
|
|
308
|
-
throw
|
|
356
|
+
throw asError(err);
|
|
309
357
|
}
|
|
310
358
|
|
|
311
359
|
// Re-read the backend session id post-send so finalize/DB target it.
|
|
312
360
|
if (sess.backendSessionId) sessionId = sess.backendSessionId;
|
|
313
361
|
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
log.warn({ room, to:
|
|
317
|
-
await teardown(); // close the dead session so ensureSession opens the next
|
|
362
|
+
const next = failover && cursor.advance(failover);
|
|
363
|
+
if (next) {
|
|
364
|
+
log.warn({ room, to: describeEntry(next), scope: failover }, "chat failing over to next model");
|
|
365
|
+
await teardown(); // close the dead session so ensureSession opens the next entry
|
|
318
366
|
sessionId = null; // a cross-backend session id is meaningless; start fresh
|
|
319
|
-
userSaved = false; // re-save the user turn under the new
|
|
367
|
+
userSaved = false; // re-save the user turn under the new session
|
|
368
|
+
handoff = await buildHandoff(userMessage);
|
|
320
369
|
continue;
|
|
321
370
|
}
|
|
322
371
|
break;
|
|
@@ -339,7 +388,7 @@ export async function createChatEngine(opts: EngineOptions): Promise<ChatEngine>
|
|
|
339
388
|
}
|
|
340
389
|
}
|
|
341
390
|
await teardown();
|
|
342
|
-
await ActiveEngine.unregister(room)
|
|
391
|
+
await ignore(ActiveEngine.unregister(room), "unregister active-engine");
|
|
343
392
|
},
|
|
344
393
|
};
|
|
345
394
|
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dates a user turn when time has visibly passed.
|
|
3
|
+
*
|
|
4
|
+
* A resumed session replays its transcript with no timestamps, so a reply from
|
|
5
|
+
* three days ago reads as present-tense — ask "what should I wear?" twice in a
|
|
6
|
+
* week and the model happily repeats Monday's weather. The marker is prefixed
|
|
7
|
+
* onto the turn that is sent AND stored, so the transcript dates itself from
|
|
8
|
+
* then on.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const MINUTE_MS = 60_000;
|
|
12
|
+
const HOUR_MS = 60 * MINUTE_MS;
|
|
13
|
+
const DAY_MS = 24 * HOUR_MS;
|
|
14
|
+
|
|
15
|
+
/** Gap that counts as "time has passed" even within one day. */
|
|
16
|
+
export const GAP_THRESHOLD_MS = 2 * HOUR_MS;
|
|
17
|
+
|
|
18
|
+
const LOCALE = "en-US";
|
|
19
|
+
|
|
20
|
+
function localDay(date: Date, timezone: string): string {
|
|
21
|
+
return new Intl.DateTimeFormat("en-CA", {
|
|
22
|
+
year: "numeric",
|
|
23
|
+
month: "2-digit",
|
|
24
|
+
day: "2-digit",
|
|
25
|
+
timeZone: timezone,
|
|
26
|
+
}).format(date);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function plural(n: number, unit: string): string {
|
|
30
|
+
return `${n} ${unit}${n === 1 ? "" : "s"}`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Floored, never rounded up: the absolute stamp alongside it carries the
|
|
34
|
+
* precision, so this should not claim more time passed than actually did. */
|
|
35
|
+
function elapsed(ms: number): string {
|
|
36
|
+
if (ms >= DAY_MS) return plural(Math.floor(ms / DAY_MS), "day");
|
|
37
|
+
if (ms >= HOUR_MS) return plural(Math.floor(ms / HOUR_MS), "hour");
|
|
38
|
+
return plural(Math.max(1, Math.floor(ms / MINUTE_MS)), "minute");
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The prefix for this turn, or null when the conversation is still continuous.
|
|
43
|
+
* Fires when the local date has changed or the gap exceeds the threshold.
|
|
44
|
+
*/
|
|
45
|
+
export function gapMarker(now: Date, last: Date | null, timezone: string): string | null {
|
|
46
|
+
if (!last) return null;
|
|
47
|
+
|
|
48
|
+
const gap = now.getTime() - last.getTime();
|
|
49
|
+
const dayChanged = localDay(now, timezone) !== localDay(last, timezone);
|
|
50
|
+
if (gap < GAP_THRESHOLD_MS && !dayChanged) return null;
|
|
51
|
+
|
|
52
|
+
const stamp = new Intl.DateTimeFormat(LOCALE, {
|
|
53
|
+
weekday: "long",
|
|
54
|
+
year: "numeric",
|
|
55
|
+
month: "long",
|
|
56
|
+
day: "numeric",
|
|
57
|
+
hour: "numeric",
|
|
58
|
+
minute: "2-digit",
|
|
59
|
+
timeZone: timezone,
|
|
60
|
+
}).format(now);
|
|
61
|
+
|
|
62
|
+
return `[${stamp} — ${elapsed(gap)} since the last message]`;
|
|
63
|
+
}
|
package/src/chat/repl.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import * as readline from "readline";
|
|
2
|
+
import { errMsg, ignore } from "../utils/errors";
|
|
2
3
|
import { createChatEngine } from "./engine";
|
|
3
4
|
import { runMigrations } from "../db/migrate";
|
|
4
5
|
import { closeDb } from "../db/connection";
|
|
@@ -118,7 +119,7 @@ export async function startRepl(
|
|
|
118
119
|
try {
|
|
119
120
|
await runMigrations();
|
|
120
121
|
} catch (err) {
|
|
121
|
-
const msg =
|
|
122
|
+
const msg = errMsg(err);
|
|
122
123
|
console.error(`Failed to connect to postgres: ${msg}`);
|
|
123
124
|
console.error(`Set database_url in ~/.niahere/config.yaml or run \`nia init\``);
|
|
124
125
|
process.exit(1);
|
|
@@ -218,7 +219,7 @@ export async function startRepl(
|
|
|
218
219
|
process.stdout.write("\n\n");
|
|
219
220
|
} catch (err) {
|
|
220
221
|
status.stop();
|
|
221
|
-
const msg =
|
|
222
|
+
const msg = errMsg(err);
|
|
222
223
|
console.error(`\n${DIM}error:${RESET} ${msg}\n`);
|
|
223
224
|
}
|
|
224
225
|
}
|
|
@@ -252,9 +253,8 @@ export async function startRepl(
|
|
|
252
253
|
rl.on("close", async () => {
|
|
253
254
|
console.log(`\n${DIM}bye${RESET}`);
|
|
254
255
|
await engine.close();
|
|
255
|
-
closeDb()
|
|
256
|
-
|
|
257
|
-
.finally(() => process.exit(0));
|
|
256
|
+
await ignore(closeDb(), "close db on exit");
|
|
257
|
+
process.exit(0);
|
|
258
258
|
});
|
|
259
259
|
|
|
260
260
|
process.on("SIGINT", () => {
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { readRawConfig, updateRawConfig } from "../utils/config";
|
|
2
|
+
|
|
3
|
+
const USAGE = [
|
|
4
|
+
"Usage: nia config <set|get|list>",
|
|
5
|
+
" nia config set <key> <value> — set a config value",
|
|
6
|
+
" nia config get <key> — get a config value",
|
|
7
|
+
" nia config list — show all config",
|
|
8
|
+
].join("\n");
|
|
9
|
+
|
|
10
|
+
/** Booleans and integers are written as their real types, not strings. */
|
|
11
|
+
function parseValue(raw: string): unknown {
|
|
12
|
+
if (raw === "true") return true;
|
|
13
|
+
if (raw === "false") return false;
|
|
14
|
+
if (/^\d+$/.test(raw)) return Number(raw);
|
|
15
|
+
return raw;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Expand `a.b.c` into `{ a: { b: { c: value } } }` for a merging write. */
|
|
19
|
+
function nest(key: string, value: unknown): Record<string, unknown> {
|
|
20
|
+
const parts = key.split(".");
|
|
21
|
+
const root: Record<string, unknown> = {};
|
|
22
|
+
let cursor = root;
|
|
23
|
+
for (const part of parts.slice(0, -1)) {
|
|
24
|
+
cursor[part] = {};
|
|
25
|
+
cursor = cursor[part] as Record<string, unknown>;
|
|
26
|
+
}
|
|
27
|
+
cursor[parts[parts.length - 1]!] = value;
|
|
28
|
+
return root;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function readPath(raw: unknown, key: string): unknown {
|
|
32
|
+
let val = raw;
|
|
33
|
+
for (const part of key.split(".")) {
|
|
34
|
+
if (val && typeof val === "object") val = (val as Record<string, unknown>)[part];
|
|
35
|
+
else return undefined;
|
|
36
|
+
}
|
|
37
|
+
return val;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export async function configCommand(sub?: string, key?: string, value?: string): Promise<void> {
|
|
41
|
+
if (sub === "set" && key) {
|
|
42
|
+
if (!value) {
|
|
43
|
+
console.error("Usage: nia config set <key> <value>");
|
|
44
|
+
process.exit(1);
|
|
45
|
+
}
|
|
46
|
+
updateRawConfig(nest(key, parseValue(value)));
|
|
47
|
+
console.log(`${key} = ${value}`);
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (sub === "get" && key) {
|
|
52
|
+
const val = readPath(readRawConfig(), key);
|
|
53
|
+
if (val === undefined) {
|
|
54
|
+
console.log(`${key}: (not set)`);
|
|
55
|
+
} else if (typeof val === "object") {
|
|
56
|
+
const yaml = (await import("js-yaml")).default;
|
|
57
|
+
console.log(yaml.dump(val, { lineWidth: -1 }).trim());
|
|
58
|
+
} else {
|
|
59
|
+
console.log(`${key} = ${val}`);
|
|
60
|
+
}
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (!sub || sub === "list") {
|
|
65
|
+
const yaml = (await import("js-yaml")).default;
|
|
66
|
+
console.log(yaml.dump(readRawConfig(), { lineWidth: -1 }).trim());
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
console.log(USAGE);
|
|
71
|
+
}
|