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.
Files changed (66) hide show
  1. package/LICENSE +25 -0
  2. package/README.md +176 -0
  3. package/dist/__tests__/schema-validation.test.js +137 -0
  4. package/dist/__tests__/server.integration.test.js +263 -0
  5. package/dist/add.js +1 -0
  6. package/dist/add.test.js +5 -0
  7. package/dist/bin/auq.js +245 -0
  8. package/dist/bin/test-session-menu.js +28 -0
  9. package/dist/bin/test-tabbar.js +42 -0
  10. package/dist/file-utils.js +59 -0
  11. package/dist/format/ResponseFormatter.js +206 -0
  12. package/dist/format/__tests__/ResponseFormatter.test.js +380 -0
  13. package/dist/package.json +74 -0
  14. package/dist/server.js +107 -0
  15. package/dist/session/ResponseFormatter.js +130 -0
  16. package/dist/session/SessionManager.js +474 -0
  17. package/dist/session/__tests__/ResponseFormatter.test.js +417 -0
  18. package/dist/session/__tests__/SessionManager.test.js +553 -0
  19. package/dist/session/__tests__/atomic-operations.test.js +345 -0
  20. package/dist/session/__tests__/file-watcher.test.js +311 -0
  21. package/dist/session/__tests__/workflow.integration.test.js +334 -0
  22. package/dist/session/atomic-operations.js +307 -0
  23. package/dist/session/file-watcher.js +218 -0
  24. package/dist/session/index.js +7 -0
  25. package/dist/session/types.js +20 -0
  26. package/dist/session/utils.js +125 -0
  27. package/dist/session-manager.js +171 -0
  28. package/dist/session-watcher.js +110 -0
  29. package/dist/src/__tests__/schema-validation.test.js +170 -0
  30. package/dist/src/__tests__/server.integration.test.js +274 -0
  31. package/dist/src/add.js +1 -0
  32. package/dist/src/add.test.js +5 -0
  33. package/dist/src/server.js +163 -0
  34. package/dist/src/session/ResponseFormatter.js +163 -0
  35. package/dist/src/session/SessionManager.js +572 -0
  36. package/dist/src/session/__tests__/ResponseFormatter.test.js +741 -0
  37. package/dist/src/session/__tests__/SessionManager.test.js +593 -0
  38. package/dist/src/session/__tests__/atomic-operations.test.js +346 -0
  39. package/dist/src/session/__tests__/file-watcher.test.js +311 -0
  40. package/dist/src/session/atomic-operations.js +307 -0
  41. package/dist/src/session/file-watcher.js +227 -0
  42. package/dist/src/session/index.js +7 -0
  43. package/dist/src/session/types.js +20 -0
  44. package/dist/src/session/utils.js +180 -0
  45. package/dist/src/tui/__tests__/session-watcher.test.js +368 -0
  46. package/dist/src/tui/components/AnimatedGradient.js +45 -0
  47. package/dist/src/tui/components/ConfirmationDialog.js +89 -0
  48. package/dist/src/tui/components/CustomInput.js +14 -0
  49. package/dist/src/tui/components/Footer.js +55 -0
  50. package/dist/src/tui/components/Header.js +35 -0
  51. package/dist/src/tui/components/MultiLineTextInput.js +65 -0
  52. package/dist/src/tui/components/OptionsList.js +115 -0
  53. package/dist/src/tui/components/QuestionDisplay.js +36 -0
  54. package/dist/src/tui/components/ReviewScreen.js +57 -0
  55. package/dist/src/tui/components/SessionSelectionMenu.js +151 -0
  56. package/dist/src/tui/components/StepperView.js +166 -0
  57. package/dist/src/tui/components/TabBar.js +42 -0
  58. package/dist/src/tui/components/Toast.js +19 -0
  59. package/dist/src/tui/components/WaitingScreen.js +20 -0
  60. package/dist/src/tui/session-watcher.js +195 -0
  61. package/dist/src/tui/theme.js +114 -0
  62. package/dist/src/tui/utils/gradientText.js +24 -0
  63. package/dist/tui/__tests__/session-watcher.test.js +368 -0
  64. package/dist/tui/session-watcher.js +183 -0
  65. package/package.json +78 -0
  66. package/scripts/postinstall.cjs +51 -0
@@ -0,0 +1,307 @@
1
+ /**
2
+ * Atomic file operations for session management
3
+ * Provides truly atomic read/write operations to prevent data corruption
4
+ */
5
+ import { constants, copyFile, rename } from "fs";
6
+ import { promises as fs } from "fs";
7
+ import { tmpdir } from "os";
8
+ import { join } from "path";
9
+ import { fileExists } from "./utils.js";
10
+ /**
11
+ * Error types for atomic operations
12
+ */
13
+ export class AtomicOperationError extends Error {
14
+ operation;
15
+ filePath;
16
+ cause;
17
+ constructor(message, operation, filePath, cause) {
18
+ super(message);
19
+ this.operation = operation;
20
+ this.filePath = filePath;
21
+ this.cause = cause;
22
+ this.name = "AtomicOperationError";
23
+ }
24
+ }
25
+ export class AtomicReadError extends AtomicOperationError {
26
+ constructor(filePath, cause) {
27
+ super(`Atomic read failed: ${filePath}`, "read", filePath, cause);
28
+ this.name = "AtomicReadError";
29
+ }
30
+ }
31
+ export class AtomicWriteError extends AtomicOperationError {
32
+ constructor(filePath, cause) {
33
+ super(`Atomic write failed: ${filePath}`, "write", filePath, cause);
34
+ this.name = "AtomicWriteError";
35
+ }
36
+ }
37
+ export class FileLockError extends AtomicOperationError {
38
+ constructor(filePath, cause) {
39
+ super(`Failed to acquire file lock: ${filePath}`, "write", filePath, cause);
40
+ this.name = "FileLockError";
41
+ }
42
+ }
43
+ const DEFAULT_WRITE_OPTIONS = {
44
+ encoding: "utf8",
45
+ flag: "w",
46
+ maxRetries: 3,
47
+ mode: 0o600,
48
+ retryDelay: 100,
49
+ tmpDir: tmpdir(),
50
+ };
51
+ const DEFAULT_READ_OPTIONS = {
52
+ encoding: "utf8",
53
+ flag: "r",
54
+ maxRetries: 3,
55
+ retryDelay: 100,
56
+ };
57
+ /**
58
+ * Atomic file copy operation
59
+ */
60
+ export async function atomicCopyFile(sourcePath, destPath, options = {}) {
61
+ const opts = { ...DEFAULT_WRITE_OPTIONS, ...options };
62
+ try {
63
+ // Acquire destination lock
64
+ await acquireLock(destPath);
65
+ // Perform atomic copy
66
+ await new Promise((resolve, reject) => {
67
+ copyFile(sourcePath, destPath, constants.COPYFILE_EXCL, (error) => {
68
+ if (error) {
69
+ reject(error);
70
+ }
71
+ else {
72
+ resolve();
73
+ }
74
+ });
75
+ });
76
+ // Set correct permissions on destination
77
+ await fs.chmod(destPath, opts.mode);
78
+ }
79
+ catch (error) {
80
+ throw new AtomicOperationError(`Atomic copy failed: ${sourcePath} -> ${destPath}`, "write", destPath, error);
81
+ }
82
+ finally {
83
+ await releaseLock(destPath);
84
+ }
85
+ }
86
+ /**
87
+ * Atomic file delete operation
88
+ */
89
+ export async function atomicDeleteFile(filePath) {
90
+ try {
91
+ await acquireLock(filePath);
92
+ await fs.unlink(filePath);
93
+ }
94
+ catch (error) {
95
+ if (error.code === "ENOENT") {
96
+ // File doesn't exist, that's okay for delete
97
+ return;
98
+ }
99
+ throw new AtomicOperationError(`Atomic delete failed: ${filePath}`, "write", filePath, error);
100
+ }
101
+ finally {
102
+ await releaseLock(filePath);
103
+ }
104
+ }
105
+ /**
106
+ * Atomic read operation with retry logic
107
+ */
108
+ export async function atomicReadFile(filePath, options = {}) {
109
+ const opts = { ...DEFAULT_READ_OPTIONS, ...options };
110
+ let lastError = null;
111
+ for (let attempt = 0; attempt < opts.maxRetries; attempt++) {
112
+ try {
113
+ // Check if file exists
114
+ if (!(await fileExists(filePath))) {
115
+ throw new AtomicReadError(filePath, new Error("File does not exist"));
116
+ }
117
+ // Acquire read lock (shared lock)
118
+ await acquireLock(filePath);
119
+ try {
120
+ const data = await fs.readFile(filePath, {
121
+ encoding: opts.encoding,
122
+ flag: opts.flag,
123
+ });
124
+ return data;
125
+ }
126
+ finally {
127
+ await releaseLock(filePath);
128
+ }
129
+ }
130
+ catch (error) {
131
+ lastError = error;
132
+ // Don't retry on certain errors
133
+ if (error instanceof AtomicReadError ||
134
+ error.code === "ENOENT" ||
135
+ error.code === "EACCES") {
136
+ break;
137
+ }
138
+ // Wait before retrying
139
+ if (attempt < opts.maxRetries - 1) {
140
+ await new Promise((resolve) => setTimeout(resolve, opts.retryDelay * Math.pow(2, attempt)));
141
+ }
142
+ }
143
+ }
144
+ throw new AtomicReadError(filePath, lastError || new Error("Unknown error"));
145
+ }
146
+ /**
147
+ * Atomic write operation using temporary file and rename
148
+ */
149
+ export async function atomicWriteFile(filePath, data, options = {}) {
150
+ const opts = { ...DEFAULT_WRITE_OPTIONS, ...options };
151
+ const tempPath = generateTempPath(filePath, opts.tmpDir);
152
+ try {
153
+ // Acquire file lock
154
+ await acquireLock(filePath);
155
+ // Write to temporary file first
156
+ await fs.writeFile(tempPath, data, {
157
+ encoding: opts.encoding,
158
+ flag: opts.flag,
159
+ mode: opts.mode,
160
+ });
161
+ // Verify the temporary file was written correctly
162
+ const verificationData = await fs.readFile(tempPath, opts.encoding);
163
+ if (verificationData !== data) {
164
+ throw new AtomicWriteError(filePath, new Error("Data verification failed after write"));
165
+ }
166
+ // Atomic rename operation
167
+ await new Promise((resolve, reject) => {
168
+ rename(tempPath, filePath, (error) => {
169
+ if (error) {
170
+ reject(error);
171
+ }
172
+ else {
173
+ resolve();
174
+ }
175
+ });
176
+ });
177
+ // Verify the final file exists and has correct permissions
178
+ try {
179
+ await fs.access(filePath, constants.F_OK);
180
+ const stats = await fs.stat(filePath);
181
+ // Check if mode is correct (at least read/write for owner)
182
+ if ((stats.mode & 0o600) !== 0o600) {
183
+ await fs.chmod(filePath, opts.mode);
184
+ }
185
+ }
186
+ catch (error) {
187
+ throw new AtomicWriteError(filePath, new Error(`File verification failed after rename: ${error}`));
188
+ }
189
+ }
190
+ catch (error) {
191
+ // Clean up temporary file on failure
192
+ try {
193
+ await fs.unlink(tempPath);
194
+ }
195
+ catch {
196
+ // Ignore cleanup errors
197
+ }
198
+ throw new AtomicWriteError(filePath, error);
199
+ }
200
+ finally {
201
+ // Always release the lock
202
+ await releaseLock(filePath);
203
+ }
204
+ }
205
+ /**
206
+ * Check if a file is locked
207
+ */
208
+ export async function isFileLocked(filePath) {
209
+ const lockPath = `${filePath}.lock`;
210
+ return await fileExists(lockPath);
211
+ }
212
+ /**
213
+ * Wait for a file to become unlocked (with timeout)
214
+ */
215
+ export async function waitForFileUnlock(filePath, timeout = 10000) {
216
+ const startTime = Date.now();
217
+ while (Date.now() - startTime < timeout) {
218
+ if (!(await isFileLocked(filePath))) {
219
+ return;
220
+ }
221
+ await new Promise((resolve) => setTimeout(resolve, 100));
222
+ }
223
+ throw new FileLockError(filePath, new Error("Timeout waiting for file unlock"));
224
+ }
225
+ /**
226
+ * Simple file lock implementation using lock files
227
+ */
228
+ async function acquireLock(filePath, timeout = 5000) {
229
+ const lockPath = `${filePath}.lock`;
230
+ const startTime = Date.now();
231
+ while (Date.now() - startTime < timeout) {
232
+ try {
233
+ // Try to create lock file (O_EXCL ensures atomic creation)
234
+ await fs.writeFile(lockPath, process.pid.toString(), {
235
+ encoding: "utf8",
236
+ flag: "wx", // Write and fail if file exists
237
+ mode: 0o600,
238
+ });
239
+ return; // Lock acquired
240
+ }
241
+ catch (error) {
242
+ if (error.code === "EEXIST") {
243
+ // Lock file exists, check if it's stale
244
+ try {
245
+ const lockContent = await fs.readFile(lockPath, "utf8");
246
+ const lockPid = parseInt(lockContent.trim(), 10);
247
+ // Check if process with PID still exists
248
+ try {
249
+ process.kill(lockPid, 0); // Signal 0 just checks if process exists
250
+ }
251
+ catch {
252
+ // Process doesn't exist, remove stale lock
253
+ await fs.unlink(lockPath);
254
+ continue;
255
+ }
256
+ }
257
+ catch {
258
+ // Can't read lock file, try to remove it
259
+ try {
260
+ await fs.unlink(lockPath);
261
+ continue;
262
+ }
263
+ catch {
264
+ // Can't remove lock file, continue waiting
265
+ }
266
+ }
267
+ // Lock is active, wait and retry
268
+ await new Promise((resolve) => setTimeout(resolve, 50));
269
+ continue;
270
+ }
271
+ throw error; // Other error occurred
272
+ }
273
+ }
274
+ throw new FileLockError(filePath, new Error("Lock acquisition timeout"));
275
+ }
276
+ /**
277
+ * Get the base filename from a path
278
+ */
279
+ function basename(path) {
280
+ return path.split(/[\\/]/).pop() || "";
281
+ }
282
+ /**
283
+ * Generate a unique temporary file path
284
+ */
285
+ function generateTempPath(originalPath, tmpDir) {
286
+ const timestamp = Date.now();
287
+ const random = Math.random().toString(36).substring(2);
288
+ const filename = `${timestamp}-${random}-${basename(originalPath)}.tmp`;
289
+ return join(tmpDir, filename);
290
+ }
291
+ /**
292
+ * Release a file lock
293
+ */
294
+ async function releaseLock(filePath) {
295
+ const lockPath = `${filePath}.lock`;
296
+ try {
297
+ await fs.unlink(lockPath);
298
+ }
299
+ catch (error) {
300
+ // Lock file might not exist or we don't have permission
301
+ // Don't throw error as this is cleanup
302
+ // Only log if it's not ENOENT (file not found)
303
+ if (error.code !== "ENOENT") {
304
+ console.warn(`Warning: Could not release lock for ${filePath}:`, error);
305
+ }
306
+ }
307
+ }
@@ -0,0 +1,227 @@
1
+ /**
2
+ * File System Watching Module
3
+ *
4
+ * Provides cross-platform file system watching capabilities for both
5
+ * MCP server and TUI coordination with proper debouncing and error handling.
6
+ */
7
+ import { EventEmitter } from "events";
8
+ import { watch } from "fs";
9
+ import { join } from "path";
10
+ import { fileExists } from "./utils.js";
11
+ import { resolveSessionDirectory } from "./utils.js";
12
+ /**
13
+ * Promise-based file watcher for specific file patterns
14
+ */
15
+ export class PromiseFileWatcher extends EventEmitter {
16
+ config;
17
+ debounceTimers = new Map();
18
+ isWatching = false;
19
+ watchers = new Map();
20
+ constructor(config = {}) {
21
+ super();
22
+ this.config = {
23
+ debounceMs: config.debounceMs ?? 100,
24
+ ignoreInitial: config.ignoreInitial ?? true,
25
+ timeoutMs: config.timeoutMs ?? 30000, // 30 seconds default
26
+ };
27
+ }
28
+ /**
29
+ * Check if currently watching
30
+ */
31
+ active() {
32
+ return this.isWatching;
33
+ }
34
+ /**
35
+ * Clean up all watchers and timers
36
+ */
37
+ cleanup() {
38
+ // Clear all debounce timers
39
+ for (const timer of this.debounceTimers.values()) {
40
+ clearTimeout(timer);
41
+ }
42
+ this.debounceTimers.clear();
43
+ // Close all file watchers
44
+ for (const watcher of this.watchers.values()) {
45
+ watcher.close();
46
+ }
47
+ this.watchers.clear();
48
+ this.isWatching = false;
49
+ this.removeAllListeners();
50
+ }
51
+ /**
52
+ * Watch for a specific file to be created or modified
53
+ * Returns a promise that resolves when the file is detected
54
+ */
55
+ async waitForFile(watchPath, fileName) {
56
+ const fullPath = join(watchPath, fileName);
57
+ // Fast-path: if file already exists, resolve immediately
58
+ if (await fileExists(fullPath)) {
59
+ return fullPath;
60
+ }
61
+ return new Promise((resolve, reject) => {
62
+ const timeoutId = this.config.timeoutMs > 0
63
+ ? setTimeout(() => {
64
+ this.cleanup();
65
+ reject(new Error(`Timeout waiting for file: ${fullPath}`));
66
+ }, this.config.timeoutMs)
67
+ : undefined;
68
+ try {
69
+ // Set up file watcher
70
+ const watcher = watch(watchPath, { persistent: false }, (eventType, filename) => {
71
+ if (!filename)
72
+ return;
73
+ const eventPath = join(watchPath, filename);
74
+ // Check if this is the file we're waiting for
75
+ if (filename === fileName || eventPath === fullPath) {
76
+ this.handleFileEvent(eventType, eventPath);
77
+ // Verify file exists and is accessible; resolve on create or write
78
+ if (eventType === "rename" || eventType === "change") {
79
+ // Ensure the file actually exists before resolving
80
+ fileExists(fullPath).then((exists) => {
81
+ if (!exists)
82
+ return;
83
+ if (timeoutId)
84
+ clearTimeout(timeoutId);
85
+ this.cleanup();
86
+ resolve(fullPath);
87
+ });
88
+ }
89
+ }
90
+ });
91
+ this.watchers.set(watchPath, watcher);
92
+ // Handle watcher errors
93
+ watcher.on("error", (error) => {
94
+ if (timeoutId)
95
+ clearTimeout(timeoutId);
96
+ this.cleanup();
97
+ reject(new Error(`File watcher error: ${error.message}`));
98
+ });
99
+ this.isWatching = true;
100
+ }
101
+ catch (error) {
102
+ if (timeoutId)
103
+ clearTimeout(timeoutId);
104
+ reject(new Error(`File watcher setup error: ${error}`));
105
+ }
106
+ });
107
+ }
108
+ /**
109
+ * Watch a directory for new session directories
110
+ * Emits events when new directories are created
111
+ */
112
+ watchForSessions(sessionDirPath, onSessionCreated) {
113
+ try {
114
+ const watcher = watch(sessionDirPath, { persistent: false }, (eventType, filename) => {
115
+ if (!filename || eventType !== "rename")
116
+ return;
117
+ const fullPath = join(sessionDirPath, filename);
118
+ // Debounce rapid events
119
+ this.debounceEvent(fullPath, () => {
120
+ // Check if this is a new directory (potential session)
121
+ this.handleSessionEvent(fullPath, onSessionCreated);
122
+ });
123
+ });
124
+ this.watchers.set(sessionDirPath, watcher);
125
+ watcher.on("error", (error) => {
126
+ this.emit("error", new Error(`Session watcher error: ${error.message}`));
127
+ });
128
+ this.isWatching = true;
129
+ }
130
+ catch (error) {
131
+ this.emit("error", new Error(`Session watcher setup error: ${error}`));
132
+ }
133
+ }
134
+ /**
135
+ * Debounce file system events to prevent duplicates
136
+ */
137
+ debounceEvent(eventKey, callback) {
138
+ // Clear existing timer for this event
139
+ const existingTimer = this.debounceTimers.get(eventKey);
140
+ if (existingTimer) {
141
+ clearTimeout(existingTimer);
142
+ }
143
+ // Set new timer
144
+ const timer = setTimeout(() => {
145
+ this.debounceTimers.delete(eventKey);
146
+ callback();
147
+ }, this.config.debounceMs);
148
+ this.debounceTimers.set(eventKey, timer);
149
+ }
150
+ /**
151
+ * Handle file system events with debouncing
152
+ */
153
+ handleFileEvent(eventType, filePath) {
154
+ const event = {
155
+ eventType: eventType,
156
+ filePath,
157
+ timestamp: Date.now(),
158
+ };
159
+ this.emit("fileEvent", event);
160
+ }
161
+ /**
162
+ * Handle new session directory creation
163
+ */
164
+ async handleSessionEvent(sessionPath, onSessionCreated) {
165
+ try {
166
+ // Check if this is actually a directory and has session files
167
+ const stats = await import("fs").then((fs) => fs.promises.stat(sessionPath));
168
+ if (!stats.isDirectory())
169
+ return;
170
+ // Extract session ID from directory name
171
+ const sessionId = sessionPath.split("/").pop() ?? "";
172
+ if (!sessionId)
173
+ return;
174
+ // Verify it's a valid session (has request.json)
175
+ const requestFile = join(sessionPath, "request.json");
176
+ try {
177
+ await import("fs").then((fs) => fs.promises.access(requestFile));
178
+ onSessionCreated(sessionId, sessionPath);
179
+ }
180
+ catch {
181
+ // Not a valid session directory
182
+ return;
183
+ }
184
+ }
185
+ catch {
186
+ // Error accessing directory - ignore
187
+ return;
188
+ }
189
+ }
190
+ }
191
+ /**
192
+ * TUI Session Watcher -专门用于 TUI 检测新会话
193
+ */
194
+ export class TUISessionWatcher {
195
+ /**
196
+ * Get the session directory path being watched
197
+ */
198
+ get watchedPath() {
199
+ return this.sessionDirPath;
200
+ }
201
+ fileWatcher;
202
+ sessionDirPath;
203
+ constructor(config) {
204
+ // Resolve session directory using XDG-compliant path
205
+ const sessionConfig = {
206
+ baseDir: config?.baseDir ?? "~/.local/share/auq/sessions",
207
+ };
208
+ this.sessionDirPath = resolveSessionDirectory(sessionConfig.baseDir);
209
+ this.fileWatcher = new PromiseFileWatcher({
210
+ debounceMs: config?.debounceMs ?? 200,
211
+ ignoreInitial: config?.ignoreInitial ?? true,
212
+ timeoutMs: config?.timeoutMs ?? 60000, // 1 minute for TUI
213
+ });
214
+ }
215
+ /**
216
+ * Start watching for new sessions
217
+ */
218
+ startWatching(onNewSession) {
219
+ this.fileWatcher.watchForSessions(this.sessionDirPath, onNewSession);
220
+ }
221
+ /**
222
+ * Stop watching and clean up
223
+ */
224
+ stop() {
225
+ this.fileWatcher.cleanup();
226
+ }
227
+ }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Session module exports for AskUserQuestions MCP server
3
+ */
4
+ export * from "./atomic-operations.js";
5
+ export { SessionManager } from "./SessionManager.js";
6
+ export * from "./types.js";
7
+ export * from "./utils.js";
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Session-related TypeScript interfaces and types for AskUserQuestions MCP server
3
+ */
4
+ /**
5
+ * File names for session storage
6
+ */
7
+ export const SESSION_FILES = {
8
+ ANSWERS: "answers.json",
9
+ REQUEST: "request.json",
10
+ STATUS: "status.json",
11
+ };
12
+ /**
13
+ * Default session configuration
14
+ */
15
+ export const DEFAULT_SESSION_CONFIG = {
16
+ baseDir: "~/.local/share/auq/sessions", // Will be resolved to actual path
17
+ maxSessions: 100,
18
+ retentionPeriod: 604800000, // 7 days in milliseconds
19
+ sessionTimeout: 0, // 0 = infinite timeout (wait indefinitely for user)
20
+ };