@lovelybunch/api 1.0.50 → 1.0.52
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/dist/lib/git-settings.d.ts +7 -0
- package/dist/lib/git-settings.js +84 -0
- package/dist/lib/git.js +6 -2
- package/dist/lib/storage/file-storage.d.ts +1 -0
- package/dist/lib/storage/file-storage.js +39 -4
- package/dist/lib/terminal/terminal-manager.d.ts +5 -0
- package/dist/lib/terminal/terminal-manager.js +79 -0
- package/dist/routes/api/v1/ai/route.js +53 -15
- package/dist/routes/api/v1/git/index.js +27 -0
- package/dist/routes/api/v1/mcp/config/index.d.ts +1 -0
- package/dist/routes/api/v1/mcp/config/index.js +1 -0
- package/dist/routes/api/v1/mcp/config/route.d.ts +3 -0
- package/dist/routes/api/v1/mcp/config/route.js +59 -0
- package/dist/routes/api/v1/mcp/index.js +22 -3
- package/dist/routes/api/v1/terminal/sessions/route.js +43 -1
- package/dist/server-with-static.js +22 -0
- package/dist/server.js +23 -0
- package/package.json +4 -4
- package/static/assets/{index-DkPOXMdu.js → index-Cwq3YsC0.js} +201 -186
- package/static/assets/index-D_1UlOMs.css +33 -0
- package/static/index.html +3 -3
- package/static/assets/index-5KUVUaRG.css +0 -33
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export interface GitSettings {
|
|
2
|
+
defaultWorktreePath: string;
|
|
3
|
+
}
|
|
4
|
+
export declare function normalizeWorktreePath(input: string): string;
|
|
5
|
+
export declare function loadGitSettings(): Promise<GitSettings>;
|
|
6
|
+
export declare function saveGitSettings(settings: GitSettings): Promise<void>;
|
|
7
|
+
export declare function setDefaultWorktreePath(relativePath: string): Promise<GitSettings>;
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { promises as fs } from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { findNutDirectory } from './gait-path.js';
|
|
4
|
+
const DEFAULT_SETTINGS = {
|
|
5
|
+
defaultWorktreePath: '../worktrees',
|
|
6
|
+
};
|
|
7
|
+
async function ensureNutDirectory() {
|
|
8
|
+
const nutDir = await findNutDirectory();
|
|
9
|
+
if (nutDir)
|
|
10
|
+
return nutDir;
|
|
11
|
+
const fallback = path.join(process.cwd(), '.nut');
|
|
12
|
+
await fs.mkdir(fallback, { recursive: true });
|
|
13
|
+
return fallback;
|
|
14
|
+
}
|
|
15
|
+
async function getConfigPath() {
|
|
16
|
+
const nutDir = await ensureNutDirectory();
|
|
17
|
+
return path.join(nutDir, 'config.json');
|
|
18
|
+
}
|
|
19
|
+
async function loadProjectConfig() {
|
|
20
|
+
const configPath = await getConfigPath();
|
|
21
|
+
try {
|
|
22
|
+
const raw = await fs.readFile(configPath, 'utf-8');
|
|
23
|
+
return JSON.parse(raw);
|
|
24
|
+
}
|
|
25
|
+
catch (error) {
|
|
26
|
+
if (error?.code === 'ENOENT') {
|
|
27
|
+
return {};
|
|
28
|
+
}
|
|
29
|
+
console.warn('[git-settings] Failed to read config.json, using empty config:', error);
|
|
30
|
+
return {};
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
async function saveProjectConfig(config) {
|
|
34
|
+
const configPath = await getConfigPath();
|
|
35
|
+
const dir = path.dirname(configPath);
|
|
36
|
+
await fs.mkdir(dir, { recursive: true });
|
|
37
|
+
await fs.writeFile(configPath, JSON.stringify(config, null, 2), 'utf-8');
|
|
38
|
+
}
|
|
39
|
+
export function normalizeWorktreePath(input) {
|
|
40
|
+
const trimmed = input?.trim();
|
|
41
|
+
if (!trimmed) {
|
|
42
|
+
return DEFAULT_SETTINGS.defaultWorktreePath;
|
|
43
|
+
}
|
|
44
|
+
if (path.isAbsolute(trimmed)) {
|
|
45
|
+
throw new Error('Worktree path must be relative to the project root');
|
|
46
|
+
}
|
|
47
|
+
const normalized = path.normalize(trimmed);
|
|
48
|
+
if (!normalized || normalized === '.' || normalized === '') {
|
|
49
|
+
return DEFAULT_SETTINGS.defaultWorktreePath;
|
|
50
|
+
}
|
|
51
|
+
return normalized;
|
|
52
|
+
}
|
|
53
|
+
export async function loadGitSettings() {
|
|
54
|
+
const config = await loadProjectConfig();
|
|
55
|
+
const configuredPath = config.git?.defaultWorktreePath;
|
|
56
|
+
if (!configuredPath) {
|
|
57
|
+
return { ...DEFAULT_SETTINGS };
|
|
58
|
+
}
|
|
59
|
+
try {
|
|
60
|
+
const normalized = normalizeWorktreePath(configuredPath);
|
|
61
|
+
return { defaultWorktreePath: normalized };
|
|
62
|
+
}
|
|
63
|
+
catch (error) {
|
|
64
|
+
console.warn('[git-settings] Invalid default worktree path in config.json, using default:', error);
|
|
65
|
+
return { ...DEFAULT_SETTINGS };
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
export async function saveGitSettings(settings) {
|
|
69
|
+
const config = await loadProjectConfig();
|
|
70
|
+
const normalized = normalizeWorktreePath(settings.defaultWorktreePath);
|
|
71
|
+
const next = {
|
|
72
|
+
...config,
|
|
73
|
+
git: {
|
|
74
|
+
...config.git,
|
|
75
|
+
defaultWorktreePath: normalized,
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
await saveProjectConfig(next);
|
|
79
|
+
}
|
|
80
|
+
export async function setDefaultWorktreePath(relativePath) {
|
|
81
|
+
const normalized = normalizeWorktreePath(relativePath);
|
|
82
|
+
await saveGitSettings({ defaultWorktreePath: normalized });
|
|
83
|
+
return { defaultWorktreePath: normalized };
|
|
84
|
+
}
|
package/dist/lib/git.js
CHANGED
|
@@ -2,6 +2,7 @@ import { promisify } from 'util';
|
|
|
2
2
|
import { execFile as _execFile } from 'child_process';
|
|
3
3
|
import path from 'path';
|
|
4
4
|
import { findGaitDirectory } from './gait-path.js';
|
|
5
|
+
import { loadGitSettings } from './git-settings.js';
|
|
5
6
|
import { promises as fs } from 'fs';
|
|
6
7
|
const execFile = promisify(_execFile);
|
|
7
8
|
// Base directory for worktrees under the repository root
|
|
@@ -16,7 +17,9 @@ export async function getRepoRoot() {
|
|
|
16
17
|
}
|
|
17
18
|
export async function getWorktreesBase() {
|
|
18
19
|
const repoRoot = await getRepoRoot();
|
|
19
|
-
|
|
20
|
+
const settings = await loadGitSettings();
|
|
21
|
+
const relativeBase = settings.defaultWorktreePath || 'worktrees';
|
|
22
|
+
return path.resolve(repoRoot, relativeBase);
|
|
20
23
|
}
|
|
21
24
|
export function sanitizeBranchName(name) {
|
|
22
25
|
if (name.length > 120) {
|
|
@@ -31,7 +34,8 @@ export async function resolveSafeWorktreePath(name) {
|
|
|
31
34
|
const base = await getWorktreesBase();
|
|
32
35
|
const safeName = sanitizeBranchName(name);
|
|
33
36
|
const resolved = path.resolve(base, safeName);
|
|
34
|
-
|
|
37
|
+
const relative = path.relative(base, resolved);
|
|
38
|
+
if (relative.startsWith('..') || path.isAbsolute(relative)) {
|
|
35
39
|
throw new Error('Invalid worktree path');
|
|
36
40
|
}
|
|
37
41
|
return resolved;
|
|
@@ -15,6 +15,7 @@ export interface StorageAdapter {
|
|
|
15
15
|
}
|
|
16
16
|
export declare class FileStorageAdapter implements StorageAdapter {
|
|
17
17
|
private basePath;
|
|
18
|
+
private sanitizeForYAML;
|
|
18
19
|
constructor(basePath?: string);
|
|
19
20
|
ensureDirectories(): Promise<void>;
|
|
20
21
|
createCP(cp: ChangeProposal): Promise<void>;
|
|
@@ -4,6 +4,33 @@ import matter from 'gray-matter';
|
|
|
4
4
|
import Fuse from 'fuse.js';
|
|
5
5
|
export class FileStorageAdapter {
|
|
6
6
|
basePath;
|
|
7
|
+
// Remove undefined values recursively to avoid YAML dump errors
|
|
8
|
+
sanitizeForYAML(value) {
|
|
9
|
+
if (value === undefined)
|
|
10
|
+
return undefined;
|
|
11
|
+
if (value === null)
|
|
12
|
+
return null;
|
|
13
|
+
if (Array.isArray(value)) {
|
|
14
|
+
return value
|
|
15
|
+
.filter((v) => v !== undefined)
|
|
16
|
+
.map((v) => this.sanitizeForYAML(v));
|
|
17
|
+
}
|
|
18
|
+
if (value instanceof Date) {
|
|
19
|
+
return value.toISOString();
|
|
20
|
+
}
|
|
21
|
+
if (typeof value === 'object') {
|
|
22
|
+
const out = {};
|
|
23
|
+
for (const [k, v] of Object.entries(value)) {
|
|
24
|
+
if (v === undefined || typeof v === 'function')
|
|
25
|
+
continue;
|
|
26
|
+
const sanitized = this.sanitizeForYAML(v);
|
|
27
|
+
if (sanitized !== undefined)
|
|
28
|
+
out[k] = sanitized;
|
|
29
|
+
}
|
|
30
|
+
return out;
|
|
31
|
+
}
|
|
32
|
+
return value;
|
|
33
|
+
}
|
|
7
34
|
constructor(basePath) {
|
|
8
35
|
if (basePath) {
|
|
9
36
|
this.basePath = basePath;
|
|
@@ -31,13 +58,20 @@ export class FileStorageAdapter {
|
|
|
31
58
|
await this.ensureDirectories();
|
|
32
59
|
// Extract content from the proposal if it exists
|
|
33
60
|
const { content, ...frontmatter } = cp;
|
|
61
|
+
// Normalize date fields to Date instances in case callers provide ISO strings
|
|
62
|
+
const createdAt = cp.metadata.createdAt instanceof Date
|
|
63
|
+
? cp.metadata.createdAt
|
|
64
|
+
: new Date(cp.metadata.createdAt);
|
|
65
|
+
const updatedAt = cp.metadata.updatedAt instanceof Date
|
|
66
|
+
? cp.metadata.updatedAt
|
|
67
|
+
: new Date(cp.metadata.updatedAt);
|
|
34
68
|
// Convert the proposal to markdown with YAML frontmatter
|
|
35
|
-
const
|
|
69
|
+
const frontmatterData = this.sanitizeForYAML({
|
|
36
70
|
// Required fields
|
|
37
71
|
id: cp.id,
|
|
38
72
|
intent: cp.intent,
|
|
39
|
-
createdAt:
|
|
40
|
-
updatedAt:
|
|
73
|
+
createdAt: createdAt.toISOString(),
|
|
74
|
+
updatedAt: updatedAt.toISOString(),
|
|
41
75
|
status: cp.status,
|
|
42
76
|
priority: cp.metadata.priority || 'medium',
|
|
43
77
|
// Author information
|
|
@@ -61,8 +95,9 @@ export class FileStorageAdapter {
|
|
|
61
95
|
// Metadata
|
|
62
96
|
tags: cp.metadata.tags || [],
|
|
63
97
|
labels: [],
|
|
64
|
-
comments: cp.comments || []
|
|
98
|
+
comments: (cp.comments || []).filter((c) => c !== undefined)
|
|
65
99
|
});
|
|
100
|
+
const markdown = matter.stringify(content || this.getDefaultContent(cp), frontmatterData);
|
|
66
101
|
const filePath = path.join(this.basePath, 'proposals', `${cp.id}.md`);
|
|
67
102
|
await fs.writeFile(filePath, markdown, 'utf-8');
|
|
68
103
|
}
|
|
@@ -10,6 +10,9 @@ export interface TerminalSession {
|
|
|
10
10
|
enableLogging?: boolean;
|
|
11
11
|
logFilePath?: string;
|
|
12
12
|
backlog: string;
|
|
13
|
+
previewSockets?: Set<WebSocket>;
|
|
14
|
+
previewBuffer?: string;
|
|
15
|
+
previewFlushTimer?: NodeJS.Timeout | null;
|
|
13
16
|
}
|
|
14
17
|
export declare class TerminalManager {
|
|
15
18
|
private sessions;
|
|
@@ -25,4 +28,6 @@ export declare class TerminalManager {
|
|
|
25
28
|
private cleanupInactiveSessions;
|
|
26
29
|
getAllSessions(): TerminalSession[];
|
|
27
30
|
destroy(): void;
|
|
31
|
+
private enqueuePreviewBroadcast;
|
|
32
|
+
attachPreviewWebSocket(sessionId: string, ws: WebSocket): boolean;
|
|
28
33
|
}
|
|
@@ -114,6 +114,9 @@ export class TerminalManager {
|
|
|
114
114
|
enableLogging,
|
|
115
115
|
logFilePath,
|
|
116
116
|
backlog: '',
|
|
117
|
+
previewSockets: new Set(),
|
|
118
|
+
previewBuffer: '',
|
|
119
|
+
previewFlushTimer: null,
|
|
117
120
|
};
|
|
118
121
|
this.sessions.set(sessionId, session);
|
|
119
122
|
// Set up PTY event handlers
|
|
@@ -147,6 +150,8 @@ export class TerminalManager {
|
|
|
147
150
|
data: data,
|
|
148
151
|
}));
|
|
149
152
|
}
|
|
153
|
+
// Broadcast to preview viewers with throttling
|
|
154
|
+
this.enqueuePreviewBroadcast(session, data);
|
|
150
155
|
});
|
|
151
156
|
ptyProcess.onExit((e) => {
|
|
152
157
|
// Log session end if logging is enabled
|
|
@@ -261,6 +266,17 @@ export class TerminalManager {
|
|
|
261
266
|
if (session.websocket && session.websocket.readyState === WebSocket.OPEN) {
|
|
262
267
|
session.websocket.close();
|
|
263
268
|
}
|
|
269
|
+
// Close all preview sockets
|
|
270
|
+
if (session.previewSockets && session.previewSockets.size > 0) {
|
|
271
|
+
for (const ws of Array.from(session.previewSockets)) {
|
|
272
|
+
try {
|
|
273
|
+
if (ws.readyState === WebSocket.OPEN)
|
|
274
|
+
ws.close();
|
|
275
|
+
}
|
|
276
|
+
catch { }
|
|
277
|
+
}
|
|
278
|
+
session.previewSockets.clear();
|
|
279
|
+
}
|
|
264
280
|
// Kill PTY process
|
|
265
281
|
try {
|
|
266
282
|
session.pty.kill();
|
|
@@ -310,5 +326,68 @@ export class TerminalManager {
|
|
|
310
326
|
clearInterval(this.cleanupInterval);
|
|
311
327
|
}
|
|
312
328
|
}
|
|
329
|
+
enqueuePreviewBroadcast(session, chunk) {
|
|
330
|
+
try {
|
|
331
|
+
if (!session.previewSockets || session.previewSockets.size === 0)
|
|
332
|
+
return;
|
|
333
|
+
session.previewBuffer = (session.previewBuffer || '') + chunk;
|
|
334
|
+
if (session.previewFlushTimer)
|
|
335
|
+
return;
|
|
336
|
+
session.previewFlushTimer = setTimeout(() => {
|
|
337
|
+
const payload = session.previewBuffer || '';
|
|
338
|
+
session.previewBuffer = '';
|
|
339
|
+
session.previewFlushTimer = null;
|
|
340
|
+
// Clean up closed sockets while broadcasting
|
|
341
|
+
const dead = [];
|
|
342
|
+
for (const ws of session.previewSockets) {
|
|
343
|
+
try {
|
|
344
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
345
|
+
ws.send(JSON.stringify({ type: 'data', data: payload }));
|
|
346
|
+
}
|
|
347
|
+
else {
|
|
348
|
+
dead.push(ws);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
catch {
|
|
352
|
+
dead.push(ws);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
if (dead.length) {
|
|
356
|
+
for (const d of dead)
|
|
357
|
+
session.previewSockets.delete(d);
|
|
358
|
+
}
|
|
359
|
+
}, 75); // batch messages ~13fps
|
|
360
|
+
}
|
|
361
|
+
catch { }
|
|
362
|
+
}
|
|
363
|
+
attachPreviewWebSocket(sessionId, ws) {
|
|
364
|
+
const session = this.sessions.get(sessionId);
|
|
365
|
+
if (!session)
|
|
366
|
+
return false;
|
|
367
|
+
if (!session.previewSockets)
|
|
368
|
+
session.previewSockets = new Set();
|
|
369
|
+
session.previewSockets.add(ws);
|
|
370
|
+
session.lastActivity = new Date();
|
|
371
|
+
// On attach, send a snapshot of recent backlog (last 4KB)
|
|
372
|
+
try {
|
|
373
|
+
const backlog = session.backlog || '';
|
|
374
|
+
const sliceStart = Math.max(0, backlog.length - 4096);
|
|
375
|
+
const chunk = backlog.slice(sliceStart);
|
|
376
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
377
|
+
ws.send(JSON.stringify({ type: 'snapshot', data: chunk }));
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
catch { }
|
|
381
|
+
ws.on('message', () => {
|
|
382
|
+
// Ignore messages: preview sockets are read-only
|
|
383
|
+
});
|
|
384
|
+
ws.on('close', () => {
|
|
385
|
+
session.previewSockets?.delete(ws);
|
|
386
|
+
});
|
|
387
|
+
ws.on('error', () => {
|
|
388
|
+
session.previewSockets?.delete(ws);
|
|
389
|
+
});
|
|
390
|
+
return true;
|
|
391
|
+
}
|
|
313
392
|
}
|
|
314
393
|
// We'll use the global manager instead of creating a direct export
|
|
@@ -2,7 +2,7 @@ import { homedir } from 'os';
|
|
|
2
2
|
import { join } from 'path';
|
|
3
3
|
import { existsSync, readFileSync } from 'fs';
|
|
4
4
|
import { fileURLToPath } from 'url';
|
|
5
|
-
import { proposalsTool, listProposalsTool } from '@lovelybunch/mcp';
|
|
5
|
+
import { proposalsTool, listProposalsTool, validateProposalData } from '@lovelybunch/mcp';
|
|
6
6
|
import { FileStorageAdapter } from '../../../../lib/storage/file-storage.js';
|
|
7
7
|
import { getAuthorInfo } from '../../../../lib/user-preferences.js';
|
|
8
8
|
// Function to get global config API key as fallback
|
|
@@ -239,24 +239,62 @@ async function executeProposalsToolDirect(args, storage) {
|
|
|
239
239
|
}
|
|
240
240
|
// Get author info
|
|
241
241
|
const author = await getAuthorInfo();
|
|
242
|
-
//
|
|
243
|
-
const
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
productSpecRef: proposal.metadata?.productSpecRef || null
|
|
242
|
+
// Normalize and validate incoming proposal
|
|
243
|
+
const validatedProposal = validateProposalData(proposal);
|
|
244
|
+
// Normalize plan steps: accept strings or objects
|
|
245
|
+
const planSteps = (validatedProposal.planSteps || []).map((step, index) => {
|
|
246
|
+
if (typeof step === 'string') {
|
|
247
|
+
return {
|
|
248
|
+
id: `step-${index + 1}`,
|
|
249
|
+
description: step,
|
|
250
|
+
status: 'pending'
|
|
251
|
+
};
|
|
253
252
|
}
|
|
253
|
+
return {
|
|
254
|
+
id: step.id || `step-${index + 1}`,
|
|
255
|
+
description: step.description || '',
|
|
256
|
+
status: step.status || 'pending',
|
|
257
|
+
command: step.command,
|
|
258
|
+
expectedOutcome: step.expectedOutcome,
|
|
259
|
+
output: step.output,
|
|
260
|
+
error: step.error,
|
|
261
|
+
executedAt: step.executedAt
|
|
262
|
+
};
|
|
263
|
+
});
|
|
264
|
+
const now = new Date();
|
|
265
|
+
const newProposal = {
|
|
266
|
+
id: validatedProposal.id || `cp-${Date.now()}`,
|
|
267
|
+
intent: validatedProposal.intent || '',
|
|
268
|
+
content: validatedProposal.content || '',
|
|
269
|
+
author: {
|
|
270
|
+
id: validatedProposal.author?.id || 'current-user',
|
|
271
|
+
name: validatedProposal.author?.name || author.name || 'Unknown User',
|
|
272
|
+
email: validatedProposal.author?.email || author.email || '',
|
|
273
|
+
type: validatedProposal.author?.type || 'human'
|
|
274
|
+
},
|
|
275
|
+
planSteps,
|
|
276
|
+
evidence: validatedProposal.evidence || [],
|
|
277
|
+
policies: validatedProposal.policies || [],
|
|
278
|
+
featureFlags: validatedProposal.featureFlags || [],
|
|
279
|
+
experiments: validatedProposal.experiments || [],
|
|
280
|
+
telemetryContracts: validatedProposal.telemetryContracts || [],
|
|
281
|
+
releasePlan: validatedProposal.releasePlan || { strategy: 'immediate' },
|
|
282
|
+
status: validatedProposal.status || 'draft',
|
|
283
|
+
metadata: {
|
|
284
|
+
createdAt: now,
|
|
285
|
+
updatedAt: now,
|
|
286
|
+
reviewers: validatedProposal.metadata?.reviewers || [],
|
|
287
|
+
aiInteractions: validatedProposal.metadata?.aiInteractions || [],
|
|
288
|
+
tags: validatedProposal.metadata?.tags || [],
|
|
289
|
+
priority: validatedProposal.metadata?.priority || 'medium'
|
|
290
|
+
},
|
|
291
|
+
productSpecRef: validatedProposal.productSpecRef
|
|
254
292
|
};
|
|
255
|
-
await storage.createCP(
|
|
293
|
+
await storage.createCP(newProposal);
|
|
256
294
|
return {
|
|
257
295
|
success: true,
|
|
258
|
-
data:
|
|
259
|
-
message: `Created proposal ${
|
|
296
|
+
data: newProposal,
|
|
297
|
+
message: `Created proposal ${newProposal.id} (${newProposal.intent})`
|
|
260
298
|
};
|
|
261
299
|
}
|
|
262
300
|
case 'update': {
|
|
@@ -1,6 +1,33 @@
|
|
|
1
1
|
import { Hono } from 'hono';
|
|
2
2
|
import { getRepoStatus, listBranches, createBranch, deleteBranch, pushCurrent, pullCurrent, listWorktrees, addWorktree, removeWorktree, commitInWorktree, pushWorktree, pullWorktree, } from '../../../../lib/git.js';
|
|
3
|
+
import { loadGitSettings, setDefaultWorktreePath } from '../../../../lib/git-settings.js';
|
|
3
4
|
const app = new Hono();
|
|
5
|
+
// Settings
|
|
6
|
+
app.get('/settings', async (c) => {
|
|
7
|
+
try {
|
|
8
|
+
const settings = await loadGitSettings();
|
|
9
|
+
return c.json({ success: true, data: settings });
|
|
10
|
+
}
|
|
11
|
+
catch (e) {
|
|
12
|
+
return c.json({ success: false, error: { message: e.message || 'Failed to load git settings' } }, 500);
|
|
13
|
+
}
|
|
14
|
+
});
|
|
15
|
+
app.put('/settings', async (c) => {
|
|
16
|
+
try {
|
|
17
|
+
const body = await c.req.json();
|
|
18
|
+
const path = body?.defaultWorktreePath;
|
|
19
|
+
if (typeof path !== 'string' || !path.trim()) {
|
|
20
|
+
return c.json({ success: false, error: { message: 'defaultWorktreePath is required' } }, 400);
|
|
21
|
+
}
|
|
22
|
+
const settings = await setDefaultWorktreePath(path);
|
|
23
|
+
return c.json({ success: true, data: settings });
|
|
24
|
+
}
|
|
25
|
+
catch (e) {
|
|
26
|
+
const message = e?.message || 'Failed to update git settings';
|
|
27
|
+
const status = /relative to the project root/i.test(message) ? 400 : 500;
|
|
28
|
+
return c.json({ success: false, error: { message } }, status);
|
|
29
|
+
}
|
|
30
|
+
});
|
|
4
31
|
// Status
|
|
5
32
|
app.get('/status', async (c) => {
|
|
6
33
|
try {
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default } from './route.js';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default } from './route.js';
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { Hono } from 'hono';
|
|
2
|
+
import { promises as fs } from 'fs';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
const app = new Hono();
|
|
5
|
+
const getMcpConfigPath = () => {
|
|
6
|
+
const gaitPath = path.join(process.cwd(), '.gait');
|
|
7
|
+
return path.join(gaitPath, 'mcp', 'config.json');
|
|
8
|
+
};
|
|
9
|
+
const ensureMcpDirectory = async () => {
|
|
10
|
+
const gaitPath = path.join(process.cwd(), '.gait');
|
|
11
|
+
const mcpPath = path.join(gaitPath, 'mcp');
|
|
12
|
+
try {
|
|
13
|
+
await fs.access(mcpPath);
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
await fs.mkdir(mcpPath, { recursive: true });
|
|
17
|
+
}
|
|
18
|
+
};
|
|
19
|
+
// GET /api/v1/mcp/config - Get MCP configuration
|
|
20
|
+
app.get('/', async (c) => {
|
|
21
|
+
try {
|
|
22
|
+
await ensureMcpDirectory();
|
|
23
|
+
const configPath = getMcpConfigPath();
|
|
24
|
+
try {
|
|
25
|
+
const configData = await fs.readFile(configPath, 'utf-8');
|
|
26
|
+
const config = JSON.parse(configData);
|
|
27
|
+
return c.json(config);
|
|
28
|
+
}
|
|
29
|
+
catch (error) {
|
|
30
|
+
// If file doesn't exist or is invalid, return empty config
|
|
31
|
+
return c.json({});
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
catch (error) {
|
|
35
|
+
console.error('Error reading MCP config:', error);
|
|
36
|
+
return c.json({ error: 'Failed to read MCP configuration' }, 500);
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
// PUT /api/v1/mcp/config - Update MCP configuration
|
|
40
|
+
app.put('/', async (c) => {
|
|
41
|
+
try {
|
|
42
|
+
const body = await c.req.json();
|
|
43
|
+
await ensureMcpDirectory();
|
|
44
|
+
const configPath = getMcpConfigPath();
|
|
45
|
+
// Validate the configuration structure
|
|
46
|
+
if (typeof body !== 'object') {
|
|
47
|
+
return c.json({ error: 'Invalid configuration format' }, 400);
|
|
48
|
+
}
|
|
49
|
+
// Write the configuration to file
|
|
50
|
+
const configData = JSON.stringify(body, null, 2);
|
|
51
|
+
await fs.writeFile(configPath, configData, 'utf-8');
|
|
52
|
+
return c.json({ message: 'MCP configuration updated successfully' });
|
|
53
|
+
}
|
|
54
|
+
catch (error) {
|
|
55
|
+
console.error('Error updating MCP config:', error);
|
|
56
|
+
return c.json({ error: 'Failed to update MCP configuration' }, 500);
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
export default app;
|
|
@@ -40,10 +40,10 @@ app.get('/', async (c) => {
|
|
|
40
40
|
}
|
|
41
41
|
}
|
|
42
42
|
const names = Object.keys(externalServers);
|
|
43
|
-
// Add built-in tools
|
|
43
|
+
// Add built-in tools (include full JSON schema for parameters)
|
|
44
44
|
const builtInTools = {
|
|
45
|
-
|
|
46
|
-
|
|
45
|
+
change_proposals: proposalsTool,
|
|
46
|
+
list_proposals: listProposalsTool
|
|
47
47
|
};
|
|
48
48
|
return c.json({
|
|
49
49
|
success: true,
|
|
@@ -57,6 +57,25 @@ app.get('/', async (c) => {
|
|
|
57
57
|
return c.json({ success: false, error: 'Failed to load MCP servers', servers: [], tools: {} }, 500);
|
|
58
58
|
}
|
|
59
59
|
});
|
|
60
|
+
/**
|
|
61
|
+
* GET /api/v1/mcp/schema
|
|
62
|
+
* Returns the JSON Schemas for built-in tools so LLMs and clients can construct valid calls.
|
|
63
|
+
*/
|
|
64
|
+
app.get('/schema', async (c) => {
|
|
65
|
+
try {
|
|
66
|
+
const schema = {
|
|
67
|
+
tools: {
|
|
68
|
+
change_proposals: proposalsTool,
|
|
69
|
+
list_proposals: listProposalsTool
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
return c.json(schema);
|
|
73
|
+
}
|
|
74
|
+
catch (err) {
|
|
75
|
+
console.error('Error returning MCP schema:', err);
|
|
76
|
+
return c.json({ error: 'Failed to load MCP schema' }, 500);
|
|
77
|
+
}
|
|
78
|
+
});
|
|
60
79
|
/**
|
|
61
80
|
* POST /api/v1/mcp/execute
|
|
62
81
|
* Execute a tool call
|
|
@@ -1,12 +1,51 @@
|
|
|
1
1
|
import { getGlobalTerminalManager } from '../../../../../lib/terminal/global-manager.js';
|
|
2
|
+
// Lightweight ANSI escape sequence stripper to keep preview readable
|
|
3
|
+
// This avoids adding a dependency just for previews
|
|
4
|
+
const ANSI_REGEX = /[\u001B\u009B][[\]()#;?]*(?:\d{1,4}(?:;\d{0,4})*)?[0-9A-ORZcf-nqry=><]/g;
|
|
5
|
+
function stripAnsi(input) {
|
|
6
|
+
try {
|
|
7
|
+
return input.replace(ANSI_REGEX, '');
|
|
8
|
+
}
|
|
9
|
+
catch {
|
|
10
|
+
return input;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
// Create a small preview from the backlog without scanning the entire string
|
|
14
|
+
function buildPreview(backlog, lines, maxBytes, strip) {
|
|
15
|
+
if (!backlog)
|
|
16
|
+
return [];
|
|
17
|
+
// Take only the last maxBytes to avoid processing the full backlog
|
|
18
|
+
const sliceStart = Math.max(0, backlog.length - maxBytes);
|
|
19
|
+
let chunk = backlog.slice(sliceStart);
|
|
20
|
+
// Normalize newlines for readability
|
|
21
|
+
chunk = chunk.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
|
22
|
+
// Strip ANSI sequences unless colorized previews are requested
|
|
23
|
+
if (strip) {
|
|
24
|
+
chunk = stripAnsi(chunk);
|
|
25
|
+
}
|
|
26
|
+
const parts = chunk.split('\n');
|
|
27
|
+
const tail = parts.slice(-Math.max(1, lines));
|
|
28
|
+
return tail;
|
|
29
|
+
}
|
|
2
30
|
export async function GET(c) {
|
|
3
31
|
try {
|
|
4
32
|
const url = new URL(c.req.url);
|
|
5
33
|
const searchParams = url.searchParams;
|
|
6
34
|
const proposalId = searchParams.get('proposalId');
|
|
35
|
+
const sessionId = searchParams.get('sessionId');
|
|
36
|
+
const withPreview = ['1', 'true', 'yes'].includes((searchParams.get('withPreview') || '').toLowerCase());
|
|
37
|
+
const linesParam = parseInt(searchParams.get('lines') || '0', 10);
|
|
38
|
+
const previewLines = Number.isFinite(linesParam) && linesParam > 0 ? Math.min(linesParam, 10) : 4; // cap at 10
|
|
39
|
+
const maxBytesParam = parseInt(searchParams.get('previewBytes') || '0', 10);
|
|
40
|
+
const previewBytes = Number.isFinite(maxBytesParam) && maxBytesParam > 0 ? Math.min(maxBytesParam, 8192) : 4096; // cap at 8KB
|
|
41
|
+
const colorize = ['1', 'true', 'yes'].includes((searchParams.get('colorize') || '').toLowerCase());
|
|
7
42
|
const terminalManager = getGlobalTerminalManager();
|
|
8
43
|
let sessions;
|
|
9
|
-
if (
|
|
44
|
+
if (sessionId) {
|
|
45
|
+
const s = terminalManager.getSession(sessionId);
|
|
46
|
+
sessions = s ? [s] : [];
|
|
47
|
+
}
|
|
48
|
+
else if (proposalId) {
|
|
10
49
|
sessions = terminalManager.getSessionsByProposal(proposalId);
|
|
11
50
|
}
|
|
12
51
|
else {
|
|
@@ -19,6 +58,9 @@ export async function GET(c) {
|
|
|
19
58
|
createdAt: session.createdAt,
|
|
20
59
|
lastActivity: session.lastActivity,
|
|
21
60
|
connected: !!session.websocket,
|
|
61
|
+
...(withPreview
|
|
62
|
+
? { preview: buildPreview(session.backlog || '', previewLines, previewBytes, !colorize) }
|
|
63
|
+
: {}),
|
|
22
64
|
}));
|
|
23
65
|
return c.json({ sessions: sessionInfo });
|
|
24
66
|
}
|
|
@@ -52,6 +52,28 @@ app.get('/ws/terminal/:sessionId', upgradeWebSocket((c) => ({
|
|
|
52
52
|
console.error('WebSocket error:', evt);
|
|
53
53
|
}
|
|
54
54
|
})));
|
|
55
|
+
// WebSocket route for terminal preview sessions (read-only viewers)
|
|
56
|
+
app.get('/ws/terminal-preview/:sessionId', upgradeWebSocket((c) => ({
|
|
57
|
+
onOpen: (_evt, ws) => {
|
|
58
|
+
const sessionId = c.req.param('sessionId');
|
|
59
|
+
const terminalManager = getGlobalTerminalManager();
|
|
60
|
+
const rawWs = ws.raw;
|
|
61
|
+
if (!rawWs) {
|
|
62
|
+
try {
|
|
63
|
+
ws.close(1000, 'WebSocket error');
|
|
64
|
+
}
|
|
65
|
+
catch { }
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
const success = terminalManager.attachPreviewWebSocket(sessionId, rawWs);
|
|
69
|
+
if (!success) {
|
|
70
|
+
try {
|
|
71
|
+
ws.close(1000, 'Session not found');
|
|
72
|
+
}
|
|
73
|
+
catch { }
|
|
74
|
+
}
|
|
75
|
+
},
|
|
76
|
+
})));
|
|
55
77
|
// Import and register API routes
|
|
56
78
|
import proposals from './routes/api/v1/proposals/index.js';
|
|
57
79
|
import terminalSessions from './routes/api/v1/terminal/sessions/index.js';
|