auq-mcp-server 2.3.0 → 2.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +82 -0
- package/dist/bin/auq.js +47 -93
- package/dist/bin/tui-app.js +69 -6
- package/dist/package.json +1 -1
- package/dist/src/__tests__/server.abort.test.js +214 -0
- package/dist/src/cli/commands/__tests__/answer.test.js +199 -0
- package/dist/src/cli/commands/__tests__/config.test.js +218 -0
- package/dist/src/cli/commands/__tests__/sessions.test.js +282 -0
- package/dist/src/cli/commands/answer.js +128 -0
- package/dist/src/cli/commands/config.js +263 -0
- package/dist/src/cli/commands/sessions.js +164 -0
- package/dist/src/cli/utils.js +95 -0
- package/dist/src/config/__tests__/ConfigLoader.test.js +41 -0
- package/dist/src/config/defaults.js +3 -0
- package/dist/src/config/types.js +4 -0
- package/dist/src/core/ask-user-questions.js +3 -2
- package/dist/src/i18n/locales/en.js +7 -0
- package/dist/src/i18n/locales/ko.js +7 -0
- package/dist/src/server.js +64 -11
- package/dist/src/session/SessionManager.js +69 -4
- package/dist/src/session/__tests__/SessionManager.test.js +65 -0
- package/dist/src/tui/__tests__/session-watcher.test.js +109 -0
- package/dist/src/tui/components/SessionDots.js +33 -4
- package/dist/src/tui/components/SessionPicker.js +25 -17
- package/dist/src/tui/components/Spinner.js +19 -0
- package/dist/src/tui/components/StepperView.js +68 -5
- package/dist/src/tui/components/__tests__/SessionDots.test.js +160 -1
- package/dist/src/tui/components/__tests__/SessionPicker.test.js +43 -1
- package/dist/src/tui/components/__tests__/StepperView.abandoned.test.js +160 -0
- package/dist/src/tui/components/__tests__/StepperView.state.test.js +1 -0
- package/dist/src/tui/session-watcher.js +50 -0
- package/dist/src/tui/themes/catppuccin-latte.js +7 -0
- package/dist/src/tui/themes/catppuccin-mocha.js +7 -0
- package/dist/src/tui/themes/dark.js +7 -0
- package/dist/src/tui/themes/dracula.js +7 -0
- package/dist/src/tui/themes/github-dark.js +7 -0
- package/dist/src/tui/themes/github-light.js +7 -0
- package/dist/src/tui/themes/gruvbox-dark.js +7 -0
- package/dist/src/tui/themes/gruvbox-light.js +7 -0
- package/dist/src/tui/themes/light.js +7 -0
- package/dist/src/tui/themes/monokai.js +7 -0
- package/dist/src/tui/themes/nord.js +7 -0
- package/dist/src/tui/themes/one-dark.js +7 -0
- package/dist/src/tui/themes/rose-pine.js +7 -0
- package/dist/src/tui/themes/solarized-dark.js +7 -0
- package/dist/src/tui/themes/solarized-light.js +7 -0
- package/dist/src/tui/themes/tokyo-night.js +7 -0
- package/dist/src/tui/utils/__tests__/detectTheme.test.js +78 -0
- package/dist/src/tui/utils/__tests__/staleDetection.test.js +118 -0
- package/dist/src/tui/utils/staleDetection.js +51 -0
- package/package.json +1 -1
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { promises as fs } from "fs";
|
|
3
|
+
import { join } from "path";
|
|
4
|
+
import { runSessionsCommand } from "../sessions.js";
|
|
5
|
+
import { SessionManager } from "../../../session/SessionManager.js";
|
|
6
|
+
const TEST_BASE_DIR = "/tmp/auq-test-cli-sessions";
|
|
7
|
+
const TEST_ARCHIVE_DIR = "/tmp/auq-test-cli-sessions-archive";
|
|
8
|
+
/**
|
|
9
|
+
* Helper: create a session with a given createdAt timestamp
|
|
10
|
+
*/
|
|
11
|
+
async function createTestSession(manager, opts = {}) {
|
|
12
|
+
const questionCount = opts.questionCount ?? 1;
|
|
13
|
+
const questions = Array.from({ length: questionCount }, (_, i) => ({
|
|
14
|
+
title: `Q${i}`,
|
|
15
|
+
prompt: `Question ${i}?`,
|
|
16
|
+
options: [
|
|
17
|
+
{ label: "A", description: "Option A" },
|
|
18
|
+
{ label: "B", description: "Option B" },
|
|
19
|
+
],
|
|
20
|
+
}));
|
|
21
|
+
const sessionId = await manager.createSession(questions);
|
|
22
|
+
// Patch createdAt if requested
|
|
23
|
+
if (opts.createdAt) {
|
|
24
|
+
const status = await manager.getSessionStatus(sessionId);
|
|
25
|
+
if (status) {
|
|
26
|
+
await manager.updateSessionStatus(sessionId, status.status, {
|
|
27
|
+
createdAt: opts.createdAt,
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
// Transition status if requested
|
|
32
|
+
if (opts.status && opts.status !== "pending") {
|
|
33
|
+
await manager.updateSessionStatus(sessionId, opts.status);
|
|
34
|
+
}
|
|
35
|
+
return sessionId;
|
|
36
|
+
}
|
|
37
|
+
describe("sessions command", () => {
|
|
38
|
+
let consoleLogSpy;
|
|
39
|
+
let consoleErrorSpy;
|
|
40
|
+
let consoleWarnSpy;
|
|
41
|
+
let sessionManager;
|
|
42
|
+
const originalEnv = { ...process.env };
|
|
43
|
+
beforeEach(async () => {
|
|
44
|
+
// Clean up test dirs
|
|
45
|
+
await fs.rm(TEST_BASE_DIR, { force: true, recursive: true });
|
|
46
|
+
await fs.rm(TEST_ARCHIVE_DIR, { force: true, recursive: true });
|
|
47
|
+
// Point to test directories
|
|
48
|
+
process.env.AUQ_SESSION_DIR = TEST_BASE_DIR;
|
|
49
|
+
process.env.XDG_DATA_HOME = "/tmp/auq-test-cli-sessions-archive";
|
|
50
|
+
process.exitCode = undefined;
|
|
51
|
+
consoleLogSpy = vi.spyOn(console, "log").mockImplementation(() => { });
|
|
52
|
+
consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => { });
|
|
53
|
+
consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => { });
|
|
54
|
+
// Create a SessionManager for setting up test fixtures
|
|
55
|
+
sessionManager = new SessionManager({ baseDir: TEST_BASE_DIR });
|
|
56
|
+
await sessionManager.initialize();
|
|
57
|
+
});
|
|
58
|
+
afterEach(async () => {
|
|
59
|
+
consoleLogSpy.mockRestore();
|
|
60
|
+
consoleErrorSpy.mockRestore();
|
|
61
|
+
consoleWarnSpy.mockRestore();
|
|
62
|
+
vi.restoreAllMocks();
|
|
63
|
+
process.exitCode = undefined;
|
|
64
|
+
process.env = { ...originalEnv };
|
|
65
|
+
// Clean up
|
|
66
|
+
await fs.rm(TEST_BASE_DIR, { force: true, recursive: true }).catch(() => { });
|
|
67
|
+
await fs.rm(TEST_ARCHIVE_DIR, { force: true, recursive: true }).catch(() => { });
|
|
68
|
+
});
|
|
69
|
+
// ── Sessions List ─────────────────────────────────────────────────
|
|
70
|
+
describe("sessions list", () => {
|
|
71
|
+
it("should list pending sessions by default", async () => {
|
|
72
|
+
const id1 = await createTestSession(sessionManager);
|
|
73
|
+
const id2 = await createTestSession(sessionManager, {
|
|
74
|
+
status: "completed",
|
|
75
|
+
});
|
|
76
|
+
await runSessionsCommand(["list"]);
|
|
77
|
+
const allOutput = consoleLogSpy.mock.calls.map((c) => c[0]).join("\n");
|
|
78
|
+
expect(allOutput).toContain(id1);
|
|
79
|
+
expect(allOutput).not.toContain(id2);
|
|
80
|
+
});
|
|
81
|
+
it("should show empty message when no sessions", async () => {
|
|
82
|
+
await runSessionsCommand(["list"]);
|
|
83
|
+
const allOutput = consoleLogSpy.mock.calls.map((c) => c[0]).join("\n");
|
|
84
|
+
expect(allOutput).toContain("No sessions found");
|
|
85
|
+
});
|
|
86
|
+
it("should list in-progress sessions with default filter", async () => {
|
|
87
|
+
const id1 = await createTestSession(sessionManager, {
|
|
88
|
+
status: "in-progress",
|
|
89
|
+
});
|
|
90
|
+
await runSessionsCommand(["list"]);
|
|
91
|
+
const allOutput = consoleLogSpy.mock.calls.map((c) => c[0]).join("\n");
|
|
92
|
+
expect(allOutput).toContain(id1);
|
|
93
|
+
});
|
|
94
|
+
it("should show only stale sessions with --stale flag", async () => {
|
|
95
|
+
// Create a stale session (3 hours ago)
|
|
96
|
+
const threeHoursAgo = new Date(Date.now() - 3 * 60 * 60 * 1000).toISOString();
|
|
97
|
+
const staleId = await createTestSession(sessionManager, {
|
|
98
|
+
createdAt: threeHoursAgo,
|
|
99
|
+
});
|
|
100
|
+
// Create a fresh session
|
|
101
|
+
const freshId = await createTestSession(sessionManager);
|
|
102
|
+
await runSessionsCommand(["list", "--stale"]);
|
|
103
|
+
const allOutput = consoleLogSpy.mock.calls.map((c) => c[0]).join("\n");
|
|
104
|
+
expect(allOutput).toContain(staleId);
|
|
105
|
+
expect(allOutput).not.toContain(freshId);
|
|
106
|
+
});
|
|
107
|
+
it("should show all sessions with --all flag", async () => {
|
|
108
|
+
const pendingId = await createTestSession(sessionManager);
|
|
109
|
+
const completedId = await createTestSession(sessionManager, {
|
|
110
|
+
status: "completed",
|
|
111
|
+
});
|
|
112
|
+
const abandonedId = await createTestSession(sessionManager, {
|
|
113
|
+
status: "abandoned",
|
|
114
|
+
});
|
|
115
|
+
await runSessionsCommand(["list", "--all"]);
|
|
116
|
+
const allOutput = consoleLogSpy.mock.calls.map((c) => c[0]).join("\n");
|
|
117
|
+
expect(allOutput).toContain(pendingId);
|
|
118
|
+
expect(allOutput).toContain(completedId);
|
|
119
|
+
expect(allOutput).toContain(abandonedId);
|
|
120
|
+
});
|
|
121
|
+
it("should show --pending as same as default", async () => {
|
|
122
|
+
const pendingId = await createTestSession(sessionManager);
|
|
123
|
+
const completedId = await createTestSession(sessionManager, {
|
|
124
|
+
status: "completed",
|
|
125
|
+
});
|
|
126
|
+
await runSessionsCommand(["list", "--pending"]);
|
|
127
|
+
const allOutput = consoleLogSpy.mock.calls.map((c) => c[0]).join("\n");
|
|
128
|
+
expect(allOutput).toContain(pendingId);
|
|
129
|
+
expect(allOutput).not.toContain(completedId);
|
|
130
|
+
});
|
|
131
|
+
it("should sort sessions newest first", async () => {
|
|
132
|
+
const twoHoursAgo = new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString();
|
|
133
|
+
const oneHourAgo = new Date(Date.now() - 1 * 60 * 60 * 1000).toISOString();
|
|
134
|
+
const oldId = await createTestSession(sessionManager, {
|
|
135
|
+
createdAt: twoHoursAgo,
|
|
136
|
+
});
|
|
137
|
+
const newId = await createTestSession(sessionManager, {
|
|
138
|
+
createdAt: oneHourAgo,
|
|
139
|
+
});
|
|
140
|
+
await runSessionsCommand(["list"]);
|
|
141
|
+
// Find which order they appear in output
|
|
142
|
+
const calls = consoleLogSpy.mock.calls.map((c) => String(c[0]));
|
|
143
|
+
const oldIdx = calls.findIndex((c) => c.includes(oldId));
|
|
144
|
+
const newIdx = calls.findIndex((c) => c.includes(newId));
|
|
145
|
+
expect(newIdx).toBeLessThan(oldIdx);
|
|
146
|
+
});
|
|
147
|
+
it("should show stale indicator for stale sessions", async () => {
|
|
148
|
+
const threeHoursAgo = new Date(Date.now() - 3 * 60 * 60 * 1000).toISOString();
|
|
149
|
+
const staleId = await createTestSession(sessionManager, {
|
|
150
|
+
createdAt: threeHoursAgo,
|
|
151
|
+
});
|
|
152
|
+
await runSessionsCommand(["list"]);
|
|
153
|
+
const allOutput = consoleLogSpy.mock.calls.map((c) => c[0]).join("\n");
|
|
154
|
+
expect(allOutput).toContain("\u26a0");
|
|
155
|
+
});
|
|
156
|
+
it("should output valid JSON array with --json flag", async () => {
|
|
157
|
+
const id = await createTestSession(sessionManager, {
|
|
158
|
+
questionCount: 3,
|
|
159
|
+
});
|
|
160
|
+
await runSessionsCommand(["list", "--json"]);
|
|
161
|
+
expect(consoleLogSpy).toHaveBeenCalledTimes(1);
|
|
162
|
+
const output = consoleLogSpy.mock.calls[0][0];
|
|
163
|
+
const parsed = JSON.parse(output);
|
|
164
|
+
expect(Array.isArray(parsed)).toBe(true);
|
|
165
|
+
expect(parsed.length).toBe(1);
|
|
166
|
+
expect(parsed[0].sessionId).toBe(id);
|
|
167
|
+
expect(parsed[0].status).toBe("pending");
|
|
168
|
+
expect(parsed[0].createdAt).toBeDefined();
|
|
169
|
+
expect(parsed[0].age).toBeDefined();
|
|
170
|
+
expect(typeof parsed[0].stale).toBe("boolean");
|
|
171
|
+
expect(parsed[0].questionCount).toBe(3);
|
|
172
|
+
});
|
|
173
|
+
it("should include question count in output", async () => {
|
|
174
|
+
await createTestSession(sessionManager, {
|
|
175
|
+
questionCount: 5,
|
|
176
|
+
});
|
|
177
|
+
await runSessionsCommand(["list"]);
|
|
178
|
+
const allOutput = consoleLogSpy.mock.calls.map((c) => c[0]).join("\n");
|
|
179
|
+
expect(allOutput).toContain("questions: 5");
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
// ── Sessions Dismiss ──────────────────────────────────────────────
|
|
183
|
+
describe("sessions dismiss", () => {
|
|
184
|
+
it("should dismiss a stale session successfully", async () => {
|
|
185
|
+
const threeHoursAgo = new Date(Date.now() - 3 * 60 * 60 * 1000).toISOString();
|
|
186
|
+
const sessionId = await createTestSession(sessionManager, {
|
|
187
|
+
createdAt: threeHoursAgo,
|
|
188
|
+
});
|
|
189
|
+
await runSessionsCommand(["dismiss", sessionId]);
|
|
190
|
+
expect(process.exitCode).toBeUndefined();
|
|
191
|
+
const allOutput = consoleLogSpy.mock.calls.map((c) => c[0]).join("\n");
|
|
192
|
+
expect(allOutput).toContain("dismissed");
|
|
193
|
+
expect(allOutput).toContain("archived");
|
|
194
|
+
});
|
|
195
|
+
it("should archive files to correct directory", async () => {
|
|
196
|
+
const threeHoursAgo = new Date(Date.now() - 3 * 60 * 60 * 1000).toISOString();
|
|
197
|
+
const sessionId = await createTestSession(sessionManager, {
|
|
198
|
+
createdAt: threeHoursAgo,
|
|
199
|
+
});
|
|
200
|
+
await runSessionsCommand(["dismiss", sessionId]);
|
|
201
|
+
// Check archive directory contains files
|
|
202
|
+
const archiveDir = join(TEST_ARCHIVE_DIR, "auq", "archive", sessionId);
|
|
203
|
+
const archivedFiles = await fs.readdir(archiveDir);
|
|
204
|
+
expect(archivedFiles.length).toBeGreaterThan(0);
|
|
205
|
+
expect(archivedFiles).toContain("status.json");
|
|
206
|
+
expect(archivedFiles).toContain("request.json");
|
|
207
|
+
});
|
|
208
|
+
it("should remove session from active directory after dismiss", async () => {
|
|
209
|
+
const threeHoursAgo = new Date(Date.now() - 3 * 60 * 60 * 1000).toISOString();
|
|
210
|
+
const sessionId = await createTestSession(sessionManager, {
|
|
211
|
+
createdAt: threeHoursAgo,
|
|
212
|
+
});
|
|
213
|
+
await runSessionsCommand(["dismiss", sessionId]);
|
|
214
|
+
// Session should no longer exist in active
|
|
215
|
+
const exists = await sessionManager.sessionExists(sessionId);
|
|
216
|
+
expect(exists).toBe(false);
|
|
217
|
+
});
|
|
218
|
+
it("should error on non-stale session without --force", async () => {
|
|
219
|
+
const sessionId = await createTestSession(sessionManager);
|
|
220
|
+
await runSessionsCommand(["dismiss", sessionId]);
|
|
221
|
+
expect(process.exitCode).toBe(1);
|
|
222
|
+
const errorOutput = consoleErrorSpy.mock.calls.map((c) => c[0]).join("\n");
|
|
223
|
+
expect(errorOutput).toContain("not stale");
|
|
224
|
+
});
|
|
225
|
+
it("should dismiss non-stale session with --force", async () => {
|
|
226
|
+
const sessionId = await createTestSession(sessionManager);
|
|
227
|
+
await runSessionsCommand(["dismiss", sessionId, "--force"]);
|
|
228
|
+
expect(process.exitCode).toBeUndefined();
|
|
229
|
+
const exists = await sessionManager.sessionExists(sessionId);
|
|
230
|
+
expect(exists).toBe(false);
|
|
231
|
+
});
|
|
232
|
+
it("should error on non-existent session", async () => {
|
|
233
|
+
const fakeId = "00000000-0000-4000-a000-000000000000";
|
|
234
|
+
await runSessionsCommand(["dismiss", fakeId]);
|
|
235
|
+
expect(process.exitCode).toBe(1);
|
|
236
|
+
const errorOutput = consoleErrorSpy.mock.calls.map((c) => c[0]).join("\n");
|
|
237
|
+
expect(errorOutput).toContain("not found");
|
|
238
|
+
});
|
|
239
|
+
it("should error when sessionId is missing", async () => {
|
|
240
|
+
await runSessionsCommand(["dismiss"]);
|
|
241
|
+
expect(process.exitCode).toBe(1);
|
|
242
|
+
const errorOutput = consoleErrorSpy.mock.calls.map((c) => c[0]).join("\n");
|
|
243
|
+
expect(errorOutput).toContain("Missing session ID");
|
|
244
|
+
});
|
|
245
|
+
it("should output valid JSON with --json flag", async () => {
|
|
246
|
+
const threeHoursAgo = new Date(Date.now() - 3 * 60 * 60 * 1000).toISOString();
|
|
247
|
+
const sessionId = await createTestSession(sessionManager, {
|
|
248
|
+
createdAt: threeHoursAgo,
|
|
249
|
+
});
|
|
250
|
+
await runSessionsCommand(["dismiss", sessionId, "--json"]);
|
|
251
|
+
expect(consoleLogSpy).toHaveBeenCalled();
|
|
252
|
+
// Find the JSON output (outputResult uses console.log)
|
|
253
|
+
const jsonCalls = consoleLogSpy.mock.calls.filter((c) => {
|
|
254
|
+
try {
|
|
255
|
+
JSON.parse(c[0]);
|
|
256
|
+
return true;
|
|
257
|
+
}
|
|
258
|
+
catch {
|
|
259
|
+
return false;
|
|
260
|
+
}
|
|
261
|
+
});
|
|
262
|
+
expect(jsonCalls.length).toBeGreaterThanOrEqual(1);
|
|
263
|
+
const parsed = JSON.parse(jsonCalls[0][0]);
|
|
264
|
+
expect(parsed.success).toBe(true);
|
|
265
|
+
expect(parsed.sessionId).toBe(sessionId);
|
|
266
|
+
expect(parsed.archivedTo).toBeDefined();
|
|
267
|
+
expect(parsed.archivedTo).toContain(sessionId);
|
|
268
|
+
});
|
|
269
|
+
});
|
|
270
|
+
// ── Sessions help ─────────────────────────────────────────────────
|
|
271
|
+
describe("sessions help", () => {
|
|
272
|
+
it("should show usage help when no subcommand provided", async () => {
|
|
273
|
+
await runSessionsCommand([]);
|
|
274
|
+
const allOutput = consoleLogSpy.mock.calls.map((c) => c[0]).join("\n");
|
|
275
|
+
expect(allOutput).toContain("Usage");
|
|
276
|
+
});
|
|
277
|
+
it("should set exitCode for unknown subcommand", async () => {
|
|
278
|
+
await runSessionsCommand(["unknown"]);
|
|
279
|
+
expect(process.exitCode).toBe(1);
|
|
280
|
+
});
|
|
281
|
+
});
|
|
282
|
+
});
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLI Answer Command — `auq answer <sessionId>`
|
|
3
|
+
* Allows answering or rejecting sessions programmatically.
|
|
4
|
+
*/
|
|
5
|
+
import { SessionManager } from "../../session/SessionManager.js";
|
|
6
|
+
import { getSessionDirectory } from "../../session/utils.js";
|
|
7
|
+
import { outputResult, parseFlags } from "../utils.js";
|
|
8
|
+
/**
|
|
9
|
+
* Run the `auq answer` command.
|
|
10
|
+
*
|
|
11
|
+
* Usage:
|
|
12
|
+
* auq answer <sessionId> --answers '{"0": {"selectedOption": "opt1"}}'
|
|
13
|
+
* auq answer <sessionId> --reject [--reason "..."]
|
|
14
|
+
* auq answer <sessionId> --answers '...' --force # force-answer abandoned session
|
|
15
|
+
* auq answer <sessionId> --reject --json
|
|
16
|
+
*/
|
|
17
|
+
export async function runAnswerCommand(args) {
|
|
18
|
+
const { flags, positionals } = parseFlags(args);
|
|
19
|
+
const jsonMode = flags.json === true;
|
|
20
|
+
const sessionId = positionals[0];
|
|
21
|
+
// ── Validate sessionId ───────────────────────────────────────────
|
|
22
|
+
if (!sessionId) {
|
|
23
|
+
outputResult({
|
|
24
|
+
success: false,
|
|
25
|
+
error: "Missing session ID. Usage: auq answer <sessionId> --answers '{...}' | --reject [--reason \"...\"]'",
|
|
26
|
+
}, jsonMode);
|
|
27
|
+
process.exit(1);
|
|
28
|
+
return; // unreachable, keeps TS happy
|
|
29
|
+
}
|
|
30
|
+
// ── Initialise SessionManager ─────────────────────────────────────
|
|
31
|
+
const sessionManager = new SessionManager({
|
|
32
|
+
baseDir: getSessionDirectory(),
|
|
33
|
+
});
|
|
34
|
+
await sessionManager.initialize();
|
|
35
|
+
// ── Verify session exists ────────────────────────────────────────
|
|
36
|
+
const exists = await sessionManager.sessionExists(sessionId);
|
|
37
|
+
if (!exists) {
|
|
38
|
+
outputResult({ success: false, error: `Session not found: ${sessionId}` }, jsonMode);
|
|
39
|
+
process.exit(1);
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
// ── Check for abandoned status ───────────────────────────────────
|
|
43
|
+
const status = await sessionManager.getSessionStatus(sessionId);
|
|
44
|
+
if (status?.status === "abandoned" && !flags.force) {
|
|
45
|
+
outputResult({
|
|
46
|
+
success: false,
|
|
47
|
+
error: "Warning: AI disconnected. Use --force to answer anyway.",
|
|
48
|
+
}, jsonMode);
|
|
49
|
+
process.exit(1);
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
if (status?.status === "abandoned" && flags.force) {
|
|
53
|
+
// Proceed with a warning in human-readable mode
|
|
54
|
+
if (!jsonMode) {
|
|
55
|
+
console.warn("Warning: AI disconnected, proceeding with --force.");
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
// ── Reject path ──────────────────────────────────────────────────
|
|
59
|
+
if (flags.reject) {
|
|
60
|
+
const reason = typeof flags.reason === "string" ? flags.reason : undefined;
|
|
61
|
+
await sessionManager.rejectSession(sessionId, reason);
|
|
62
|
+
outputResult({ success: true, sessionId, status: "rejected" }, jsonMode);
|
|
63
|
+
if (!jsonMode) {
|
|
64
|
+
console.log(`Session ${sessionId} rejected.`);
|
|
65
|
+
}
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
// ── Answer path ──────────────────────────────────────────────────
|
|
69
|
+
const answersRaw = flags.answers;
|
|
70
|
+
if (!answersRaw || typeof answersRaw !== "string") {
|
|
71
|
+
outputResult({
|
|
72
|
+
success: false,
|
|
73
|
+
error: "Either --answers or --reject is required. Usage: auq answer <sessionId> --answers '{...}'",
|
|
74
|
+
}, jsonMode);
|
|
75
|
+
process.exit(1);
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
// Parse the answers JSON
|
|
79
|
+
let parsed;
|
|
80
|
+
try {
|
|
81
|
+
parsed = JSON.parse(answersRaw);
|
|
82
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
83
|
+
throw new Error("Answers must be a JSON object with numeric keys.");
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
catch (err) {
|
|
87
|
+
outputResult({
|
|
88
|
+
success: false,
|
|
89
|
+
error: `Invalid answers JSON: ${err instanceof Error ? err.message : String(err)}`,
|
|
90
|
+
}, jsonMode);
|
|
91
|
+
process.exit(1);
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
// Read session request to get expected questions
|
|
95
|
+
const request = await sessionManager.getSessionRequest(sessionId);
|
|
96
|
+
if (!request) {
|
|
97
|
+
outputResult({ success: false, error: `Session request not found: ${sessionId}` }, jsonMode);
|
|
98
|
+
process.exit(1);
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
// Transform answers to UserAnswer[] format
|
|
102
|
+
const answers = Object.entries(parsed).map(([idx, ans]) => {
|
|
103
|
+
const a = ans;
|
|
104
|
+
return {
|
|
105
|
+
questionIndex: parseInt(idx, 10),
|
|
106
|
+
selectedOption: typeof a.selectedOption === "string"
|
|
107
|
+
? a.selectedOption
|
|
108
|
+
: undefined,
|
|
109
|
+
selectedOptions: Array.isArray(a.selectedOptions)
|
|
110
|
+
? a.selectedOptions
|
|
111
|
+
: undefined,
|
|
112
|
+
customText: typeof a.customText === "string" ? a.customText : undefined,
|
|
113
|
+
timestamp: new Date().toISOString(),
|
|
114
|
+
};
|
|
115
|
+
});
|
|
116
|
+
// Save answers through SessionManager
|
|
117
|
+
const callId = status?.callId;
|
|
118
|
+
await sessionManager.saveSessionAnswers(sessionId, {
|
|
119
|
+
sessionId,
|
|
120
|
+
timestamp: new Date().toISOString(),
|
|
121
|
+
answers,
|
|
122
|
+
...(callId && { callId }),
|
|
123
|
+
});
|
|
124
|
+
outputResult({ success: true, sessionId, status: "completed" }, jsonMode);
|
|
125
|
+
if (!jsonMode) {
|
|
126
|
+
console.log(`Session ${sessionId} answered successfully.`);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
|
|
2
|
+
import { dirname } from "path";
|
|
3
|
+
import { AUQConfigSchema } from "../../config/types.js";
|
|
4
|
+
import { getConfigPaths, loadConfig } from "../../config/ConfigLoader.js";
|
|
5
|
+
import { outputResult, parseFlags } from "../utils.js";
|
|
6
|
+
/**
|
|
7
|
+
* Get all valid config key paths, including dot-notated nested keys.
|
|
8
|
+
* E.g. ["maxOptions", "notifications.enabled", "notifications.sound", ...]
|
|
9
|
+
*/
|
|
10
|
+
function getValidConfigKeys() {
|
|
11
|
+
const shape = AUQConfigSchema.shape;
|
|
12
|
+
const keys = [];
|
|
13
|
+
for (const key of Object.keys(shape)) {
|
|
14
|
+
const fieldSchema = shape[key];
|
|
15
|
+
// Check if this is a nested object schema (z.object wrapped in z.ZodDefault)
|
|
16
|
+
const inner = getInnerSchema(fieldSchema);
|
|
17
|
+
if (inner && "shape" in inner && typeof inner.shape === "object") {
|
|
18
|
+
// It's a nested object — add dot-notated keys
|
|
19
|
+
for (const subKey of Object.keys(inner.shape)) {
|
|
20
|
+
keys.push(`${key}.${subKey}`);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
else {
|
|
24
|
+
keys.push(key);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return keys;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Unwrap a Zod schema from wrappers like ZodDefault, ZodOptional, etc.
|
|
31
|
+
*/
|
|
32
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
33
|
+
function getInnerSchema(schema) {
|
|
34
|
+
if (!schema)
|
|
35
|
+
return schema;
|
|
36
|
+
// ZodDefault wraps an inner schema in _def.innerType
|
|
37
|
+
if (schema._def?.innerType) {
|
|
38
|
+
return getInnerSchema(schema._def.innerType);
|
|
39
|
+
}
|
|
40
|
+
return schema;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Get a value from a config object by dot-notated key.
|
|
44
|
+
*/
|
|
45
|
+
function getNestedValue(obj, key) {
|
|
46
|
+
const parts = key.split(".");
|
|
47
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
48
|
+
let current = obj;
|
|
49
|
+
for (const part of parts) {
|
|
50
|
+
if (current === null || current === undefined)
|
|
51
|
+
return undefined;
|
|
52
|
+
current = current[part];
|
|
53
|
+
}
|
|
54
|
+
return current;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Set a value in a config object by dot-notated key.
|
|
58
|
+
*/
|
|
59
|
+
function setNestedValue(obj, key, value) {
|
|
60
|
+
const parts = key.split(".");
|
|
61
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
62
|
+
let current = obj;
|
|
63
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
64
|
+
const part = parts[i];
|
|
65
|
+
if (current[part] === undefined ||
|
|
66
|
+
current[part] === null ||
|
|
67
|
+
typeof current[part] !== "object") {
|
|
68
|
+
current[part] = {};
|
|
69
|
+
}
|
|
70
|
+
current = current[part];
|
|
71
|
+
}
|
|
72
|
+
current[parts[parts.length - 1]] = value;
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Coerce a string value to the correct type based on the key's schema.
|
|
76
|
+
* Handles numbers, booleans, and enum strings.
|
|
77
|
+
*/
|
|
78
|
+
function coerceValue(key, rawValue) {
|
|
79
|
+
const parts = key.split(".");
|
|
80
|
+
const shape = AUQConfigSchema.shape;
|
|
81
|
+
// Get the Zod schema for this key
|
|
82
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
83
|
+
let fieldSchema = shape[parts[0]];
|
|
84
|
+
if (!fieldSchema)
|
|
85
|
+
return rawValue;
|
|
86
|
+
// Unwrap nested path
|
|
87
|
+
if (parts.length > 1) {
|
|
88
|
+
const innerObj = getInnerSchema(fieldSchema);
|
|
89
|
+
if (innerObj?.shape) {
|
|
90
|
+
fieldSchema = innerObj.shape[parts[1]];
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
const inner = getInnerSchema(fieldSchema);
|
|
94
|
+
if (!inner)
|
|
95
|
+
return rawValue;
|
|
96
|
+
// Detect type using Zod schema type property (works with Zod v4+)
|
|
97
|
+
const schemaType = inner._def?.type || inner.type || "";
|
|
98
|
+
// Boolean
|
|
99
|
+
if (schemaType === "boolean" || schemaType === "ZodBoolean") {
|
|
100
|
+
if (rawValue === "true")
|
|
101
|
+
return true;
|
|
102
|
+
if (rawValue === "false")
|
|
103
|
+
return false;
|
|
104
|
+
return rawValue; // let Zod validation catch invalid values
|
|
105
|
+
}
|
|
106
|
+
// Number
|
|
107
|
+
if (schemaType === "number" || schemaType === "ZodNumber") {
|
|
108
|
+
const num = Number(rawValue);
|
|
109
|
+
if (!Number.isNaN(num))
|
|
110
|
+
return num;
|
|
111
|
+
return rawValue; // let Zod validation catch it
|
|
112
|
+
}
|
|
113
|
+
// Enum or string — keep as string
|
|
114
|
+
return rawValue;
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Build a partial config object from a single key=value pair,
|
|
118
|
+
* suitable for Zod partial validation.
|
|
119
|
+
*/
|
|
120
|
+
function buildPartialConfig(key, value) {
|
|
121
|
+
const obj = {};
|
|
122
|
+
setNestedValue(obj, key, value);
|
|
123
|
+
return obj;
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Read an existing config file or return empty object.
|
|
127
|
+
*/
|
|
128
|
+
function readConfigFileForWrite(filePath) {
|
|
129
|
+
try {
|
|
130
|
+
if (existsSync(filePath)) {
|
|
131
|
+
const content = readFileSync(filePath, "utf-8");
|
|
132
|
+
return JSON.parse(content);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
catch {
|
|
136
|
+
// File doesn't exist or invalid — start fresh
|
|
137
|
+
}
|
|
138
|
+
return {};
|
|
139
|
+
}
|
|
140
|
+
// ── Config Get ──────────────────────────────────────────────────────
|
|
141
|
+
async function configGet(args) {
|
|
142
|
+
const { flags, positionals } = parseFlags(args);
|
|
143
|
+
const jsonMode = flags.json === true;
|
|
144
|
+
const key = positionals[0];
|
|
145
|
+
const config = loadConfig();
|
|
146
|
+
if (key) {
|
|
147
|
+
// Validate that key is known
|
|
148
|
+
const validKeys = getValidConfigKeys();
|
|
149
|
+
if (!validKeys.includes(key)) {
|
|
150
|
+
outputResult({
|
|
151
|
+
success: false,
|
|
152
|
+
error: `Unknown config key: "${key}". Valid keys: ${validKeys.join(", ")}`,
|
|
153
|
+
}, jsonMode);
|
|
154
|
+
process.exitCode = 1;
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
const value = getNestedValue(config, key);
|
|
158
|
+
if (jsonMode) {
|
|
159
|
+
console.log(JSON.stringify({ success: true, key, value }, null, 2));
|
|
160
|
+
}
|
|
161
|
+
else {
|
|
162
|
+
console.log(`${key} = ${typeof value === "object" ? JSON.stringify(value) : String(value)}`);
|
|
163
|
+
}
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
// Show all config values
|
|
167
|
+
if (jsonMode) {
|
|
168
|
+
console.log(JSON.stringify({ success: true, config }, null, 2));
|
|
169
|
+
}
|
|
170
|
+
else {
|
|
171
|
+
const validKeys = getValidConfigKeys();
|
|
172
|
+
for (const k of validKeys) {
|
|
173
|
+
const value = getNestedValue(config, k);
|
|
174
|
+
console.log(`${k} = ${typeof value === "object" ? JSON.stringify(value) : String(value)}`);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
// ── Config Set ──────────────────────────────────────────────────────
|
|
179
|
+
async function configSet(args) {
|
|
180
|
+
const { flags, positionals } = parseFlags(args);
|
|
181
|
+
const jsonMode = flags.json === true;
|
|
182
|
+
const isGlobal = flags.global === true;
|
|
183
|
+
const key = positionals[0];
|
|
184
|
+
const rawValue = positionals[1];
|
|
185
|
+
if (!key || rawValue === undefined) {
|
|
186
|
+
outputResult({
|
|
187
|
+
success: false,
|
|
188
|
+
error: "Usage: auq config set <key> <value> [--global] [--json]",
|
|
189
|
+
}, jsonMode);
|
|
190
|
+
process.exitCode = 1;
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
// Validate key
|
|
194
|
+
const validKeys = getValidConfigKeys();
|
|
195
|
+
if (!validKeys.includes(key)) {
|
|
196
|
+
outputResult({
|
|
197
|
+
success: false,
|
|
198
|
+
error: `Unknown config key: "${key}". Valid keys: ${validKeys.join(", ")}`,
|
|
199
|
+
}, jsonMode);
|
|
200
|
+
process.exitCode = 1;
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
// Coerce value to correct type
|
|
204
|
+
const coerced = coerceValue(key, rawValue);
|
|
205
|
+
// Validate with Zod
|
|
206
|
+
const partial = buildPartialConfig(key, coerced);
|
|
207
|
+
const partialSchema = AUQConfigSchema.partial();
|
|
208
|
+
const validation = partialSchema.safeParse(partial);
|
|
209
|
+
if (!validation.success) {
|
|
210
|
+
const issues = validation.error.issues
|
|
211
|
+
.map((i) => `${i.path.join(".")}: ${i.message}`)
|
|
212
|
+
.join("; ");
|
|
213
|
+
outputResult({
|
|
214
|
+
success: false,
|
|
215
|
+
error: `Invalid value for "${key}": ${issues}`,
|
|
216
|
+
}, jsonMode);
|
|
217
|
+
process.exitCode = 1;
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
// Determine target file
|
|
221
|
+
const paths = getConfigPaths();
|
|
222
|
+
const targetFile = isGlobal ? paths.global : paths.local;
|
|
223
|
+
// Ensure directory exists
|
|
224
|
+
const targetDir = dirname(targetFile);
|
|
225
|
+
if (!existsSync(targetDir)) {
|
|
226
|
+
mkdirSync(targetDir, { recursive: true });
|
|
227
|
+
}
|
|
228
|
+
// Read existing config, merge, and write
|
|
229
|
+
const existing = readConfigFileForWrite(targetFile);
|
|
230
|
+
setNestedValue(existing, key, coerced);
|
|
231
|
+
writeFileSync(targetFile, JSON.stringify(existing, null, 2) + "\n");
|
|
232
|
+
outputResult({
|
|
233
|
+
success: true,
|
|
234
|
+
key,
|
|
235
|
+
value: coerced,
|
|
236
|
+
file: targetFile,
|
|
237
|
+
}, jsonMode);
|
|
238
|
+
}
|
|
239
|
+
// ── Config Command Dispatcher ───────────────────────────────────────
|
|
240
|
+
export async function runConfigCommand(args) {
|
|
241
|
+
const subcommand = args[0];
|
|
242
|
+
switch (subcommand) {
|
|
243
|
+
case "get":
|
|
244
|
+
return configGet(args.slice(1));
|
|
245
|
+
case "set":
|
|
246
|
+
return configSet(args.slice(1));
|
|
247
|
+
default:
|
|
248
|
+
console.log(`Usage: auq config <subcommand>`, "\n");
|
|
249
|
+
console.log("Subcommands:");
|
|
250
|
+
console.log(" get [key] [--json] Show config values");
|
|
251
|
+
console.log(" set <key> <value> [--global] [--json] Set a config value");
|
|
252
|
+
console.log("");
|
|
253
|
+
console.log("Examples:");
|
|
254
|
+
console.log(" auq config get");
|
|
255
|
+
console.log(" auq config get staleThreshold --json");
|
|
256
|
+
console.log(" auq config set staleThreshold 3600000");
|
|
257
|
+
console.log(" auq config set notifyOnStale false --global");
|
|
258
|
+
if (subcommand !== undefined) {
|
|
259
|
+
process.exitCode = 1;
|
|
260
|
+
}
|
|
261
|
+
break;
|
|
262
|
+
}
|
|
263
|
+
}
|