auq-mcp-server 0.1.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 +25 -0
- package/README.md +176 -0
- package/dist/__tests__/schema-validation.test.js +137 -0
- package/dist/__tests__/server.integration.test.js +263 -0
- package/dist/add.js +1 -0
- package/dist/add.test.js +5 -0
- package/dist/bin/auq.js +245 -0
- package/dist/bin/test-session-menu.js +28 -0
- package/dist/bin/test-tabbar.js +42 -0
- package/dist/file-utils.js +59 -0
- package/dist/format/ResponseFormatter.js +206 -0
- package/dist/format/__tests__/ResponseFormatter.test.js +380 -0
- package/dist/package.json +74 -0
- package/dist/server.js +107 -0
- package/dist/session/ResponseFormatter.js +130 -0
- package/dist/session/SessionManager.js +474 -0
- package/dist/session/__tests__/ResponseFormatter.test.js +417 -0
- package/dist/session/__tests__/SessionManager.test.js +553 -0
- package/dist/session/__tests__/atomic-operations.test.js +345 -0
- package/dist/session/__tests__/file-watcher.test.js +311 -0
- package/dist/session/__tests__/workflow.integration.test.js +334 -0
- package/dist/session/atomic-operations.js +307 -0
- package/dist/session/file-watcher.js +218 -0
- package/dist/session/index.js +7 -0
- package/dist/session/types.js +20 -0
- package/dist/session/utils.js +125 -0
- package/dist/session-manager.js +171 -0
- package/dist/session-watcher.js +110 -0
- package/dist/src/__tests__/schema-validation.test.js +170 -0
- package/dist/src/__tests__/server.integration.test.js +274 -0
- package/dist/src/add.js +1 -0
- package/dist/src/add.test.js +5 -0
- package/dist/src/server.js +163 -0
- package/dist/src/session/ResponseFormatter.js +163 -0
- package/dist/src/session/SessionManager.js +572 -0
- package/dist/src/session/__tests__/ResponseFormatter.test.js +741 -0
- package/dist/src/session/__tests__/SessionManager.test.js +593 -0
- package/dist/src/session/__tests__/atomic-operations.test.js +346 -0
- package/dist/src/session/__tests__/file-watcher.test.js +311 -0
- package/dist/src/session/atomic-operations.js +307 -0
- package/dist/src/session/file-watcher.js +227 -0
- package/dist/src/session/index.js +7 -0
- package/dist/src/session/types.js +20 -0
- package/dist/src/session/utils.js +180 -0
- package/dist/src/tui/__tests__/session-watcher.test.js +368 -0
- package/dist/src/tui/components/AnimatedGradient.js +45 -0
- package/dist/src/tui/components/ConfirmationDialog.js +89 -0
- package/dist/src/tui/components/CustomInput.js +14 -0
- package/dist/src/tui/components/Footer.js +55 -0
- package/dist/src/tui/components/Header.js +35 -0
- package/dist/src/tui/components/MultiLineTextInput.js +65 -0
- package/dist/src/tui/components/OptionsList.js +115 -0
- package/dist/src/tui/components/QuestionDisplay.js +36 -0
- package/dist/src/tui/components/ReviewScreen.js +57 -0
- package/dist/src/tui/components/SessionSelectionMenu.js +151 -0
- package/dist/src/tui/components/StepperView.js +166 -0
- package/dist/src/tui/components/TabBar.js +42 -0
- package/dist/src/tui/components/Toast.js +19 -0
- package/dist/src/tui/components/WaitingScreen.js +20 -0
- package/dist/src/tui/session-watcher.js +195 -0
- package/dist/src/tui/theme.js +114 -0
- package/dist/src/tui/utils/gradientText.js +24 -0
- package/dist/tui/__tests__/session-watcher.test.js +368 -0
- package/dist/tui/session-watcher.js +183 -0
- package/package.json +78 -0
- package/scripts/postinstall.cjs +51 -0
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for TUI session watcher functionality
|
|
3
|
+
*/
|
|
4
|
+
import { promises as fs } from "fs";
|
|
5
|
+
import { join } from "path";
|
|
6
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
7
|
+
import { SESSION_FILES } from "../../session/types.js";
|
|
8
|
+
import { createTUIWatcher, EnhancedTUISessionWatcher, getNextPendingSession, } from "../session-watcher.js";
|
|
9
|
+
describe("TUI Session Watcher", () => {
|
|
10
|
+
const testDir = "/tmp/auq-tui-watcher-test";
|
|
11
|
+
const sessionDir = join(testDir, "sessions");
|
|
12
|
+
const testSessionId = "test-session-123";
|
|
13
|
+
const mockSessionRequest = {
|
|
14
|
+
questions: [
|
|
15
|
+
{
|
|
16
|
+
title: "Language",
|
|
17
|
+
options: [
|
|
18
|
+
{ description: "Dynamic web language", label: "JavaScript" },
|
|
19
|
+
{ description: "Typed JavaScript", label: "TypeScript" },
|
|
20
|
+
{ description: "Versatile and readable", label: "Python" },
|
|
21
|
+
],
|
|
22
|
+
prompt: "What is your favorite programming language?",
|
|
23
|
+
},
|
|
24
|
+
],
|
|
25
|
+
sessionId: testSessionId,
|
|
26
|
+
status: "pending",
|
|
27
|
+
timestamp: new Date().toISOString(),
|
|
28
|
+
};
|
|
29
|
+
beforeEach(async () => {
|
|
30
|
+
// Clean up test directory before each test
|
|
31
|
+
await fs.rm(testDir, { force: true, recursive: true }).catch(() => { });
|
|
32
|
+
await fs.mkdir(sessionDir, { recursive: true });
|
|
33
|
+
});
|
|
34
|
+
afterEach(async () => {
|
|
35
|
+
// Clean up test directory after each test
|
|
36
|
+
await fs.rm(testDir, { force: true, recursive: true }).catch(() => { });
|
|
37
|
+
});
|
|
38
|
+
describe("EnhancedTUISessionWatcher", () => {
|
|
39
|
+
describe("session event detection", () => {
|
|
40
|
+
it("should detect new sessions with loaded data", async () => {
|
|
41
|
+
const watcher = new EnhancedTUISessionWatcher({
|
|
42
|
+
autoLoadData: true,
|
|
43
|
+
debounceMs: 100,
|
|
44
|
+
sessionDir,
|
|
45
|
+
});
|
|
46
|
+
const events = [];
|
|
47
|
+
watcher.startEnhancedWatching((event) => {
|
|
48
|
+
events.push(event);
|
|
49
|
+
});
|
|
50
|
+
// Give watcher time to initialize
|
|
51
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
52
|
+
// Create a new session directory
|
|
53
|
+
const newSessionDir = join(sessionDir, testSessionId);
|
|
54
|
+
await fs.mkdir(newSessionDir);
|
|
55
|
+
// Create required session files
|
|
56
|
+
const requestFile = join(newSessionDir, SESSION_FILES.REQUEST);
|
|
57
|
+
const statusFile = join(newSessionDir, SESSION_FILES.STATUS);
|
|
58
|
+
await Promise.all([
|
|
59
|
+
fs.writeFile(requestFile, JSON.stringify(mockSessionRequest, null, 2)),
|
|
60
|
+
fs.writeFile(statusFile, JSON.stringify({
|
|
61
|
+
createdAt: new Date().toISOString(),
|
|
62
|
+
lastModified: new Date().toISOString(),
|
|
63
|
+
sessionId: testSessionId,
|
|
64
|
+
status: "pending",
|
|
65
|
+
totalQuestions: 1,
|
|
66
|
+
})),
|
|
67
|
+
]);
|
|
68
|
+
// Wait for debounce (100ms) + processing time
|
|
69
|
+
await new Promise((resolve) => setTimeout(resolve, 300));
|
|
70
|
+
expect(events).toHaveLength(1);
|
|
71
|
+
expect(events[0].type).toBe("session-created");
|
|
72
|
+
expect(events[0].sessionId).toBe(testSessionId);
|
|
73
|
+
expect(events[0].sessionRequest).toEqual(mockSessionRequest);
|
|
74
|
+
watcher.stop();
|
|
75
|
+
});
|
|
76
|
+
it("should handle autoLoadData disabled", async () => {
|
|
77
|
+
const watcher = new EnhancedTUISessionWatcher({
|
|
78
|
+
autoLoadData: false,
|
|
79
|
+
debounceMs: 100,
|
|
80
|
+
sessionDir,
|
|
81
|
+
});
|
|
82
|
+
const events = [];
|
|
83
|
+
watcher.startEnhancedWatching((event) => {
|
|
84
|
+
events.push(event);
|
|
85
|
+
});
|
|
86
|
+
// Give watcher time to initialize
|
|
87
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
88
|
+
// Create session directory and files
|
|
89
|
+
const newSessionDir = join(sessionDir, testSessionId);
|
|
90
|
+
await fs.mkdir(newSessionDir);
|
|
91
|
+
const requestFile = join(newSessionDir, SESSION_FILES.REQUEST);
|
|
92
|
+
await fs.writeFile(requestFile, JSON.stringify(mockSessionRequest, null, 2));
|
|
93
|
+
// Wait for debounce + processing
|
|
94
|
+
await new Promise((resolve) => setTimeout(resolve, 300));
|
|
95
|
+
expect(events).toHaveLength(1);
|
|
96
|
+
expect(events[0].type).toBe("session-created");
|
|
97
|
+
expect(events[0].sessionId).toBe(testSessionId);
|
|
98
|
+
expect(events[0].sessionRequest).toBeUndefined(); // Should not be loaded
|
|
99
|
+
watcher.stop();
|
|
100
|
+
});
|
|
101
|
+
it("should handle corrupted session files gracefully", async () => {
|
|
102
|
+
const watcher = new EnhancedTUISessionWatcher({
|
|
103
|
+
autoLoadData: true,
|
|
104
|
+
debounceMs: 100,
|
|
105
|
+
sessionDir,
|
|
106
|
+
});
|
|
107
|
+
const events = [];
|
|
108
|
+
const consoleSpy = vi
|
|
109
|
+
.spyOn(console, "warn")
|
|
110
|
+
.mockImplementation(() => { });
|
|
111
|
+
watcher.startEnhancedWatching((event) => {
|
|
112
|
+
events.push(event);
|
|
113
|
+
});
|
|
114
|
+
// Create session with corrupted request.json
|
|
115
|
+
const newSessionDir = join(sessionDir, testSessionId);
|
|
116
|
+
await fs.mkdir(newSessionDir);
|
|
117
|
+
const requestFile = join(newSessionDir, SESSION_FILES.REQUEST);
|
|
118
|
+
const statusFile = join(newSessionDir, SESSION_FILES.STATUS);
|
|
119
|
+
// Write corrupted request.json and valid status.json
|
|
120
|
+
await Promise.all([
|
|
121
|
+
fs.writeFile(requestFile, "invalid json content"),
|
|
122
|
+
fs.writeFile(statusFile, JSON.stringify({
|
|
123
|
+
createdAt: new Date().toISOString(),
|
|
124
|
+
lastModified: new Date().toISOString(),
|
|
125
|
+
sessionId: testSessionId,
|
|
126
|
+
status: "pending",
|
|
127
|
+
totalQuestions: 1,
|
|
128
|
+
})),
|
|
129
|
+
]);
|
|
130
|
+
// Wait for debounce + processing
|
|
131
|
+
await new Promise((resolve) => setTimeout(resolve, 300));
|
|
132
|
+
expect(events).toHaveLength(1);
|
|
133
|
+
expect(events[0].type).toBe("session-created");
|
|
134
|
+
expect(events[0].sessionId).toBe(testSessionId);
|
|
135
|
+
expect(events[0].sessionRequest).toBeUndefined();
|
|
136
|
+
// Should have logged a warning about corrupted data
|
|
137
|
+
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining("Failed to load session request"), expect.any(Error));
|
|
138
|
+
consoleSpy.mockRestore();
|
|
139
|
+
watcher.stop();
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
describe("event handlers", () => {
|
|
143
|
+
it("should support multiple event handlers", async () => {
|
|
144
|
+
const watcher = new EnhancedTUISessionWatcher({
|
|
145
|
+
debounceMs: 100,
|
|
146
|
+
sessionDir,
|
|
147
|
+
});
|
|
148
|
+
const mainEvents = [];
|
|
149
|
+
const customEvents = [];
|
|
150
|
+
watcher.addEventHandler("custom", (event) => {
|
|
151
|
+
customEvents.push(event);
|
|
152
|
+
});
|
|
153
|
+
watcher.startEnhancedWatching((event) => {
|
|
154
|
+
mainEvents.push(event);
|
|
155
|
+
});
|
|
156
|
+
// Give watcher time to initialize
|
|
157
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
158
|
+
// Create session
|
|
159
|
+
const newSessionDir = join(sessionDir, testSessionId);
|
|
160
|
+
await fs.mkdir(newSessionDir);
|
|
161
|
+
const requestFile = join(newSessionDir, SESSION_FILES.REQUEST);
|
|
162
|
+
await fs.writeFile(requestFile, JSON.stringify(mockSessionRequest, null, 2));
|
|
163
|
+
// Wait for debounce + processing
|
|
164
|
+
await new Promise((resolve) => setTimeout(resolve, 300));
|
|
165
|
+
expect(mainEvents).toHaveLength(1);
|
|
166
|
+
expect(customEvents).toHaveLength(1);
|
|
167
|
+
expect(customEvents[0]).toEqual(mainEvents[0]);
|
|
168
|
+
watcher.removeEventHandler("custom");
|
|
169
|
+
watcher.stop();
|
|
170
|
+
});
|
|
171
|
+
it("should handle event handler removal", async () => {
|
|
172
|
+
const watcher = new EnhancedTUISessionWatcher({ sessionDir });
|
|
173
|
+
const customEvents = [];
|
|
174
|
+
watcher.addEventHandler("custom", (event) => {
|
|
175
|
+
customEvents.push(event);
|
|
176
|
+
});
|
|
177
|
+
watcher.removeEventHandler("custom");
|
|
178
|
+
watcher.startEnhancedWatching(() => { });
|
|
179
|
+
// Create session
|
|
180
|
+
const newSessionDir = join(sessionDir, testSessionId);
|
|
181
|
+
await fs.mkdir(newSessionDir);
|
|
182
|
+
const requestFile = join(newSessionDir, SESSION_FILES.REQUEST);
|
|
183
|
+
await fs.writeFile(requestFile, JSON.stringify(mockSessionRequest, null, 2));
|
|
184
|
+
// Wait for processing
|
|
185
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
186
|
+
expect(customEvents).toHaveLength(0); // Should not have triggered
|
|
187
|
+
watcher.stop();
|
|
188
|
+
});
|
|
189
|
+
});
|
|
190
|
+
describe("session management", () => {
|
|
191
|
+
beforeEach(async () => {
|
|
192
|
+
// Create multiple test sessions
|
|
193
|
+
const sessions = [
|
|
194
|
+
{ completed: false, id: "session-1" },
|
|
195
|
+
{ completed: true, id: "session-2" },
|
|
196
|
+
{ completed: false, id: "session-3" },
|
|
197
|
+
];
|
|
198
|
+
for (const session of sessions) {
|
|
199
|
+
const sessionDir = join(testDir, "sessions", session.id);
|
|
200
|
+
await fs.mkdir(sessionDir, { recursive: true });
|
|
201
|
+
// Create request.json
|
|
202
|
+
const requestFile = join(sessionDir, SESSION_FILES.REQUEST);
|
|
203
|
+
await fs.writeFile(requestFile, JSON.stringify({
|
|
204
|
+
...mockSessionRequest,
|
|
205
|
+
sessionId: session.id,
|
|
206
|
+
}));
|
|
207
|
+
// Create status.json
|
|
208
|
+
const statusFile = join(sessionDir, SESSION_FILES.STATUS);
|
|
209
|
+
await fs.writeFile(statusFile, JSON.stringify({
|
|
210
|
+
createdAt: new Date().toISOString(),
|
|
211
|
+
lastModified: new Date().toISOString(),
|
|
212
|
+
sessionId: session.id,
|
|
213
|
+
status: session.completed ? "completed" : "pending",
|
|
214
|
+
totalQuestions: 1,
|
|
215
|
+
}));
|
|
216
|
+
// Create answers.json for completed sessions
|
|
217
|
+
if (session.completed) {
|
|
218
|
+
const answersFile = join(sessionDir, SESSION_FILES.ANSWERS);
|
|
219
|
+
await fs.writeFile(answersFile, JSON.stringify({
|
|
220
|
+
answers: [
|
|
221
|
+
{
|
|
222
|
+
questionIndex: 0,
|
|
223
|
+
selectedOption: "JavaScript",
|
|
224
|
+
timestamp: new Date().toISOString(),
|
|
225
|
+
},
|
|
226
|
+
],
|
|
227
|
+
sessionId: session.id,
|
|
228
|
+
timestamp: new Date().toISOString(),
|
|
229
|
+
}));
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
});
|
|
233
|
+
it("should get pending sessions correctly", async () => {
|
|
234
|
+
const watcher = new EnhancedTUISessionWatcher({ sessionDir });
|
|
235
|
+
const pendingSessions = await watcher.getPendingSessions();
|
|
236
|
+
expect(pendingSessions).toHaveLength(2);
|
|
237
|
+
expect(pendingSessions).toContain("session-1");
|
|
238
|
+
expect(pendingSessions).toContain("session-3");
|
|
239
|
+
expect(pendingSessions).not.toContain("session-2");
|
|
240
|
+
// Should be sorted
|
|
241
|
+
expect(pendingSessions[0]).toBe("session-1");
|
|
242
|
+
expect(pendingSessions[1]).toBe("session-3");
|
|
243
|
+
watcher.stop();
|
|
244
|
+
});
|
|
245
|
+
it("should get session request data", async () => {
|
|
246
|
+
const watcher = new EnhancedTUISessionWatcher({ sessionDir });
|
|
247
|
+
const sessionRequest = await watcher.getSessionRequest("session-1");
|
|
248
|
+
expect(sessionRequest).toBeTruthy();
|
|
249
|
+
expect(sessionRequest?.sessionId).toBe("session-1");
|
|
250
|
+
expect(sessionRequest?.questions).toHaveLength(1);
|
|
251
|
+
watcher.stop();
|
|
252
|
+
});
|
|
253
|
+
it("should handle non-existent session request", async () => {
|
|
254
|
+
const watcher = new EnhancedTUISessionWatcher({ sessionDir });
|
|
255
|
+
const consoleSpy = vi
|
|
256
|
+
.spyOn(console, "warn")
|
|
257
|
+
.mockImplementation(() => { });
|
|
258
|
+
const sessionRequest = await watcher.getSessionRequest("non-existent");
|
|
259
|
+
expect(sessionRequest).toBeNull();
|
|
260
|
+
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining("Failed to load session request"), expect.any(Error));
|
|
261
|
+
consoleSpy.mockRestore();
|
|
262
|
+
watcher.stop();
|
|
263
|
+
});
|
|
264
|
+
it("should check session pending status correctly", async () => {
|
|
265
|
+
const watcher = new EnhancedTUISessionWatcher({ sessionDir });
|
|
266
|
+
expect(await watcher.isSessionPending("session-1")).toBe(true);
|
|
267
|
+
expect(await watcher.isSessionPending("session-2")).toBe(false);
|
|
268
|
+
expect(await watcher.isSessionPending("session-3")).toBe(true);
|
|
269
|
+
expect(await watcher.isSessionPending("non-existent")).toBe(false);
|
|
270
|
+
watcher.stop();
|
|
271
|
+
});
|
|
272
|
+
});
|
|
273
|
+
});
|
|
274
|
+
describe("Utility Functions", () => {
|
|
275
|
+
describe("createTUIWatcher", () => {
|
|
276
|
+
it("should create an enhanced TUI session watcher", () => {
|
|
277
|
+
const watcher = createTUIWatcher({ sessionDir });
|
|
278
|
+
expect(watcher).toBeInstanceOf(EnhancedTUISessionWatcher);
|
|
279
|
+
expect(watcher.watchedPath).toBe(sessionDir);
|
|
280
|
+
watcher.stop();
|
|
281
|
+
});
|
|
282
|
+
});
|
|
283
|
+
describe("getNextPendingSession", () => {
|
|
284
|
+
beforeEach(async () => {
|
|
285
|
+
// Create test sessions
|
|
286
|
+
const sessions = [
|
|
287
|
+
{ id: "first-session", pending: true },
|
|
288
|
+
{ id: "second-session", pending: true },
|
|
289
|
+
];
|
|
290
|
+
for (const session of sessions) {
|
|
291
|
+
const sessionDir = join(testDir, "sessions", session.id);
|
|
292
|
+
await fs.mkdir(sessionDir, { recursive: true });
|
|
293
|
+
// Create required files
|
|
294
|
+
const requestFile = join(sessionDir, SESSION_FILES.REQUEST);
|
|
295
|
+
const statusFile = join(sessionDir, SESSION_FILES.STATUS);
|
|
296
|
+
await Promise.all([
|
|
297
|
+
fs.writeFile(requestFile, JSON.stringify({
|
|
298
|
+
...mockSessionRequest,
|
|
299
|
+
sessionId: session.id,
|
|
300
|
+
})),
|
|
301
|
+
fs.writeFile(statusFile, JSON.stringify({
|
|
302
|
+
createdAt: new Date().toISOString(),
|
|
303
|
+
lastModified: new Date().toISOString(),
|
|
304
|
+
sessionId: session.id,
|
|
305
|
+
status: "pending",
|
|
306
|
+
totalQuestions: 1,
|
|
307
|
+
})),
|
|
308
|
+
]);
|
|
309
|
+
}
|
|
310
|
+
});
|
|
311
|
+
it("should get the next pending session", async () => {
|
|
312
|
+
const result = await getNextPendingSession({ sessionDir });
|
|
313
|
+
expect(result).toBeTruthy();
|
|
314
|
+
expect(result?.sessionId).toBe("first-session");
|
|
315
|
+
expect(result?.sessionRequest).toBeTruthy();
|
|
316
|
+
expect(result?.sessionRequest.sessionId).toBe("first-session");
|
|
317
|
+
});
|
|
318
|
+
it("should return null when no pending sessions", async () => {
|
|
319
|
+
// Clear all sessions
|
|
320
|
+
await fs.rm(sessionDir, { recursive: true });
|
|
321
|
+
await fs.mkdir(sessionDir);
|
|
322
|
+
const result = await getNextPendingSession({ sessionDir });
|
|
323
|
+
expect(result).toBeNull();
|
|
324
|
+
});
|
|
325
|
+
it("should handle sessions with corrupted data", async () => {
|
|
326
|
+
// Create session with corrupted request.json
|
|
327
|
+
const corruptedSessionDir = join(sessionDir, "corrupted-session");
|
|
328
|
+
await fs.mkdir(corruptedSessionDir);
|
|
329
|
+
const statusFile = join(corruptedSessionDir, SESSION_FILES.STATUS);
|
|
330
|
+
await fs.writeFile(statusFile, JSON.stringify({
|
|
331
|
+
createdAt: new Date().toISOString(),
|
|
332
|
+
lastModified: new Date().toISOString(),
|
|
333
|
+
sessionId: "corrupted-session",
|
|
334
|
+
status: "pending",
|
|
335
|
+
totalQuestions: 1,
|
|
336
|
+
}));
|
|
337
|
+
// Create corrupted request.json
|
|
338
|
+
const requestFile = join(corruptedSessionDir, SESSION_FILES.REQUEST);
|
|
339
|
+
await fs.writeFile(requestFile, "invalid json");
|
|
340
|
+
const result = await getNextPendingSession({ sessionDir });
|
|
341
|
+
// Should skip corrupted session and return valid one
|
|
342
|
+
expect(result).toBeTruthy();
|
|
343
|
+
expect(result?.sessionId).toBe("first-session");
|
|
344
|
+
});
|
|
345
|
+
});
|
|
346
|
+
});
|
|
347
|
+
describe("Error Handling", () => {
|
|
348
|
+
it("should handle directory access errors", async () => {
|
|
349
|
+
const watcher = new EnhancedTUISessionWatcher({
|
|
350
|
+
sessionDir: "/invalid/directory/path",
|
|
351
|
+
});
|
|
352
|
+
const consoleSpy = vi.spyOn(console, "warn").mockImplementation(() => { });
|
|
353
|
+
const pendingSessions = await watcher.getPendingSessions();
|
|
354
|
+
expect(pendingSessions).toEqual([]);
|
|
355
|
+
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining("Failed to scan for pending sessions"), expect.any(Error));
|
|
356
|
+
consoleSpy.mockRestore();
|
|
357
|
+
watcher.stop();
|
|
358
|
+
});
|
|
359
|
+
it("should handle missing watched path gracefully", async () => {
|
|
360
|
+
const watcher = new EnhancedTUISessionWatcher({
|
|
361
|
+
sessionDir: "/non/existent/path",
|
|
362
|
+
});
|
|
363
|
+
const sessionRequest = await watcher.getSessionRequest("any-id");
|
|
364
|
+
expect(sessionRequest).toBeNull();
|
|
365
|
+
watcher.stop();
|
|
366
|
+
});
|
|
367
|
+
});
|
|
368
|
+
});
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TUI Session Watcher Module
|
|
3
|
+
*
|
|
4
|
+
* Provides TUI applications with the ability to detect new question sessions
|
|
5
|
+
* and coordinate with the MCP server through file system events.
|
|
6
|
+
*/
|
|
7
|
+
import { atomicReadFile } from "../session/atomic-operations.js";
|
|
8
|
+
import { TUISessionWatcher } from "../session/file-watcher.js";
|
|
9
|
+
import { SESSION_FILES } from "../session/types.js";
|
|
10
|
+
/**
|
|
11
|
+
* Enhanced TUI Session Watcher with session data loading
|
|
12
|
+
*/
|
|
13
|
+
export class EnhancedTUISessionWatcher extends TUISessionWatcher {
|
|
14
|
+
autoLoadData;
|
|
15
|
+
eventHandlers = new Map();
|
|
16
|
+
constructor(config) {
|
|
17
|
+
// Map sessionDir to baseDir for parent class
|
|
18
|
+
super({
|
|
19
|
+
baseDir: config?.sessionDir,
|
|
20
|
+
debounceMs: config?.debounceMs,
|
|
21
|
+
});
|
|
22
|
+
this.autoLoadData = config?.autoLoadData ?? true;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Add custom event handler
|
|
26
|
+
*/
|
|
27
|
+
addEventHandler(name, handler) {
|
|
28
|
+
this.eventHandlers.set(name, handler);
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Get list of pending sessions (sessions without answers)
|
|
32
|
+
*/
|
|
33
|
+
async getPendingSessions() {
|
|
34
|
+
const fs = await import("fs/promises");
|
|
35
|
+
const { join } = await import("path");
|
|
36
|
+
try {
|
|
37
|
+
const sessionDir = this.watchedPath;
|
|
38
|
+
const entries = await fs.readdir(sessionDir, { withFileTypes: true });
|
|
39
|
+
const pendingSessions = [];
|
|
40
|
+
for (const entry of entries) {
|
|
41
|
+
if (!entry.isDirectory())
|
|
42
|
+
continue;
|
|
43
|
+
const sessionPath = join(sessionDir, entry.name);
|
|
44
|
+
const answersPath = join(sessionPath, SESSION_FILES.ANSWERS);
|
|
45
|
+
const statusPath = join(sessionPath, SESSION_FILES.STATUS);
|
|
46
|
+
try {
|
|
47
|
+
// Check if answers file doesn't exist (pending session)
|
|
48
|
+
await fs.access(answersPath);
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
// Answers file doesn't exist - check if this is a valid session
|
|
52
|
+
try {
|
|
53
|
+
await fs.access(statusPath);
|
|
54
|
+
pendingSessions.push(entry.name);
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
// No status file - not a valid session
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return pendingSessions.sort(); // Sort for consistent ordering
|
|
62
|
+
}
|
|
63
|
+
catch (error) {
|
|
64
|
+
console.warn("Failed to scan for pending sessions:", error);
|
|
65
|
+
return [];
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Get session request data for a specific session
|
|
70
|
+
*/
|
|
71
|
+
async getSessionRequest(sessionId) {
|
|
72
|
+
const { join } = await import("path");
|
|
73
|
+
const sessionPath = join(this.watchedPath, sessionId);
|
|
74
|
+
try {
|
|
75
|
+
return await this.loadSessionRequest(sessionPath);
|
|
76
|
+
}
|
|
77
|
+
catch (error) {
|
|
78
|
+
console.warn(`Failed to load session request for ${sessionId}:`, error);
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Check if a session exists and is pending
|
|
84
|
+
*/
|
|
85
|
+
async isSessionPending(sessionId) {
|
|
86
|
+
const { join } = await import("path");
|
|
87
|
+
const sessionPath = join(this.watchedPath, sessionId);
|
|
88
|
+
const answersPath = join(sessionPath, SESSION_FILES.ANSWERS);
|
|
89
|
+
const statusPath = join(sessionPath, SESSION_FILES.STATUS);
|
|
90
|
+
try {
|
|
91
|
+
const fs = await import("fs/promises");
|
|
92
|
+
// Check if status file exists (valid session)
|
|
93
|
+
await fs.access(statusPath);
|
|
94
|
+
// Check if answers file doesn't exist (pending)
|
|
95
|
+
try {
|
|
96
|
+
await fs.access(answersPath);
|
|
97
|
+
return false; // Answers exist - session is completed
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
return true; // No answers - session is pending
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
return false; // Not a valid session
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Remove event handler
|
|
109
|
+
*/
|
|
110
|
+
removeEventHandler(name) {
|
|
111
|
+
this.eventHandlers.delete(name);
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Start watching with enhanced event handling
|
|
115
|
+
*/
|
|
116
|
+
startEnhancedWatching(onSessionEvent) {
|
|
117
|
+
this.startWatching(async (sessionId, sessionPath) => {
|
|
118
|
+
const event = {
|
|
119
|
+
sessionId,
|
|
120
|
+
sessionPath,
|
|
121
|
+
timestamp: Date.now(),
|
|
122
|
+
type: "session-created",
|
|
123
|
+
};
|
|
124
|
+
// Auto-load session data if requested
|
|
125
|
+
if (this.autoLoadData) {
|
|
126
|
+
try {
|
|
127
|
+
const sessionRequest = await this.loadSessionRequest(sessionPath);
|
|
128
|
+
event.sessionRequest = sessionRequest;
|
|
129
|
+
}
|
|
130
|
+
catch (error) {
|
|
131
|
+
console.warn(`Failed to load session request for ${sessionId}:`, error);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
// Emit the event
|
|
135
|
+
onSessionEvent(event);
|
|
136
|
+
// Call any additional handlers
|
|
137
|
+
this.eventHandlers.forEach((handler) => handler(event));
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Load session request data from file
|
|
142
|
+
*/
|
|
143
|
+
async loadSessionRequest(sessionPath) {
|
|
144
|
+
const requestPath = `${sessionPath}/${SESSION_FILES.REQUEST}`;
|
|
145
|
+
try {
|
|
146
|
+
const content = await atomicReadFile(requestPath, {
|
|
147
|
+
encoding: "utf8",
|
|
148
|
+
maxRetries: 3,
|
|
149
|
+
retryDelay: 100,
|
|
150
|
+
});
|
|
151
|
+
return JSON.parse(content);
|
|
152
|
+
}
|
|
153
|
+
catch (error) {
|
|
154
|
+
throw new Error(`Failed to load session request from ${requestPath}: ${error}`);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* Create a simple TUI session watcher instance
|
|
160
|
+
*/
|
|
161
|
+
export function createTUIWatcher(config) {
|
|
162
|
+
return new EnhancedTUISessionWatcher(config);
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* Convenience function to get the next pending session
|
|
166
|
+
*/
|
|
167
|
+
export async function getNextPendingSession(config) {
|
|
168
|
+
const watcher = createTUIWatcher(config);
|
|
169
|
+
const pendingSessions = await watcher.getPendingSessions();
|
|
170
|
+
if (pendingSessions.length === 0) {
|
|
171
|
+
return null;
|
|
172
|
+
}
|
|
173
|
+
// Try each pending session until we find one with valid data
|
|
174
|
+
for (const sessionId of pendingSessions) {
|
|
175
|
+
const sessionRequest = await watcher.getSessionRequest(sessionId);
|
|
176
|
+
if (sessionRequest) {
|
|
177
|
+
return { sessionId, sessionRequest };
|
|
178
|
+
}
|
|
179
|
+
// Skip corrupted sessions and continue to next one
|
|
180
|
+
}
|
|
181
|
+
// No valid sessions found
|
|
182
|
+
return null;
|
|
183
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "auq-mcp-server",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"main": "dist/index.js",
|
|
5
|
+
"bin": {
|
|
6
|
+
"auq": "dist/bin/auq.js"
|
|
7
|
+
},
|
|
8
|
+
"files": [
|
|
9
|
+
"dist",
|
|
10
|
+
"scripts/postinstall.cjs",
|
|
11
|
+
"README.md",
|
|
12
|
+
"LICENSE"
|
|
13
|
+
],
|
|
14
|
+
"scripts": {
|
|
15
|
+
"build": "tsc",
|
|
16
|
+
"prepare": "npm run build",
|
|
17
|
+
"postinstall": "node scripts/postinstall.cjs",
|
|
18
|
+
"server": "node dist/src/server.js",
|
|
19
|
+
"start": "tsx src/server.ts",
|
|
20
|
+
"dev": "fastmcp dev src/server.ts",
|
|
21
|
+
"lint": "prettier --check . && eslint . && tsc --noEmit",
|
|
22
|
+
"test": "vitest run",
|
|
23
|
+
"format": "prettier --write . && eslint --fix ."
|
|
24
|
+
},
|
|
25
|
+
"keywords": [
|
|
26
|
+
"fastmcp",
|
|
27
|
+
"mcp",
|
|
28
|
+
"ask-user-questions",
|
|
29
|
+
"tui",
|
|
30
|
+
"terminal"
|
|
31
|
+
],
|
|
32
|
+
"repository": {
|
|
33
|
+
"url": "git+https://github.com/paulp-o/ask-user-question-mcp.git"
|
|
34
|
+
},
|
|
35
|
+
"author": "Paul Park",
|
|
36
|
+
"homepage": "https://github.com/paulp-o/ask-user-question-mcp",
|
|
37
|
+
"type": "module",
|
|
38
|
+
"license": "MIT",
|
|
39
|
+
"description": "An MCP server that provides a tool to ask a user questions via the terminal",
|
|
40
|
+
"dependencies": {
|
|
41
|
+
"@inkjs/ui": "^2.0.0",
|
|
42
|
+
"@types/uuid": "^10.0.0",
|
|
43
|
+
"chalk": "^5.6.2",
|
|
44
|
+
"fastmcp": "^1.27.3",
|
|
45
|
+
"gradient-string": "^3.0.0",
|
|
46
|
+
"ink": "^6.4.0",
|
|
47
|
+
"ink-gradient": "^3.0.0",
|
|
48
|
+
"ink-text-input": "^6.0.0",
|
|
49
|
+
"react": "^19.2.0",
|
|
50
|
+
"uuid": "^13.0.0",
|
|
51
|
+
"zod": "^3.24.4"
|
|
52
|
+
},
|
|
53
|
+
"release": {
|
|
54
|
+
"branches": [
|
|
55
|
+
"main"
|
|
56
|
+
],
|
|
57
|
+
"plugins": [
|
|
58
|
+
"@semantic-release/commit-analyzer",
|
|
59
|
+
"@semantic-release/release-notes-generator",
|
|
60
|
+
"@semantic-release/npm",
|
|
61
|
+
"@semantic-release/github"
|
|
62
|
+
]
|
|
63
|
+
},
|
|
64
|
+
"devDependencies": {
|
|
65
|
+
"@eslint/js": "^9.26.0",
|
|
66
|
+
"@tsconfig/node22": "^22.0.1",
|
|
67
|
+
"@types/react": "^19.2.2",
|
|
68
|
+
"eslint-config-prettier": "^10.1.3",
|
|
69
|
+
"eslint-plugin-perfectionist": "^4.12.3",
|
|
70
|
+
"jiti": "^2.4.2",
|
|
71
|
+
"prettier": "^3.5.3",
|
|
72
|
+
"semantic-release": "^24.2.3",
|
|
73
|
+
"tsx": "^4.19.4",
|
|
74
|
+
"typescript": "^5.8.3",
|
|
75
|
+
"typescript-eslint": "^8.32.0",
|
|
76
|
+
"vitest": "^3.1.3"
|
|
77
|
+
}
|
|
78
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Post-install script for auq-mcp-server
|
|
5
|
+
* Provides instructions for setting up shell aliases
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const os = require('os');
|
|
9
|
+
const path = require('path');
|
|
10
|
+
|
|
11
|
+
// Check if this is a global installation
|
|
12
|
+
const isGlobal = process.env.npm_config_global === 'true';
|
|
13
|
+
|
|
14
|
+
if (!isGlobal) {
|
|
15
|
+
// Local install - no setup needed
|
|
16
|
+
process.exit(0);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
console.log('\nā
AUQ MCP Server installed successfully!\n');
|
|
20
|
+
console.log('š Optional: Set up shell aliases for convenience\n');
|
|
21
|
+
|
|
22
|
+
const homeDir = os.homedir();
|
|
23
|
+
const shell = process.env.SHELL || '';
|
|
24
|
+
|
|
25
|
+
// Determine shell config file
|
|
26
|
+
let configFile = '';
|
|
27
|
+
let aliasCommand = '';
|
|
28
|
+
|
|
29
|
+
if (shell.includes('zsh')) {
|
|
30
|
+
configFile = path.join(homeDir, '.zshrc');
|
|
31
|
+
aliasCommand = 'alias auq="npx auq-mcp-server"';
|
|
32
|
+
} else if (shell.includes('bash')) {
|
|
33
|
+
configFile = path.join(homeDir, '.bashrc');
|
|
34
|
+
aliasCommand = 'alias auq="npx auq-mcp-server"';
|
|
35
|
+
} else if (shell.includes('fish')) {
|
|
36
|
+
configFile = path.join(homeDir, '.config/fish/config.fish');
|
|
37
|
+
aliasCommand = 'alias auq "npx auq-mcp-server"';
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (configFile) {
|
|
41
|
+
console.log(`To set up a shell alias, add this to your ${path.basename(configFile)}:\n`);
|
|
42
|
+
console.log(` ${aliasCommand}\n`);
|
|
43
|
+
console.log(`Then restart your terminal or run: source ${configFile}\n`);
|
|
44
|
+
} else {
|
|
45
|
+
console.log('To set up a shell alias, add this to your shell config:\n');
|
|
46
|
+
console.log(' alias auq="npx auq-mcp-server"\n');
|
|
47
|
+
console.log('Then restart your terminal.\n');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
console.log('For MCP server setup with Claude Desktop or Cursor, see:');
|
|
51
|
+
console.log(' https://github.com/paulp-o/ask-user-question-mcp#setup\n');
|