Compare commits

..

14 Commits

Author SHA1 Message Date
Haileyesus
907cf510a3 refactor(providers): remove debug logging from Claude authentication status checks 2026-04-17 16:01:16 +03:00
Haileyesus
eb6268748b chore(api): remove unused backend endpoints after MCP audit
Remove legacy backend routes that no longer have frontend or internal
callers, including the old Claude/Codex MCP APIs, unused Cursor and Codex
helper endpoints, stale TaskMaster detection/next/initialize routes,
and unused command/project helpers.

This reduces duplicated MCP behavior now handled by the provider-based
MCP API, shrinks the exposed backend surface, and removes probe/service
code that only existed for deleted endpoints.

Add an MCP settings API audit document to capture the route-usage
analysis and explain why the legacy MCP endpoints were considered safe
to remove.
2026-04-17 15:57:07 +03:00
Haileyesus
4e962272cd refactor(providers): move auth status routes under provider API
Move provider authentication status endpoints out of the legacy `/api/cli` route
namespace so auth status is exposed through the same provider module that owns
provider auth and MCP behavior.

Add `GET /api/providers/:provider/auth/status` to the provider router and route
it through the provider auth service. Remove the old `cli-auth` route file and
`/api/cli` mount now that provider auth status is handled by the unified provider
API.

Update the frontend provider auth endpoint map to call the new provider-scoped
routes and rename the endpoint constant to reflect that it is no longer CLI
specific.
2026-04-17 15:39:25 +03:00
Haileyesus
0f1e515b39 refactor(providers): move session message delegation into sessions service
Move provider-backed session history and message normalization calls out of the
generic providers service so the service name reflects the behavior it owns.

Add a dedicated sessions service for listing session-capable providers,
normalizing live provider events, and fetching persisted session history through
the provider registry. Update realtime handlers and the unified messages route to
depend on `sessionsService` instead of `providersService`.

This separates session message operations from other provider concerns such as
auth and MCP, keeping the provider services easier to navigate as the module
grows.
2026-04-17 15:29:35 +03:00
Haileyesus
b74b5fb967 refactor(providers): clarify provider auth and MCP naming
Rename provider auth/MCP contracts to remove the overloaded Runtime suffix so
the shared interfaces read as stable provider capabilities instead of execution
implementation details.

Add a consistent provider-first auth class naming convention by renaming
ClaudeAuthProvider, CodexAuthProvider, CursorAuthProvider, and GeminiAuthProvider
to ClaudeProviderAuth, CodexProviderAuth, CursorProviderAuth, and
GeminiProviderAuth.

This keeps the provider module API easier to scan and aligns auth naming with
the main provider ownership model.
2026-04-17 15:23:12 +03:00
Haileyesus
32dfd27156 refactor(providers): move auth status checks into provider runtimes
Move provider authentication status logic out of the CLI auth route so auth checks
live with the provider implementations that understand each provider's install
and credential model.

Add provider-specific auth runtime classes for Claude, Codex, Cursor, and Gemini,
and expose them through the shared provider contract as `provider.auth`. Add a
provider auth service that resolves providers through the registry and delegates
status checks via `auth.getStatus()`.

Keep the existing `/api/cli/<provider>/status` endpoints, but make them thin route
adapters over the new provider auth service. This removes duplicated route-local
credential parsing and makes auth status a first-class provider capability beside
MCP and message handling.
2026-04-17 15:15:26 +03:00
Haileyesus
7832429011 refactor(providers): centralize message handling in provider module
Move provider-specific normalizeMessage and fetchHistory logic out of the legacy
server/providers adapters and into the refactored provider classes so callers can
depend on the main provider contract instead of parallel adapter plumbing.

Add a providers service to resolve concrete providers through the registry and
delegate message normalization/history loading from realtime handlers and the
unified messages route. Add shared TypeScript message/history types and normalized
message helpers so provider implementations and callers use the same contract.

Remove the old adapter registry/files now that Claude, Codex, Cursor, and Gemini
implement the required behavior directly.
2026-04-17 14:22:29 +03:00
Haileyesus
1a6eb57043 feat: implement platform-specific provider visibility for cursor agent 2026-04-16 23:21:21 +03:00
Haileyesus
d979c315cd feat(mcp): add global MCP server creation flow
Add a separate global MCP add path in the settings MCP module so users can create
one shared MCP server configuration across Claude, Cursor, Codex, and Gemini from
the same screen.

The provider-specific add flow is still kept next to it because these two actions
have different intent. A global MCP server must be constrained to the subset of
configuration that every provider can accept, while a provider-specific server can
still use that provider's own supported scopes, transports, and fields. Naming the
buttons as "Add Global MCP Server" and "Add <Provider> MCP Server" makes that
distinction explicit without forcing users to infer it from the selected tab.

This also moves the explanatory copy to button hover text to keep the MCP toolbar
compact while still documenting the difference between global and provider-only
adds at the point of action.

Implementation details:
- Add global MCP form mode with shared user/project scopes and stdio/http transports.
- Submit global creates through `/api/providers/mcp/servers/global`.
- Reuse the existing MCP form modal with configurable scopes, transports, labels,
  and descriptions instead of duplicating form logic.
- Disable provider-only fields for the global flow because those fields cannot be
  safely written to every provider.
- Clear the MCP server cache globally after a global add because every provider tab
  may have changed.
- Surface partial global add failures with provider-specific error messages.

Validation:
- npx eslint src/components/mcp/view/McpServers.tsx
- npm run typecheck
- npm run build:client
2026-04-16 22:43:18 +03:00
Haileyesus
5143a92021 fix(mcp): form with multiline text handling for args, env, headers, and envVars 2026-04-16 22:29:34 +03:00
Haileyesus
358f47d020 refactor(settings): move MCP server management into provider module
Extract MCP server settings out of the settings controller and agents tab into a
dedicated frontend MCP module. The settings UI now delegates MCP rendering and
behavior to a single module that only needs the selected provider and current
projects.

Changes:
- Add `src/components/mcp` as the single frontend MCP module
- Move MCP server list rendering into `McpServers`
- Move MCP add/edit modal into `McpServerFormModal`
- Move MCP API/state logic into `useMcpServers`
- Move MCP form state/validation logic into `useMcpServerForm`
- Add provider-specific MCP constants, types, and formatting helpers
- Use the unified `/api/providers/:provider/mcp/servers` API for all providers
- Support MCP management for Claude, Cursor, Codex, and Gemini
- Remove old settings-owned Claude/Codex MCP modal components
- Remove old provider-specific `McpServersContent` branching from settings
- Strip MCP server state, fetch, save, delete, and modal ownership from
  `useSettingsController`
- Simplify agents settings props so MCP only receives `selectedProvider` and
  `currentProjects`
- Keep Claude working-directory unsupported while preserving cwd support for
  Cursor, Codex, and Gemini
- Add progressive MCP loading:
  - render user/global scope first
  - load project/local scopes in the background
  - append project results as they resolve
  - cache MCP lists briefly to avoid slow tab-switch refetches
  - ignore stale async responses after provider switches

Verification:
- `npx eslint src/components/mcp`
- `npm run typecheck`
- `npm run build:client`
2026-04-16 22:22:35 +03:00
Haileyesus
5c53100651 refactor: put /api/providers in index.js and remove /providers prefix from provider.routes.ts 2026-04-16 20:58:16 +03:00
Haileyesus
63b9606e78 chore: remove dead code related to MCP server 2026-04-16 20:57:17 +03:00
Haileyesus
016e8673f2 feat: implement MCP provider registry and service
- Add provider registry to manage LLM providers (Claude, Codex, Cursor, Gemini).
- Create provider routes for MCP server operations (list, upsert, delete, run).
- Implement MCP service for handling server operations and validations.
- Introduce abstract provider class and MCP provider base for shared functionality.
- Add tests for MCP server operations across different providers and scopes.
- Define shared interfaces and types for MCP functionality.
- Implement utility functions for handling JSON config files and API responses.
2026-04-15 20:16:26 +03:00
90 changed files with 6902 additions and 6087 deletions

View File

@@ -3,35 +3,6 @@
All notable changes to CloudCLI UI will be documented in this file.
## [1.29.4](https://github.com/siteboon/claudecodeui/compare/v1.29.3...v1.29.4) (2026-04-16)
### New Features
* deleting from sidebar will now ask whether to remove all data as well ([e9c7a50](https://github.com/siteboon/claudecodeui/commit/e9c7a5041c31a6f7b2032f06abe19c52d3d4cd8c))
### Bug Fixes
* pass pathToClaudeCodeExecutable to SDK when CLAUDE_CLI_PATH is set ([4c106a5](https://github.com/siteboon/claudecodeui/commit/4c106a5083d90989bbeedaefdbb68f5b3fa6fd58)), closes [#468](https://github.com/siteboon/claudecodeui/issues/468)
### Refactoring
* remove the sqlite3 dependency ([2895208](https://github.com/siteboon/claudecodeui/commit/289520814cf3ca36403056739ef22021f78c6033))
* **server:** extract URL detection and color utils from index.js ([#657](https://github.com/siteboon/claudecodeui/issues/657)) ([63e996b](https://github.com/siteboon/claudecodeui/commit/63e996bb77cfa97b1f55f6bdccc50161a75a3eee))
### Maintenance
* upgrade commit lint to 20.5.0 ([0948601](https://github.com/siteboon/claudecodeui/commit/09486016e67d97358c228ebc6eb4502ccb0012e4))
## [1.29.3](https://github.com/siteboon/claudecodeui/compare/v1.29.2...v1.29.3) (2026-04-15)
### Bug Fixes
* **version-upgrade-modal:** implement reload countdown and update UI messages ([#655](https://github.com/siteboon/claudecodeui/issues/655)) ([6413042](https://github.com/siteboon/claudecodeui/commit/641304242d7705b54aab65faa4a7673438c92c60))
### Maintenance
* remove unused route (migrated to providers already) ([31f28a2](https://github.com/siteboon/claudecodeui/commit/31f28a2c183f6ead50941027632d7ab64b7bb2d4))
## [1.29.2](https://github.com/siteboon/claudecodeui/compare/v1.29.1...v1.29.2) (2026-04-14)
### Bug Fixes

View File

@@ -148,9 +148,27 @@ export default tseslint.config(
],
"boundaries/elements": [
{
type: "backend-shared-types", // shared backend type contract that modules may consume without creating runtime coupling
pattern: ["server/shared/types.{js,ts}"], // support the current shared types path
mode: "file", // treat the types file itself as the boundary element instead of the whole folder
type: "backend-shared-type-contract", // shared backend type/interface contracts that modules may consume without creating runtime coupling
pattern: [
"server/shared/types.{js,ts}",
"server/shared/interfaces.{js,ts}",
], // keep backend modules on explicit shared contract files for erased imports only
mode: "file", // treat each shared contract file itself as the boundary element instead of the whole folder
},
{
type: "backend-shared-utils", // shared backend runtime helpers that modules may import directly
pattern: ["server/shared/utils.{js,ts}"], // classify the shared utils file so modules can depend on it explicitly
mode: "file",
},
{
type: "backend-legacy-runtime", // legacy runtime persistence modules used while providers migrate into server/modules
pattern: [
"server/projects.js",
"server/sessionManager.js",
"server/database/*.{js,ts}",
"server/utils/runtime-paths.js",
], // provider history loading still resolves session data through these legacy runtime/database files
mode: "file",
},
{
type: "backend-module", // logical element name used by boundaries rules below
@@ -196,13 +214,13 @@ export default tseslint.config(
checkInternals: false, // do not apply these cross-module rules to imports inside the same module
rules: [
{
from: { type: "backend-module" }, // modules may depend on the shared types contract only as erased type-only imports
to: { type: "backend-shared-types" },
from: { type: "backend-module" }, // modules may depend on shared type/interface contracts only as erased type-only imports
to: { type: "backend-shared-type-contract" },
disallow: {
dependency: { kind: ["value", "typeof"] },
}, // block runtime imports so shared types stay a compile-time contract instead of a hidden shared module
}, // block runtime imports so shared contracts stay compile-time only instead of becoming hidden shared modules
message:
"Backend modules may only use `import type` when importing from server/shared/types.ts (or server/types.ts).",
"Backend modules may only use `import type` when importing from server/shared/types.ts or server/shared/interfaces.ts.",
},
{
to: { type: "backend-module" }, // when importing anything that belongs to another backend module

1141
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "@cloudcli-ai/cloudcli",
"version": "1.29.4",
"version": "1.29.2",
"description": "A web-based UI for Claude Code CLI",
"type": "module",
"main": "dist-server/server/index.js",
@@ -117,15 +117,19 @@
"rehype-raw": "^7.0.0",
"remark-gfm": "^4.0.0",
"remark-math": "^6.0.0",
"sqlite": "^5.1.1",
"sqlite3": "^5.1.7",
"tailwind-merge": "^3.3.1",
"web-push": "^3.6.7",
"ws": "^8.14.2"
},
"devDependencies": {
"@commitlint/cli": "^20.5.0",
"@commitlint/config-conventional": "^20.5.0",
"@commitlint/cli": "^20.4.3",
"@commitlint/config-conventional": "^20.4.3",
"@eslint/js": "^9.39.3",
"@release-it/conventional-changelog": "^10.0.5",
"@types/cross-spawn": "^6.0.6",
"@types/express": "^5.0.6",
"@types/node": "^22.19.7",
"@types/react": "^18.2.43",
"@types/react-dom": "^18.2.17",

View File

@@ -24,16 +24,15 @@ import {
notifyRunStopped,
notifyUserIfEnabled
} from './services/notification-orchestrator.js';
import { claudeAdapter } from './providers/claude/adapter.js';
import { createNormalizedMessage } from './providers/types.js';
import { getStatusChecker } from './providers/registry.js';
import { sessionsService } from './modules/providers/services/sessions.service.js';
import { createNormalizedMessage } from './shared/utils.js';
const activeSessions = new Map();
const pendingToolApprovals = new Map();
const TOOL_APPROVAL_TIMEOUT_MS = parseInt(process.env.CLAUDE_TOOL_APPROVAL_TIMEOUT_MS, 10) || 55000;
const TOOLS_REQUIRING_INTERACTION = new Set(['AskUserQuestion', 'ExitPlanMode']);
const TOOLS_REQUIRING_INTERACTION = new Set(['AskUserQuestion']);
function createRequestId() {
if (typeof crypto.randomUUID === 'function') {
@@ -149,10 +148,6 @@ function mapCliOptionsToSDK(options = {}) {
const sdkOptions = {};
if (process.env.CLAUDE_CLI_PATH) {
sdkOptions.pathToClaudeCodeExecutable = process.env.CLAUDE_CLI_PATH;
}
// Map working directory
if (cwd) {
sdkOptions.cwd = cwd;
@@ -654,7 +649,7 @@ async function queryClaudeSDK(command, options = {}, ws) {
const sid = capturedSessionId || sessionId || null;
// Use adapter to normalize SDK events into NormalizedMessage[]
const normalized = claudeAdapter.normalizeMessage(transformedMessage, sid);
const normalized = sessionsService.normalizeMessage('claude', transformedMessage, sid);
for (const msg of normalized) {
// Preserve parentToolUseId from SDK wrapper for subagent tool grouping
if (transformedMessage.parentToolUseId && !msg.parentToolUseId) {
@@ -706,14 +701,8 @@ async function queryClaudeSDK(command, options = {}, ws) {
// Clean up temporary image files on error
await cleanupTempFiles(tempImagePaths, tempDir);
// Check if Claude CLI is installed for a clearer error message
const installed = getStatusChecker('claude')?.checkInstalled() ?? true;
const errorContent = !installed
? 'Claude Code is not installed. Please install it first: https://docs.anthropic.com/en/docs/claude-code'
: error.message;
// Send error to WebSocket
ws.send(createNormalizedMessage({ kind: 'error', content: errorContent, sessionId: capturedSessionId || sessionId || null, provider: 'claude' }));
ws.send(createNormalizedMessage({ kind: 'error', content: error.message, sessionId: capturedSessionId || sessionId || null, provider: 'claude' }));
notifyRunFailed({
userId: ws?.userId || null,
provider: 'claude',
@@ -721,6 +710,8 @@ async function queryClaudeSDK(command, options = {}, ws) {
sessionName: sessionSummary,
error
});
throw error;
}
}

View File

@@ -1,9 +1,8 @@
import { spawn } from 'child_process';
import crossSpawn from 'cross-spawn';
import { notifyRunFailed, notifyRunStopped } from './services/notification-orchestrator.js';
import { cursorAdapter } from './providers/cursor/adapter.js';
import { createNormalizedMessage } from './providers/types.js';
import { getStatusChecker } from './providers/registry.js';
import { sessionsService } from './modules/providers/services/sessions.service.js';
import { createNormalizedMessage } from './shared/utils.js';
// Use cross-spawn on Windows for better command execution
const spawnFunction = process.platform === 'win32' ? crossSpawn : spawn;
@@ -190,7 +189,7 @@ async function spawnCursor(command, options = {}, ws) {
case 'assistant':
// Accumulate assistant message chunks
if (response.message && response.message.content && response.message.content.length > 0) {
const normalized = cursorAdapter.normalizeMessage(response, capturedSessionId || sessionId || null);
const normalized = sessionsService.normalizeMessage('cursor', response, capturedSessionId || sessionId || null);
for (const msg of normalized) ws.send(msg);
}
break;
@@ -220,7 +219,7 @@ async function spawnCursor(command, options = {}, ws) {
}
// If not JSON, send as stream delta via adapter
const normalized = cursorAdapter.normalizeMessage(line, capturedSessionId || sessionId || null);
const normalized = sessionsService.normalizeMessage('cursor', line, capturedSessionId || sessionId || null);
for (const msg of normalized) ws.send(msg);
}
};
@@ -295,13 +294,7 @@ async function spawnCursor(command, options = {}, ws) {
const finalSessionId = capturedSessionId || sessionId || processKey;
activeCursorProcesses.delete(finalSessionId);
// Check if Cursor CLI is installed for a clearer error message
const installed = getStatusChecker('cursor')?.checkInstalled() ?? true;
const errorContent = !installed
? 'Cursor CLI is not installed. Please install it from https://cursor.com'
: error.message;
ws.send(createNormalizedMessage({ kind: 'error', content: errorContent, sessionId: capturedSessionId || sessionId || null, provider: 'cursor' }));
ws.send(createNormalizedMessage({ kind: 'error', content: error.message, sessionId: capturedSessionId || sessionId || null, provider: 'cursor' }));
notifyTerminalState({ error });
settleOnce(() => reject(error));

View File

@@ -9,8 +9,7 @@ import os from 'os';
import sessionManager from './sessionManager.js';
import GeminiResponseHandler from './gemini-response-handler.js';
import { notifyRunFailed, notifyRunStopped } from './services/notification-orchestrator.js';
import { createNormalizedMessage } from './providers/types.js';
import { getStatusChecker } from './providers/registry.js';
import { createNormalizedMessage } from './shared/utils.js';
let activeGeminiProcesses = new Map(); // Track active processes by session ID
@@ -381,15 +380,6 @@ async function spawnGemini(command, options = {}, ws) {
notifyTerminalState({ code });
resolve();
} else {
// code 127 = shell "command not found" — check installation
if (code === 127) {
const installed = getStatusChecker('gemini')?.checkInstalled() ?? true;
if (!installed) {
const socketSessionId = typeof ws.getSessionId === 'function' ? ws.getSessionId() : finalSessionId;
ws.send(createNormalizedMessage({ kind: 'error', content: 'Gemini CLI is not installed. Please install it first: https://github.com/google-gemini/gemini-cli', sessionId: socketSessionId, provider: 'gemini' }));
}
}
notifyTerminalState({
code,
error: code === null ? 'Gemini CLI process was terminated or timed out' : null
@@ -404,14 +394,8 @@ async function spawnGemini(command, options = {}, ws) {
const finalSessionId = capturedSessionId || sessionId || processKey;
activeGeminiProcesses.delete(finalSessionId);
// Check if Gemini CLI is installed for a clearer error message
const installed = getStatusChecker('gemini')?.checkInstalled() ?? true;
const errorContent = !installed
? 'Gemini CLI is not installed. Please install it first: https://github.com/google-gemini/gemini-cli'
: error.message;
const errorSessionId = typeof ws.getSessionId === 'function' ? ws.getSessionId() : finalSessionId;
ws.send(createNormalizedMessage({ kind: 'error', content: errorContent, sessionId: errorSessionId, provider: 'gemini' }));
ws.send(createNormalizedMessage({ kind: 'error', content: error.message, sessionId: errorSessionId, provider: 'gemini' }));
notifyTerminalState({ error });
reject(error);

View File

@@ -1,5 +1,5 @@
// Gemini Response Handler - JSON Stream processing
import { geminiAdapter } from './providers/gemini/adapter.js';
import { sessionsService } from './modules/providers/services/sessions.service.js';
class GeminiResponseHandler {
constructor(ws, options = {}) {
@@ -56,7 +56,7 @@ class GeminiResponseHandler {
}
// Normalize via adapter and send all resulting messages
const normalized = geminiAdapter.normalizeMessage(event, sid);
const normalized = sessionsService.normalizeMessage('gemini', event, sid);
for (const msg of normalized) {
this.ws.send(msg);
}

View File

@@ -5,13 +5,34 @@ import fs from 'fs';
import path from 'path';
import { findAppRoot, getModuleDir } from './utils/runtime-paths.js';
import { AppError, createNormalizedMessage } from '@/shared/utils.js';
const __dirname = getModuleDir(import.meta.url);
// The server source runs from /server, while the compiled output runs from /dist-server/server.
// Resolving the app root once keeps every repo-level lookup below aligned across both layouts.
const APP_ROOT = findAppRoot(__dirname);
const installMode = fs.existsSync(path.join(APP_ROOT, '.git')) ? 'git' : 'npm';
import { c } from './utils/colors.js';
// ANSI color codes for terminal output
const colors = {
reset: '\x1b[0m',
bright: '\x1b[1m',
cyan: '\x1b[36m',
green: '\x1b[32m',
yellow: '\x1b[33m',
blue: '\x1b[34m',
dim: '\x1b[2m',
};
const c = {
info: (text) => `${colors.cyan}${text}${colors.reset}`,
ok: (text) => `${colors.green}${text}${colors.reset}`,
warn: (text) => `${colors.yellow}${text}${colors.reset}`,
tip: (text) => `${colors.blue}${text}${colors.reset}`,
bright: (text) => `${colors.bright}${text}${colors.reset}`,
dim: (text) => `${colors.dim}${text}${colors.reset}`,
};
console.log('SERVER_PORT from env:', process.env.SERVER_PORT);
@@ -23,10 +44,9 @@ import cors from 'cors';
import { promises as fsPromises } from 'fs';
import { spawn } from 'child_process';
import pty from 'node-pty';
import fetch from 'node-fetch';
import mime from 'mime-types';
import { getProjects, getSessions, renameProject, deleteSession, deleteProject, addProjectManually, extractProjectDirectory, clearProjectDirectoryCache, searchConversations } from './projects.js';
import { getProjects, getSessions, renameProject, deleteSession, deleteProject, extractProjectDirectory, clearProjectDirectoryCache, searchConversations } from './projects.js';
import { queryClaudeSDK, abortClaudeSDKSession, isClaudeSDKSessionActive, getActiveClaudeSDKSessions, resolveToolApproval, getPendingApprovalsForSession, reconnectSessionWriter } from './claude-sdk.js';
import { spawnCursor, abortCursorSession, isCursorSessionActive, getActiveCursorSessions } from './cursor-cli.js';
import { queryCodex, abortCodexSession, isCodexSessionActive, getActiveCodexSessions } from './openai-codex.js';
@@ -34,7 +54,6 @@ import { spawnGemini, abortGeminiSession, isGeminiSessionActive, getActiveGemini
import sessionManager from './sessionManager.js';
import gitRoutes from './routes/git.js';
import authRoutes from './routes/auth.js';
import mcpRoutes from './routes/mcp.js';
import cursorRoutes from './routes/cursor.js';
import taskmasterRoutes from './routes/taskmaster.js';
import mcpUtilsRoutes from './routes/mcp-utils.js';
@@ -42,13 +61,12 @@ import commandsRoutes from './routes/commands.js';
import settingsRoutes from './routes/settings.js';
import agentRoutes from './routes/agent.js';
import projectsRoutes, { WORKSPACES_ROOT, validateWorkspacePath } from './routes/projects.js';
import cliAuthRoutes from './routes/cli-auth.js';
import userRoutes from './routes/user.js';
import codexRoutes from './routes/codex.js';
import geminiRoutes from './routes/gemini.js';
import pluginsRoutes from './routes/plugins.js';
import messagesRoutes from './routes/messages.js';
import { createNormalizedMessage } from './providers/types.js';
import providerRoutes from './modules/providers/provider.routes.js';
import { startEnabledPluginServers, stopAllPlugins, getPluginPort } from './utils/plugin-process-manager.js';
import { initializeDatabase, sessionNamesDb, applyCustomSessionNames } from './database/db.js';
import { configureWebPush } from './services/vapid-keys.js';
@@ -208,7 +226,68 @@ const server = http.createServer(app);
const ptySessionsMap = new Map();
const PTY_SESSION_TIMEOUT = 30 * 60 * 1000;
const SHELL_URL_PARSE_BUFFER_LIMIT = 32768;
import { stripAnsiSequences, normalizeDetectedUrl, extractUrlsFromText, shouldAutoOpenUrlFromOutput } from './utils/url-detection.js';
const ANSI_ESCAPE_SEQUENCE_REGEX = /\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~]|\][^\x07]*(?:\x07|\x1B\\))/g;
const TRAILING_URL_PUNCTUATION_REGEX = /[)\]}>.,;:!?]+$/;
function stripAnsiSequences(value = '') {
return value.replace(ANSI_ESCAPE_SEQUENCE_REGEX, '');
}
function normalizeDetectedUrl(url) {
if (!url || typeof url !== 'string') return null;
const cleaned = url.trim().replace(TRAILING_URL_PUNCTUATION_REGEX, '');
if (!cleaned) return null;
try {
const parsed = new URL(cleaned);
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
return null;
}
return parsed.toString();
} catch {
return null;
}
}
function extractUrlsFromText(value = '') {
const directMatches = value.match(/https?:\/\/[^\s<>"'`\\\x1b\x07]+/gi) || [];
// Handle wrapped terminal URLs split across lines by terminal width.
const wrappedMatches = [];
const continuationRegex = /^[A-Za-z0-9\-._~:/?#\[\]@!$&'()*+,;=%]+$/;
const lines = value.split(/\r?\n/);
for (let i = 0; i < lines.length; i++) {
const line = lines[i].trim();
const startMatch = line.match(/https?:\/\/[^\s<>"'`\\\x1b\x07]+/i);
if (!startMatch) continue;
let combined = startMatch[0];
let j = i + 1;
while (j < lines.length) {
const continuation = lines[j].trim();
if (!continuation) break;
if (!continuationRegex.test(continuation)) break;
combined += continuation;
j++;
}
wrappedMatches.push(combined.replace(/\r?\n\s*/g, ''));
}
return Array.from(new Set([...directMatches, ...wrappedMatches]));
}
function shouldAutoOpenUrlFromOutput(value = '') {
const normalized = value.toLowerCase();
return (
normalized.includes('browser didn\'t open') ||
normalized.includes('open this url') ||
normalized.includes('continue in your browser') ||
normalized.includes('press enter to open') ||
normalized.includes('open_url:')
);
}
// Single WebSocket server that handles both paths
const wss = new WebSocketServer({
@@ -286,9 +365,6 @@ app.use('/api/projects', authenticateToken, projectsRoutes);
// Git API Routes (protected)
app.use('/api/git', authenticateToken, gitRoutes);
// MCP API Routes (protected)
app.use('/api/mcp', authenticateToken, mcpRoutes);
// Cursor API Routes (protected)
app.use('/api/cursor', authenticateToken, cursorRoutes);
@@ -304,9 +380,6 @@ app.use('/api/commands', authenticateToken, commandsRoutes);
// Settings API Routes (protected)
app.use('/api/settings', authenticateToken, settingsRoutes);
// CLI Authentication API Routes (protected)
app.use('/api/cli', authenticateToken, cliAuthRoutes);
// User API Routes (protected)
app.use('/api/user', authenticateToken, userRoutes);
@@ -322,6 +395,9 @@ app.use('/api/plugins', authenticateToken, pluginsRoutes);
// Unified session messages route (protected)
app.use('/api/sessions', authenticateToken, messagesRoutes);
// Unified provider MCP routes (protected)
app.use('/api/providers', authenticateToken, providerRoutes);
// Agent API Routes (uses API key authentication)
app.use('/api/agent', agentRoutes);
@@ -494,38 +570,18 @@ app.put('/api/sessions/:sessionId/rename', authenticateToken, async (req, res) =
}
});
// Delete project endpoint
// force=true to allow removal even when sessions exist
// deleteData=true to also delete session/memory files on disk (destructive)
// Delete project endpoint (force=true to delete with sessions)
app.delete('/api/projects/:projectName', authenticateToken, async (req, res) => {
try {
const { projectName } = req.params;
const force = req.query.force === 'true';
const deleteData = req.query.deleteData === 'true';
await deleteProject(projectName, force, deleteData);
await deleteProject(projectName, force);
res.json({ success: true });
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Create project endpoint
app.post('/api/projects/create', authenticateToken, async (req, res) => {
try {
const { path: projectPath } = req.body;
if (!projectPath || !projectPath.trim()) {
return res.status(400).json({ error: 'Project path is required' });
}
const project = await addProjectManually(projectPath.trim());
res.json({ success: true, project });
} catch (error) {
console.error('Error creating project:', error);
res.status(500).json({ error: error.message });
}
});
// Search conversations content (SSE streaming)
app.get('/api/search/conversations', authenticateToken, async (req, res) => {
const query = typeof req.query.q === 'string' ? req.query.q.trim() : '';
@@ -1378,7 +1434,7 @@ wss.on('connection', (ws, request) => {
/**
* WebSocket Writer - Wrapper for WebSocket to match SSEStreamWriter interface
*
* Provider files use `createNormalizedMessage()` from `providers/types.js` and
* Provider files use `createNormalizedMessage()` from `shared/utils.js` and
* adapter `normalizeMessage()` to produce unified NormalizedMessage events.
* The writer simply serialises and sends.
*/
@@ -2213,6 +2269,30 @@ app.get('*', (req, res) => {
}
});
// global error middleware must be last
app.use((err, req, res, next) => {
if (err instanceof AppError) {
return res.status(err.statusCode).json({
success: false,
error: {
code: err.code,
message: err.message,
details: err.details,
},
});
}
console.error(err);
return res.status(500).json({
success: false,
error: {
code: 'INTERNAL_ERROR',
message: 'Internal server error',
},
});
});
// Helper function to convert permissions to rwx format
function permToRwx(perm) {
const r = perm & 4 ? 'r' : '-';

View File

@@ -0,0 +1,123 @@
import { readFile } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import spawn from 'cross-spawn';
import type { IProviderAuth } from '@/shared/interfaces.js';
import type { ProviderAuthStatus } from '@/shared/types.js';
import { readObjectRecord, readOptionalString } from '@/shared/utils.js';
type ClaudeCredentialsStatus = {
authenticated: boolean;
email: string | null;
method: string | null;
error?: string;
};
export class ClaudeProviderAuth implements IProviderAuth {
/**
* Checks whether the Claude Code CLI is available on this host.
*/
private checkInstalled(): boolean {
const cliPath = process.env.CLAUDE_CLI_PATH || 'claude';
try {
spawn.sync(cliPath, ['--version'], { stdio: 'ignore', timeout: 5000 });
return true;
} catch {
return false;
}
}
/**
* Returns Claude installation and credential status using Claude Code's auth priority.
*/
async getStatus(): Promise<ProviderAuthStatus> {
const installed = this.checkInstalled();
if (!installed) {
return {
installed,
provider: 'claude',
authenticated: false,
email: null,
method: null,
error: 'Claude Code CLI is not installed',
};
}
const credentials = await this.checkCredentials();
return {
installed,
provider: 'claude',
authenticated: credentials.authenticated,
email: credentials.authenticated ? credentials.email || 'Authenticated' : credentials.email,
method: credentials.method,
error: credentials.authenticated ? undefined : credentials.error || 'Not authenticated',
};
}
/**
* Reads Claude settings env values that the CLI can use even when the server process env is empty.
*/
private async loadSettingsEnv(): Promise<Record<string, unknown>> {
try {
const settingsPath = path.join(os.homedir(), '.claude', 'settings.json');
const content = await readFile(settingsPath, 'utf8');
const settings = readObjectRecord(JSON.parse(content));
return readObjectRecord(settings?.env) ?? {};
} catch {
return {};
}
}
/**
* Checks Claude credentials in the same priority order used by Claude Code.
*/
private async checkCredentials(): Promise<ClaudeCredentialsStatus> {
if (process.env.ANTHROPIC_API_KEY?.trim()) {
return { authenticated: true, email: 'API Key Auth', method: 'api_key' };
}
const settingsEnv = await this.loadSettingsEnv();
if (readOptionalString(settingsEnv.ANTHROPIC_API_KEY)) {
return { authenticated: true, email: 'API Key Auth', method: 'api_key' };
}
if (readOptionalString(settingsEnv.ANTHROPIC_AUTH_TOKEN)) {
return { authenticated: true, email: 'Configured via settings.json', method: 'api_key' };
}
try {
const credPath = path.join(os.homedir(), '.claude', '.credentials.json');
const content = await readFile(credPath, 'utf8');
const creds = readObjectRecord(JSON.parse(content)) ?? {};
const oauth = readObjectRecord(creds.claudeAiOauth);
const accessToken = readOptionalString(oauth?.accessToken);
if (accessToken) {
const expiresAt = typeof oauth?.expiresAt === 'number' ? oauth.expiresAt : undefined;
const email = readOptionalString(creds.email) ?? readOptionalString(creds.user) ?? null;
if (!expiresAt || Date.now() < expiresAt) {
return {
authenticated: true,
email,
method: 'credentials_file',
};
}
return {
authenticated: false,
email,
method: 'credentials_file',
error: 'OAuth token has expired. Please re-authenticate with claude login',
};
}
return { authenticated: false, email: null, method: null };
} catch {
return { authenticated: false, email: null, method: null };
}
}
}

View File

@@ -0,0 +1,135 @@
import os from 'node:os';
import path from 'node:path';
import { McpProvider } from '@/modules/providers/shared/mcp/mcp.provider.js';
import type { McpScope, ProviderMcpServer, UpsertProviderMcpServerInput } from '@/shared/types.js';
import {
AppError,
readJsonConfig,
readObjectRecord,
readOptionalString,
readStringArray,
readStringRecord,
writeJsonConfig,
} from '@/shared/utils.js';
export class ClaudeMcpProvider extends McpProvider {
constructor() {
super('claude', ['user', 'local', 'project'], ['stdio', 'http', 'sse']);
}
protected async readScopedServers(scope: McpScope, workspacePath: string): Promise<Record<string, unknown>> {
if (scope === 'project') {
const filePath = path.join(workspacePath, '.mcp.json');
const config = await readJsonConfig(filePath);
return readObjectRecord(config.mcpServers) ?? {};
}
const filePath = path.join(os.homedir(), '.claude.json');
const config = await readJsonConfig(filePath);
if (scope === 'user') {
return readObjectRecord(config.mcpServers) ?? {};
}
const projects = readObjectRecord(config.projects) ?? {};
const projectConfig = readObjectRecord(projects[workspacePath]) ?? {};
return readObjectRecord(projectConfig.mcpServers) ?? {};
}
protected async writeScopedServers(
scope: McpScope,
workspacePath: string,
servers: Record<string, unknown>,
): Promise<void> {
if (scope === 'project') {
const filePath = path.join(workspacePath, '.mcp.json');
const config = await readJsonConfig(filePath);
config.mcpServers = servers;
await writeJsonConfig(filePath, config);
return;
}
const filePath = path.join(os.homedir(), '.claude.json');
const config = await readJsonConfig(filePath);
if (scope === 'user') {
config.mcpServers = servers;
await writeJsonConfig(filePath, config);
return;
}
const projects = readObjectRecord(config.projects) ?? {};
const projectConfig = readObjectRecord(projects[workspacePath]) ?? {};
projectConfig.mcpServers = servers;
projects[workspacePath] = projectConfig;
config.projects = projects;
await writeJsonConfig(filePath, config);
}
protected buildServerConfig(input: UpsertProviderMcpServerInput): Record<string, unknown> {
if (input.transport === 'stdio') {
if (!input.command?.trim()) {
throw new AppError('command is required for stdio MCP servers.', {
code: 'MCP_COMMAND_REQUIRED',
statusCode: 400,
});
}
return {
type: 'stdio',
command: input.command,
args: input.args ?? [],
env: input.env ?? {},
};
}
if (!input.url?.trim()) {
throw new AppError('url is required for http/sse MCP servers.', {
code: 'MCP_URL_REQUIRED',
statusCode: 400,
});
}
return {
type: input.transport,
url: input.url,
headers: input.headers ?? {},
};
}
protected normalizeServerConfig(
scope: McpScope,
name: string,
rawConfig: unknown,
): ProviderMcpServer | null {
if (!rawConfig || typeof rawConfig !== 'object') {
return null;
}
const config = rawConfig as Record<string, unknown>;
if (typeof config.command === 'string') {
return {
provider: 'claude',
name,
scope,
transport: 'stdio',
command: config.command,
args: readStringArray(config.args),
env: readStringRecord(config.env),
};
}
if (typeof config.url === 'string') {
const transport = readOptionalString(config.type) === 'sse' ? 'sse' : 'http';
return {
provider: 'claude',
name,
scope,
transport,
url: config.url,
headers: readStringRecord(config.headers),
};
}
return null;
}
}

View File

@@ -0,0 +1,321 @@
import { getSessionMessages } from '@/projects.js';
import { AbstractProvider } from '@/modules/providers/shared/base/abstract.provider.js';
import { ClaudeProviderAuth } from '@/modules/providers/list/claude/claude-auth.provider.js';
import { ClaudeMcpProvider } from '@/modules/providers/list/claude/claude-mcp.provider.js';
import type { IProviderAuth } from '@/shared/interfaces.js';
import type { FetchHistoryOptions, FetchHistoryResult, NormalizedMessage } from '@/shared/types.js';
import { createNormalizedMessage, generateMessageId, readObjectRecord } from '@/shared/utils.js';
const PROVIDER = 'claude';
type RawProviderMessage = Record<string, any>;
type ClaudeToolResult = {
content: unknown;
isError: boolean;
subagentTools?: unknown;
toolUseResult?: unknown;
};
type ClaudeHistoryResult =
| RawProviderMessage[]
| {
messages?: RawProviderMessage[];
total?: number;
hasMore?: boolean;
};
const loadClaudeSessionMessages = getSessionMessages as unknown as (
projectName: string,
sessionId: string,
limit: number | null,
offset: number,
) => Promise<ClaudeHistoryResult>;
/**
* Claude writes internal command and system reminder entries into history.
* Those are useful for the CLI but should not appear in the user-facing chat.
*/
const INTERNAL_CONTENT_PREFIXES = [
'<command-name>',
'<command-message>',
'<command-args>',
'<local-command-stdout>',
'<system-reminder>',
'Caveat:',
'This session is being continued from a previous',
'[Request interrupted',
] as const;
function isInternalContent(content: string): boolean {
return INTERNAL_CONTENT_PREFIXES.some((prefix) => content.startsWith(prefix));
}
function readRawProviderMessage(raw: unknown): RawProviderMessage | null {
return readObjectRecord(raw) as RawProviderMessage | null;
}
export class ClaudeProvider extends AbstractProvider {
readonly mcp = new ClaudeMcpProvider();
readonly auth: IProviderAuth = new ClaudeProviderAuth();
constructor() {
super('claude');
}
/**
* Normalizes one Claude JSONL entry or live SDK stream event into the shared
* message shape consumed by REST and WebSocket clients.
*/
normalizeMessage(rawMessage: unknown, sessionId: string | null): NormalizedMessage[] {
const raw = readRawProviderMessage(rawMessage);
if (!raw) {
return [];
}
if (raw.type === 'content_block_delta' && raw.delta?.text) {
return [createNormalizedMessage({ kind: 'stream_delta', content: raw.delta.text, sessionId, provider: PROVIDER })];
}
if (raw.type === 'content_block_stop') {
return [createNormalizedMessage({ kind: 'stream_end', sessionId, provider: PROVIDER })];
}
const messages: NormalizedMessage[] = [];
const ts = raw.timestamp || new Date().toISOString();
const baseId = raw.uuid || generateMessageId('claude');
if (raw.message?.role === 'user' && raw.message?.content) {
if (Array.isArray(raw.message.content)) {
for (const part of raw.message.content) {
if (part.type === 'tool_result') {
messages.push(createNormalizedMessage({
id: `${baseId}_tr_${part.tool_use_id}`,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'tool_result',
toolId: part.tool_use_id,
content: typeof part.content === 'string' ? part.content : JSON.stringify(part.content),
isError: Boolean(part.is_error),
subagentTools: raw.subagentTools,
toolUseResult: raw.toolUseResult,
}));
} else if (part.type === 'text') {
const text = part.text || '';
if (text && !isInternalContent(text)) {
messages.push(createNormalizedMessage({
id: `${baseId}_text`,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'text',
role: 'user',
content: text,
}));
}
}
}
if (messages.length === 0) {
const textParts = raw.message.content
.filter((part: RawProviderMessage) => part.type === 'text')
.map((part: RawProviderMessage) => part.text)
.filter(Boolean)
.join('\n');
if (textParts && !isInternalContent(textParts)) {
messages.push(createNormalizedMessage({
id: `${baseId}_text`,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'text',
role: 'user',
content: textParts,
}));
}
}
} else if (typeof raw.message.content === 'string') {
const text = raw.message.content;
if (text && !isInternalContent(text)) {
messages.push(createNormalizedMessage({
id: baseId,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'text',
role: 'user',
content: text,
}));
}
}
return messages;
}
if (raw.type === 'thinking' && raw.message?.content) {
messages.push(createNormalizedMessage({
id: baseId,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'thinking',
content: raw.message.content,
}));
return messages;
}
if (raw.type === 'tool_use' && raw.toolName) {
messages.push(createNormalizedMessage({
id: baseId,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'tool_use',
toolName: raw.toolName,
toolInput: raw.toolInput,
toolId: raw.toolCallId || baseId,
}));
return messages;
}
if (raw.type === 'tool_result') {
messages.push(createNormalizedMessage({
id: baseId,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'tool_result',
toolId: raw.toolCallId || '',
content: raw.output || '',
isError: false,
}));
return messages;
}
if (raw.message?.role === 'assistant' && raw.message?.content) {
if (Array.isArray(raw.message.content)) {
let partIndex = 0;
for (const part of raw.message.content) {
if (part.type === 'text' && part.text) {
messages.push(createNormalizedMessage({
id: `${baseId}_${partIndex}`,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'text',
role: 'assistant',
content: part.text,
}));
} else if (part.type === 'tool_use') {
messages.push(createNormalizedMessage({
id: `${baseId}_${partIndex}`,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'tool_use',
toolName: part.name,
toolInput: part.input,
toolId: part.id,
}));
} else if (part.type === 'thinking' && part.thinking) {
messages.push(createNormalizedMessage({
id: `${baseId}_${partIndex}`,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'thinking',
content: part.thinking,
}));
}
partIndex++;
}
} else if (typeof raw.message.content === 'string') {
messages.push(createNormalizedMessage({
id: baseId,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'text',
role: 'assistant',
content: raw.message.content,
}));
}
return messages;
}
return messages;
}
/**
* Loads Claude JSONL history for a project/session and returns normalized
* messages, preserving the existing pagination behavior from projects.js.
*/
async fetchHistory(
sessionId: string,
options: FetchHistoryOptions = {},
): Promise<FetchHistoryResult> {
const { projectName, limit = null, offset = 0 } = options;
if (!projectName) {
return { messages: [], total: 0, hasMore: false, offset: 0, limit: null };
}
let result: ClaudeHistoryResult;
try {
result = await loadClaudeSessionMessages(projectName, sessionId, limit, offset);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.warn(`[ClaudeProvider] Failed to load session ${sessionId}:`, message);
return { messages: [], total: 0, hasMore: false, offset: 0, limit: null };
}
const rawMessages = Array.isArray(result) ? result : (result.messages || []);
const total = Array.isArray(result) ? rawMessages.length : (result.total || 0);
const hasMore = Array.isArray(result) ? false : Boolean(result.hasMore);
const toolResultMap = new Map<string, ClaudeToolResult>();
for (const raw of rawMessages) {
if (raw.message?.role === 'user' && Array.isArray(raw.message?.content)) {
for (const part of raw.message.content) {
if (part.type === 'tool_result' && part.tool_use_id) {
toolResultMap.set(part.tool_use_id, {
content: part.content,
isError: Boolean(part.is_error),
subagentTools: raw.subagentTools,
toolUseResult: raw.toolUseResult,
});
}
}
}
}
const normalized: NormalizedMessage[] = [];
for (const raw of rawMessages) {
normalized.push(...this.normalizeMessage(raw, sessionId));
}
for (const msg of normalized) {
if (msg.kind === 'tool_use' && msg.toolId && toolResultMap.has(msg.toolId)) {
const toolResult = toolResultMap.get(msg.toolId);
if (!toolResult) {
continue;
}
msg.toolResult = {
content: typeof toolResult.content === 'string'
? toolResult.content
: JSON.stringify(toolResult.content),
isError: toolResult.isError,
toolUseResult: toolResult.toolUseResult,
};
msg.subagentTools = toolResult.subagentTools;
}
}
return {
messages: normalized,
total,
hasMore,
offset,
limit,
};
}
}

View File

@@ -0,0 +1,100 @@
import { readFile } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import spawn from 'cross-spawn';
import type { IProviderAuth } from '@/shared/interfaces.js';
import type { ProviderAuthStatus } from '@/shared/types.js';
import { readObjectRecord, readOptionalString } from '@/shared/utils.js';
type CodexCredentialsStatus = {
authenticated: boolean;
email: string | null;
method: string | null;
error?: string;
};
export class CodexProviderAuth implements IProviderAuth {
/**
* Checks whether Codex is available to the server runtime.
*/
private checkInstalled(): boolean {
try {
spawn.sync('codex', ['--version'], { stdio: 'ignore', timeout: 5000 });
return true;
} catch {
return false;
}
}
/**
* Returns Codex SDK availability and credential status.
*/
async getStatus(): Promise<ProviderAuthStatus> {
const installed = this.checkInstalled();
const credentials = await this.checkCredentials();
return {
installed,
provider: 'codex',
authenticated: credentials.authenticated,
email: credentials.email,
method: credentials.method,
error: credentials.authenticated ? undefined : credentials.error || 'Not authenticated',
};
}
/**
* Reads Codex auth.json and checks OAuth tokens or an API key fallback.
*/
private async checkCredentials(): Promise<CodexCredentialsStatus> {
try {
const authPath = path.join(os.homedir(), '.codex', 'auth.json');
const content = await readFile(authPath, 'utf8');
const auth = readObjectRecord(JSON.parse(content)) ?? {};
const tokens = readObjectRecord(auth.tokens) ?? {};
const idToken = readOptionalString(tokens.id_token);
const accessToken = readOptionalString(tokens.access_token);
if (idToken || accessToken) {
return {
authenticated: true,
email: idToken ? this.readEmailFromIdToken(idToken) : 'Authenticated',
method: 'credentials_file',
};
}
if (readOptionalString(auth.OPENAI_API_KEY)) {
return { authenticated: true, email: 'API Key Auth', method: 'api_key' };
}
return { authenticated: false, email: null, method: null, error: 'No valid tokens found' };
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
return {
authenticated: false,
email: null,
method: null,
error: code === 'ENOENT' ? 'Codex not configured' : error instanceof Error ? error.message : 'Failed to read Codex auth',
};
}
}
/**
* Extracts the user email from a Codex id_token when a readable JWT payload exists.
*/
private readEmailFromIdToken(idToken: string): string {
try {
const parts = idToken.split('.');
if (parts.length >= 2) {
const payload = readObjectRecord(JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8')));
return readOptionalString(payload?.email) ?? readOptionalString(payload?.user) ?? 'Authenticated';
}
} catch {
// Fall back to a generic authenticated marker if the token payload is not readable.
}
return 'Authenticated';
}
}

View File

@@ -0,0 +1,135 @@
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import TOML from '@iarna/toml';
import { McpProvider } from '@/modules/providers/shared/mcp/mcp.provider.js';
import type { McpScope, ProviderMcpServer, UpsertProviderMcpServerInput } from '@/shared/types.js';
import {
AppError,
readObjectRecord,
readOptionalString,
readStringArray,
readStringRecord,
} from '@/shared/utils.js';
const readTomlConfig = async (filePath: string): Promise<Record<string, unknown>> => {
try {
const content = await readFile(filePath, 'utf8');
const parsed = TOML.parse(content) as Record<string, unknown>;
return readObjectRecord(parsed) ?? {};
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
if (code === 'ENOENT') {
return {};
}
throw error;
}
};
const writeTomlConfig = async (filePath: string, data: Record<string, unknown>): Promise<void> => {
await mkdir(path.dirname(filePath), { recursive: true });
const toml = TOML.stringify(data as never);
await writeFile(filePath, toml, 'utf8');
};
export class CodexMcpProvider extends McpProvider {
constructor() {
super('codex', ['user', 'project'], ['stdio', 'http']);
}
protected async readScopedServers(scope: McpScope, workspacePath: string): Promise<Record<string, unknown>> {
const filePath = scope === 'user'
? path.join(os.homedir(), '.codex', 'config.toml')
: path.join(workspacePath, '.codex', 'config.toml');
const config = await readTomlConfig(filePath);
return readObjectRecord(config.mcp_servers) ?? {};
}
protected async writeScopedServers(
scope: McpScope,
workspacePath: string,
servers: Record<string, unknown>,
): Promise<void> {
const filePath = scope === 'user'
? path.join(os.homedir(), '.codex', 'config.toml')
: path.join(workspacePath, '.codex', 'config.toml');
const config = await readTomlConfig(filePath);
config.mcp_servers = servers;
await writeTomlConfig(filePath, config);
}
protected buildServerConfig(input: UpsertProviderMcpServerInput): Record<string, unknown> {
if (input.transport === 'stdio') {
if (!input.command?.trim()) {
throw new AppError('command is required for stdio MCP servers.', {
code: 'MCP_COMMAND_REQUIRED',
statusCode: 400,
});
}
return {
command: input.command,
args: input.args ?? [],
env: input.env ?? {},
env_vars: input.envVars ?? [],
cwd: input.cwd,
};
}
if (!input.url?.trim()) {
throw new AppError('url is required for http MCP servers.', {
code: 'MCP_URL_REQUIRED',
statusCode: 400,
});
}
return {
url: input.url,
bearer_token_env_var: input.bearerTokenEnvVar,
http_headers: input.headers ?? {},
env_http_headers: input.envHttpHeaders ?? {},
};
}
protected normalizeServerConfig(
scope: McpScope,
name: string,
rawConfig: unknown,
): ProviderMcpServer | null {
if (!rawConfig || typeof rawConfig !== 'object') {
return null;
}
const config = rawConfig as Record<string, unknown>;
if (typeof config.command === 'string') {
return {
provider: 'codex',
name,
scope,
transport: 'stdio',
command: config.command,
args: readStringArray(config.args),
env: readStringRecord(config.env),
cwd: readOptionalString(config.cwd),
envVars: readStringArray(config.env_vars),
};
}
if (typeof config.url === 'string') {
return {
provider: 'codex',
name,
scope,
transport: 'http',
url: config.url,
headers: readStringRecord(config.http_headers),
bearerTokenEnvVar: readOptionalString(config.bearer_token_env_var),
envHttpHeaders: readStringRecord(config.env_http_headers),
};
}
return null;
}
}

View File

@@ -0,0 +1,335 @@
import { getCodexSessionMessages } from '@/projects.js';
import { AbstractProvider } from '@/modules/providers/shared/base/abstract.provider.js';
import { CodexProviderAuth } from '@/modules/providers/list/codex/codex-auth.provider.js';
import { CodexMcpProvider } from '@/modules/providers/list/codex/codex-mcp.provider.js';
import type { IProviderAuth } from '@/shared/interfaces.js';
import type { FetchHistoryOptions, FetchHistoryResult, NormalizedMessage } from '@/shared/types.js';
import { createNormalizedMessage, generateMessageId, readObjectRecord } from '@/shared/utils.js';
const PROVIDER = 'codex';
type RawProviderMessage = Record<string, any>;
type CodexHistoryResult =
| RawProviderMessage[]
| {
messages?: RawProviderMessage[];
total?: number;
hasMore?: boolean;
tokenUsage?: unknown;
};
const loadCodexSessionMessages = getCodexSessionMessages as unknown as (
sessionId: string,
limit: number | null,
offset: number,
) => Promise<CodexHistoryResult>;
function readRawProviderMessage(raw: unknown): RawProviderMessage | null {
return readObjectRecord(raw) as RawProviderMessage | null;
}
export class CodexProvider extends AbstractProvider {
readonly mcp = new CodexMcpProvider();
readonly auth: IProviderAuth = new CodexProviderAuth();
constructor() {
super('codex');
}
/**
* Normalizes a persisted Codex JSONL entry.
*
* Live Codex SDK events are transformed before they reach normalizeMessage(),
* while history entries already use a compact message/tool shape from projects.js.
*/
private normalizeHistoryEntry(raw: RawProviderMessage, sessionId: string | null): NormalizedMessage[] {
const ts = raw.timestamp || new Date().toISOString();
const baseId = raw.uuid || generateMessageId('codex');
if (raw.message?.role === 'user') {
const content = typeof raw.message.content === 'string'
? raw.message.content
: Array.isArray(raw.message.content)
? raw.message.content
.map((part: string | RawProviderMessage) => typeof part === 'string' ? part : part?.text || '')
.filter(Boolean)
.join('\n')
: String(raw.message.content || '');
if (!content.trim()) {
return [];
}
return [createNormalizedMessage({
id: baseId,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'text',
role: 'user',
content,
})];
}
if (raw.message?.role === 'assistant') {
const content = typeof raw.message.content === 'string'
? raw.message.content
: Array.isArray(raw.message.content)
? raw.message.content
.map((part: string | RawProviderMessage) => typeof part === 'string' ? part : part?.text || '')
.filter(Boolean)
.join('\n')
: '';
if (!content.trim()) {
return [];
}
return [createNormalizedMessage({
id: baseId,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'text',
role: 'assistant',
content,
})];
}
if (raw.type === 'thinking' || raw.isReasoning) {
return [createNormalizedMessage({
id: baseId,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'thinking',
content: raw.message?.content || '',
})];
}
if (raw.type === 'tool_use' || raw.toolName) {
return [createNormalizedMessage({
id: baseId,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'tool_use',
toolName: raw.toolName || 'Unknown',
toolInput: raw.toolInput,
toolId: raw.toolCallId || baseId,
})];
}
if (raw.type === 'tool_result') {
return [createNormalizedMessage({
id: baseId,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'tool_result',
toolId: raw.toolCallId || '',
content: raw.output || '',
isError: Boolean(raw.isError),
})];
}
return [];
}
/**
* Normalizes either a Codex history entry or a transformed live SDK event.
*/
normalizeMessage(rawMessage: unknown, sessionId: string | null): NormalizedMessage[] {
const raw = readRawProviderMessage(rawMessage);
if (!raw) {
return [];
}
if (raw.message?.role) {
return this.normalizeHistoryEntry(raw, sessionId);
}
const ts = raw.timestamp || new Date().toISOString();
const baseId = raw.uuid || generateMessageId('codex');
if (raw.type === 'item') {
switch (raw.itemType) {
case 'agent_message':
return [createNormalizedMessage({
id: baseId,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'text',
role: 'assistant',
content: raw.message?.content || '',
})];
case 'reasoning':
return [createNormalizedMessage({
id: baseId,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'thinking',
content: raw.message?.content || '',
})];
case 'command_execution':
return [createNormalizedMessage({
id: baseId,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'tool_use',
toolName: 'Bash',
toolInput: { command: raw.command },
toolId: baseId,
output: raw.output,
exitCode: raw.exitCode,
status: raw.status,
})];
case 'file_change':
return [createNormalizedMessage({
id: baseId,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'tool_use',
toolName: 'FileChanges',
toolInput: raw.changes,
toolId: baseId,
status: raw.status,
})];
case 'mcp_tool_call':
return [createNormalizedMessage({
id: baseId,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'tool_use',
toolName: raw.tool || 'MCP',
toolInput: raw.arguments,
toolId: baseId,
server: raw.server,
result: raw.result,
error: raw.error,
status: raw.status,
})];
case 'web_search':
return [createNormalizedMessage({
id: baseId,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'tool_use',
toolName: 'WebSearch',
toolInput: { query: raw.query },
toolId: baseId,
})];
case 'todo_list':
return [createNormalizedMessage({
id: baseId,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'tool_use',
toolName: 'TodoList',
toolInput: { items: raw.items },
toolId: baseId,
})];
case 'error':
return [createNormalizedMessage({
id: baseId,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'error',
content: raw.message?.content || 'Unknown error',
})];
default:
return [createNormalizedMessage({
id: baseId,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'tool_use',
toolName: raw.itemType || 'Unknown',
toolInput: raw.item || raw,
toolId: baseId,
})];
}
}
if (raw.type === 'turn_complete') {
return [createNormalizedMessage({
id: baseId,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'complete',
})];
}
if (raw.type === 'turn_failed') {
return [createNormalizedMessage({
id: baseId,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'error',
content: raw.error?.message || 'Turn failed',
})];
}
return [];
}
/**
* Loads Codex JSONL history and keeps token usage metadata when projects.js
* provides it.
*/
async fetchHistory(
sessionId: string,
options: FetchHistoryOptions = {},
): Promise<FetchHistoryResult> {
const { limit = null, offset = 0 } = options;
let result: CodexHistoryResult;
try {
result = await loadCodexSessionMessages(sessionId, limit, offset);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.warn(`[CodexProvider] Failed to load session ${sessionId}:`, message);
return { messages: [], total: 0, hasMore: false, offset: 0, limit: null };
}
const rawMessages = Array.isArray(result) ? result : (result.messages || []);
const total = Array.isArray(result) ? rawMessages.length : (result.total || 0);
const hasMore = Array.isArray(result) ? false : Boolean(result.hasMore);
const tokenUsage = Array.isArray(result) ? undefined : result.tokenUsage;
const normalized: NormalizedMessage[] = [];
for (const raw of rawMessages) {
normalized.push(...this.normalizeHistoryEntry(raw, sessionId));
}
const toolResultMap = new Map<string, NormalizedMessage>();
for (const msg of normalized) {
if (msg.kind === 'tool_result' && msg.toolId) {
toolResultMap.set(msg.toolId, msg);
}
}
for (const msg of normalized) {
if (msg.kind === 'tool_use' && msg.toolId && toolResultMap.has(msg.toolId)) {
const toolResult = toolResultMap.get(msg.toolId);
if (toolResult) {
msg.toolResult = { content: toolResult.content, isError: toolResult.isError };
}
}
}
return {
messages: normalized,
total,
hasMore,
offset,
limit,
tokenUsage,
};
}
}

View File

@@ -0,0 +1,143 @@
import spawn from 'cross-spawn';
import type { IProviderAuth } from '@/shared/interfaces.js';
import type { ProviderAuthStatus } from '@/shared/types.js';
type CursorLoginStatus = {
authenticated: boolean;
email: string | null;
method: string | null;
error?: string;
};
export class CursorProviderAuth implements IProviderAuth {
/**
* Checks whether the cursor-agent CLI is available on this host.
*/
private checkInstalled(): boolean {
try {
spawn.sync('cursor-agent', ['--version'], { stdio: 'ignore', timeout: 5000 });
return true;
} catch {
return false;
}
}
/**
* Returns Cursor CLI installation and login status.
*/
async getStatus(): Promise<ProviderAuthStatus> {
const installed = this.checkInstalled();
if (!installed) {
return {
installed,
provider: 'cursor',
authenticated: false,
email: null,
method: null,
error: 'Cursor CLI is not installed',
};
}
const login = await this.checkCursorLogin();
return {
installed,
provider: 'cursor',
authenticated: login.authenticated,
email: login.email,
method: login.method,
error: login.authenticated ? undefined : login.error || 'Not logged in',
};
}
/**
* Runs cursor-agent status and parses the login marker from stdout.
*/
private checkCursorLogin(): Promise<CursorLoginStatus> {
return new Promise((resolve) => {
let processCompleted = false;
let childProcess: ReturnType<typeof spawn> | undefined;
const timeout = setTimeout(() => {
if (!processCompleted) {
processCompleted = true;
childProcess?.kill();
resolve({
authenticated: false,
email: null,
method: null,
error: 'Command timeout',
});
}
}, 5000);
try {
childProcess = spawn('cursor-agent', ['status']);
} catch {
clearTimeout(timeout);
processCompleted = true;
resolve({
authenticated: false,
email: null,
method: null,
error: 'Cursor CLI not found or not installed',
});
return;
}
let stdout = '';
let stderr = '';
childProcess.stdout?.on('data', (data: Buffer) => {
stdout += data.toString();
});
childProcess.stderr?.on('data', (data: Buffer) => {
stderr += data.toString();
});
childProcess.on('close', (code) => {
if (processCompleted) {
return;
}
processCompleted = true;
clearTimeout(timeout);
if (code === 0) {
const emailMatch = stdout.match(/Logged in as ([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})/i);
if (emailMatch?.[1]) {
resolve({ authenticated: true, email: emailMatch[1], method: 'cli' });
return;
}
if (stdout.includes('Logged in')) {
resolve({ authenticated: true, email: 'Logged in', method: 'cli' });
return;
}
resolve({ authenticated: false, email: null, method: null, error: 'Not logged in' });
return;
}
resolve({ authenticated: false, email: null, method: null, error: stderr || 'Not logged in' });
});
childProcess.on('error', () => {
if (processCompleted) {
return;
}
processCompleted = true;
clearTimeout(timeout);
resolve({
authenticated: false,
email: null,
method: null,
error: 'Cursor CLI not found or not installed',
});
});
});
}
}

View File

@@ -0,0 +1,108 @@
import os from 'node:os';
import path from 'node:path';
import { McpProvider } from '@/modules/providers/shared/mcp/mcp.provider.js';
import type { McpScope, ProviderMcpServer, UpsertProviderMcpServerInput } from '@/shared/types.js';
import {
AppError,
readJsonConfig,
readObjectRecord,
readOptionalString,
readStringArray,
readStringRecord,
writeJsonConfig,
} from '@/shared/utils.js';
export class CursorMcpProvider extends McpProvider {
constructor() {
super('cursor', ['user', 'project'], ['stdio', 'http', 'sse']);
}
protected async readScopedServers(scope: McpScope, workspacePath: string): Promise<Record<string, unknown>> {
const filePath = scope === 'user'
? path.join(os.homedir(), '.cursor', 'mcp.json')
: path.join(workspacePath, '.cursor', 'mcp.json');
const config = await readJsonConfig(filePath);
return readObjectRecord(config.mcpServers) ?? {};
}
protected async writeScopedServers(
scope: McpScope,
workspacePath: string,
servers: Record<string, unknown>,
): Promise<void> {
const filePath = scope === 'user'
? path.join(os.homedir(), '.cursor', 'mcp.json')
: path.join(workspacePath, '.cursor', 'mcp.json');
const config = await readJsonConfig(filePath);
config.mcpServers = servers;
await writeJsonConfig(filePath, config);
}
protected buildServerConfig(input: UpsertProviderMcpServerInput): Record<string, unknown> {
if (input.transport === 'stdio') {
if (!input.command?.trim()) {
throw new AppError('command is required for stdio MCP servers.', {
code: 'MCP_COMMAND_REQUIRED',
statusCode: 400,
});
}
return {
command: input.command,
args: input.args ?? [],
env: input.env ?? {},
cwd: input.cwd,
};
}
if (!input.url?.trim()) {
throw new AppError('url is required for http/sse MCP servers.', {
code: 'MCP_URL_REQUIRED',
statusCode: 400,
});
}
return {
url: input.url,
headers: input.headers ?? {},
};
}
protected normalizeServerConfig(
scope: McpScope,
name: string,
rawConfig: unknown,
): ProviderMcpServer | null {
if (!rawConfig || typeof rawConfig !== 'object') {
return null;
}
const config = rawConfig as Record<string, unknown>;
if (typeof config.command === 'string') {
return {
provider: 'cursor',
name,
scope,
transport: 'stdio',
command: config.command,
args: readStringArray(config.args),
env: readStringRecord(config.env),
cwd: readOptionalString(config.cwd),
};
}
if (typeof config.url === 'string') {
return {
provider: 'cursor',
name,
scope,
transport: 'http',
url: config.url,
headers: readStringRecord(config.headers),
};
}
return null;
}
}

View File

@@ -0,0 +1,403 @@
import crypto from 'node:crypto';
import os from 'node:os';
import path from 'node:path';
import { AbstractProvider } from '@/modules/providers/shared/base/abstract.provider.js';
import { CursorProviderAuth } from '@/modules/providers/list/cursor/cursor-auth.provider.js';
import { CursorMcpProvider } from '@/modules/providers/list/cursor/cursor-mcp.provider.js';
import type { IProviderAuth } from '@/shared/interfaces.js';
import type { FetchHistoryOptions, FetchHistoryResult, NormalizedMessage } from '@/shared/types.js';
import { createNormalizedMessage, generateMessageId, readObjectRecord } from '@/shared/utils.js';
const PROVIDER = 'cursor';
type RawProviderMessage = Record<string, any>;
type CursorDbBlob = {
rowid: number;
id: string;
data?: Buffer;
};
type CursorJsonBlob = CursorDbBlob & {
parsed: RawProviderMessage;
};
type CursorMessageBlob = {
id: string;
sequence: number;
rowid: number;
content: RawProviderMessage;
};
function readRawProviderMessage(raw: unknown): RawProviderMessage | null {
return readObjectRecord(raw) as RawProviderMessage | null;
}
export class CursorProvider extends AbstractProvider {
readonly mcp = new CursorMcpProvider();
readonly auth: IProviderAuth = new CursorProviderAuth();
constructor() {
super('cursor');
}
/**
* Loads Cursor's SQLite blob DAG and returns message blobs in conversation
* order. Cursor history is stored as content-addressed blobs rather than JSONL.
*/
private async loadCursorBlobs(sessionId: string, projectPath: string): Promise<CursorMessageBlob[]> {
const sqlite3Module = await import('sqlite3');
const sqlite3 = sqlite3Module.default;
const { open } = await import('sqlite');
const cwdId = crypto.createHash('md5').update(projectPath || process.cwd()).digest('hex');
const storeDbPath = path.join(os.homedir(), '.cursor', 'chats', cwdId, sessionId, 'store.db');
const db = await open({
filename: storeDbPath,
driver: sqlite3.Database,
mode: sqlite3.OPEN_READONLY,
});
try {
const allBlobs = await db.all('SELECT rowid, id, data FROM blobs') as CursorDbBlob[];
const blobMap = new Map<string, CursorDbBlob>();
const parentRefs = new Map<string, string[]>();
const childRefs = new Map<string, string[]>();
const jsonBlobs: CursorJsonBlob[] = [];
for (const blob of allBlobs) {
blobMap.set(blob.id, blob);
if (blob.data && blob.data[0] === 0x7B) {
try {
const parsed = JSON.parse(blob.data.toString('utf8')) as RawProviderMessage;
jsonBlobs.push({ ...blob, parsed });
} catch {
// Cursor can include binary or partial blobs; only JSON blobs become messages.
}
} else if (blob.data) {
const parents: string[] = [];
let i = 0;
while (i < blob.data.length - 33) {
if (blob.data[i] === 0x0A && blob.data[i + 1] === 0x20) {
const parentHash = blob.data.slice(i + 2, i + 34).toString('hex');
if (blobMap.has(parentHash)) {
parents.push(parentHash);
}
i += 34;
} else {
i++;
}
}
if (parents.length > 0) {
parentRefs.set(blob.id, parents);
for (const parentId of parents) {
if (!childRefs.has(parentId)) {
childRefs.set(parentId, []);
}
childRefs.get(parentId)?.push(blob.id);
}
}
}
}
const visited = new Set<string>();
const sorted: CursorDbBlob[] = [];
const visit = (nodeId: string): void => {
if (visited.has(nodeId)) {
return;
}
visited.add(nodeId);
for (const parentId of parentRefs.get(nodeId) || []) {
visit(parentId);
}
const blob = blobMap.get(nodeId);
if (blob) {
sorted.push(blob);
}
};
for (const blob of allBlobs) {
if (!parentRefs.has(blob.id)) {
visit(blob.id);
}
}
for (const blob of allBlobs) {
visit(blob.id);
}
const messageOrder = new Map<string, number>();
let orderIndex = 0;
for (const blob of sorted) {
if (blob.data && blob.data[0] !== 0x7B) {
for (const jsonBlob of jsonBlobs) {
try {
const idBytes = Buffer.from(jsonBlob.id, 'hex');
if (blob.data.includes(idBytes) && !messageOrder.has(jsonBlob.id)) {
messageOrder.set(jsonBlob.id, orderIndex++);
}
} catch {
// Ignore malformed blob ids that cannot be decoded as hex.
}
}
}
}
const sortedJsonBlobs = jsonBlobs.sort((a, b) => {
const aOrder = messageOrder.get(a.id) ?? Number.MAX_SAFE_INTEGER;
const bOrder = messageOrder.get(b.id) ?? Number.MAX_SAFE_INTEGER;
return aOrder !== bOrder ? aOrder - bOrder : a.rowid - b.rowid;
});
const messages: CursorMessageBlob[] = [];
for (let idx = 0; idx < sortedJsonBlobs.length; idx++) {
const blob = sortedJsonBlobs[idx];
const parsed = blob.parsed;
const role = parsed?.role || parsed?.message?.role;
if (role === 'system') {
continue;
}
messages.push({
id: blob.id,
sequence: idx + 1,
rowid: blob.rowid,
content: parsed,
});
}
return messages;
} finally {
await db.close();
}
}
/**
* Normalizes live Cursor CLI NDJSON events. Persisted Cursor history is
* normalized from SQLite blobs in fetchHistory().
*/
normalizeMessage(rawMessage: unknown, sessionId: string | null): NormalizedMessage[] {
const raw = readRawProviderMessage(rawMessage);
if (raw?.type === 'assistant' && raw.message?.content?.[0]?.text) {
return [createNormalizedMessage({
kind: 'stream_delta',
content: raw.message.content[0].text,
sessionId,
provider: PROVIDER,
})];
}
if (typeof rawMessage === 'string' && rawMessage.trim()) {
return [createNormalizedMessage({
kind: 'stream_delta',
content: rawMessage,
sessionId,
provider: PROVIDER,
})];
}
return [];
}
/**
* Fetches and paginates Cursor session history from its project-scoped store.db.
*/
async fetchHistory(
sessionId: string,
options: FetchHistoryOptions = {},
): Promise<FetchHistoryResult> {
const { projectPath = '', limit = null, offset = 0 } = options;
try {
const blobs = await this.loadCursorBlobs(sessionId, projectPath);
const allNormalized = this.normalizeCursorBlobs(blobs, sessionId);
if (limit !== null && limit > 0) {
const start = offset;
const page = allNormalized.slice(start, start + limit);
return {
messages: page,
total: allNormalized.length,
hasMore: start + limit < allNormalized.length,
offset,
limit,
};
}
return {
messages: allNormalized,
total: allNormalized.length,
hasMore: false,
offset: 0,
limit: null,
};
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.warn(`[CursorProvider] Failed to load session ${sessionId}:`, message);
return { messages: [], total: 0, hasMore: false, offset: 0, limit: null };
}
}
/**
* Converts Cursor SQLite message blobs into normalized messages and attaches
* matching tool results to their tool_use entries.
*/
private normalizeCursorBlobs(blobs: CursorMessageBlob[], sessionId: string | null): NormalizedMessage[] {
const messages: NormalizedMessage[] = [];
const toolUseMap = new Map<string, NormalizedMessage>();
const baseTime = Date.now();
for (let i = 0; i < blobs.length; i++) {
const blob = blobs[i];
const content = blob.content;
const ts = new Date(baseTime + (blob.sequence ?? i) * 100).toISOString();
const baseId = blob.id || generateMessageId('cursor');
try {
if (!content?.role || !content?.content) {
if (content?.message?.role && content?.message?.content) {
if (content.message.role === 'system') {
continue;
}
const role = content.message.role === 'user' ? 'user' : 'assistant';
let text = '';
if (Array.isArray(content.message.content)) {
text = content.message.content
.map((part: string | RawProviderMessage) => typeof part === 'string' ? part : part?.text || '')
.filter(Boolean)
.join('\n');
} else if (typeof content.message.content === 'string') {
text = content.message.content;
}
if (text?.trim()) {
messages.push(createNormalizedMessage({
id: baseId,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'text',
role,
content: text,
sequence: blob.sequence,
rowid: blob.rowid,
}));
}
}
continue;
}
if (content.role === 'system') {
continue;
}
if (content.role === 'tool') {
const toolItems = Array.isArray(content.content) ? content.content : [];
for (const item of toolItems) {
if (item?.type !== 'tool-result') {
continue;
}
const toolCallId = item.toolCallId || content.id;
messages.push(createNormalizedMessage({
id: `${baseId}_tr`,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'tool_result',
toolId: toolCallId,
content: item.result || '',
isError: false,
}));
}
continue;
}
const role = content.role === 'user' ? 'user' : 'assistant';
if (Array.isArray(content.content)) {
for (let partIdx = 0; partIdx < content.content.length; partIdx++) {
const part = content.content[partIdx];
if (part?.type === 'text' && part?.text) {
messages.push(createNormalizedMessage({
id: `${baseId}_${partIdx}`,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'text',
role,
content: part.text,
sequence: blob.sequence,
rowid: blob.rowid,
}));
} else if (part?.type === 'reasoning' && part?.text) {
messages.push(createNormalizedMessage({
id: `${baseId}_${partIdx}`,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'thinking',
content: part.text,
}));
} else if (part?.type === 'tool-call' || part?.type === 'tool_use') {
const rawToolName = part.toolName || part.name || 'Unknown Tool';
const toolName = rawToolName === 'ApplyPatch' ? 'Edit' : rawToolName;
const toolId = part.toolCallId || part.id || `tool_${i}_${partIdx}`;
const message = createNormalizedMessage({
id: `${baseId}_${partIdx}`,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'tool_use',
toolName,
toolInput: part.args || part.input,
toolId,
});
messages.push(message);
toolUseMap.set(toolId, message);
}
}
} else if (typeof content.content === 'string' && content.content.trim()) {
messages.push(createNormalizedMessage({
id: baseId,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'text',
role,
content: content.content,
sequence: blob.sequence,
rowid: blob.rowid,
}));
}
} catch (error) {
console.warn('Error normalizing cursor blob:', error);
}
}
for (const msg of messages) {
if (msg.kind === 'tool_result' && msg.toolId && toolUseMap.has(msg.toolId)) {
const toolUse = toolUseMap.get(msg.toolId);
if (toolUse) {
toolUse.toolResult = {
content: msg.content,
isError: msg.isError,
};
}
}
}
messages.sort((a, b) => {
if (a.sequence !== undefined && b.sequence !== undefined) {
return a.sequence - b.sequence;
}
if (a.rowid !== undefined && b.rowid !== undefined) {
return a.rowid - b.rowid;
}
return new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime();
});
return messages;
}
}

View File

@@ -0,0 +1,151 @@
import { readFile } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import spawn from 'cross-spawn';
import type { IProviderAuth } from '@/shared/interfaces.js';
import type { ProviderAuthStatus } from '@/shared/types.js';
import { readObjectRecord, readOptionalString } from '@/shared/utils.js';
type GeminiCredentialsStatus = {
authenticated: boolean;
email: string | null;
method: string | null;
error?: string;
};
export class GeminiProviderAuth implements IProviderAuth {
/**
* Checks whether the Gemini CLI is available on this host.
*/
private checkInstalled(): boolean {
const cliPath = process.env.GEMINI_PATH || 'gemini';
try {
spawn.sync(cliPath, ['--version'], { stdio: 'ignore', timeout: 5000 });
return true;
} catch {
return false;
}
}
/**
* Returns Gemini CLI installation and credential status.
*/
async getStatus(): Promise<ProviderAuthStatus> {
const installed = this.checkInstalled();
if (!installed) {
return {
installed,
provider: 'gemini',
authenticated: false,
email: null,
method: null,
error: 'Gemini CLI is not installed',
};
}
const credentials = await this.checkCredentials();
return {
installed,
provider: 'gemini',
authenticated: credentials.authenticated,
email: credentials.email,
method: credentials.method,
error: credentials.authenticated ? undefined : credentials.error || 'Not authenticated',
};
}
/**
* Checks Gemini credentials from API key env vars or local OAuth credential files.
*/
private async checkCredentials(): Promise<GeminiCredentialsStatus> {
if (process.env.GEMINI_API_KEY?.trim()) {
return { authenticated: true, email: 'API Key Auth', method: 'api_key' };
}
try {
const credsPath = path.join(os.homedir(), '.gemini', 'oauth_creds.json');
const content = await readFile(credsPath, 'utf8');
const creds = readObjectRecord(JSON.parse(content)) ?? {};
const accessToken = readOptionalString(creds.access_token);
if (!accessToken) {
return {
authenticated: false,
email: null,
method: null,
error: 'No valid tokens found in oauth_creds',
};
}
const refreshToken = readOptionalString(creds.refresh_token);
const tokenInfo = await this.getTokenInfoEmail(accessToken);
if (tokenInfo.valid) {
return {
authenticated: true,
email: tokenInfo.email || 'OAuth Session',
method: 'credentials_file',
};
}
if (!refreshToken) {
return {
authenticated: false,
email: null,
method: 'credentials_file',
error: 'Access token invalid and no refresh token found',
};
}
return {
authenticated: true,
email: await this.getActiveAccountEmail() || 'OAuth Session',
method: 'credentials_file',
};
} catch {
return {
authenticated: false,
email: null,
method: null,
error: 'Gemini CLI not configured',
};
}
}
/**
* Validates a Gemini OAuth access token and returns an email when Google reports one.
*/
private async getTokenInfoEmail(accessToken: string): Promise<{ valid: boolean; email: string | null }> {
try {
const tokenRes = await fetch(`https://oauth2.googleapis.com/tokeninfo?access_token=${accessToken}`);
if (!tokenRes.ok) {
return { valid: false, email: null };
}
const tokenInfo = readObjectRecord(await tokenRes.json());
return {
valid: true,
email: readOptionalString(tokenInfo?.email) ?? null,
};
} catch {
return { valid: false, email: null };
}
}
/**
* Reads Gemini's active local Google account as an offline fallback for display.
*/
private async getActiveAccountEmail(): Promise<string | null> {
try {
const accPath = path.join(os.homedir(), '.gemini', 'google_accounts.json');
const accContent = await readFile(accPath, 'utf8');
const accounts = readObjectRecord(JSON.parse(accContent));
return readOptionalString(accounts?.active) ?? null;
} catch {
return null;
}
}
}

View File

@@ -0,0 +1,110 @@
import os from 'node:os';
import path from 'node:path';
import { McpProvider } from '@/modules/providers/shared/mcp/mcp.provider.js';
import type { McpScope, ProviderMcpServer, UpsertProviderMcpServerInput } from '@/shared/types.js';
import {
AppError,
readJsonConfig,
readObjectRecord,
readOptionalString,
readStringArray,
readStringRecord,
writeJsonConfig,
} from '@/shared/utils.js';
export class GeminiMcpProvider extends McpProvider {
constructor() {
super('gemini', ['user', 'project'], ['stdio', 'http', 'sse']);
}
protected async readScopedServers(scope: McpScope, workspacePath: string): Promise<Record<string, unknown>> {
const filePath = scope === 'user'
? path.join(os.homedir(), '.gemini', 'settings.json')
: path.join(workspacePath, '.gemini', 'settings.json');
const config = await readJsonConfig(filePath);
return readObjectRecord(config.mcpServers) ?? {};
}
protected async writeScopedServers(
scope: McpScope,
workspacePath: string,
servers: Record<string, unknown>,
): Promise<void> {
const filePath = scope === 'user'
? path.join(os.homedir(), '.gemini', 'settings.json')
: path.join(workspacePath, '.gemini', 'settings.json');
const config = await readJsonConfig(filePath);
config.mcpServers = servers;
await writeJsonConfig(filePath, config);
}
protected buildServerConfig(input: UpsertProviderMcpServerInput): Record<string, unknown> {
if (input.transport === 'stdio') {
if (!input.command?.trim()) {
throw new AppError('command is required for stdio MCP servers.', {
code: 'MCP_COMMAND_REQUIRED',
statusCode: 400,
});
}
return {
command: input.command,
args: input.args ?? [],
env: input.env ?? {},
cwd: input.cwd,
};
}
if (!input.url?.trim()) {
throw new AppError('url is required for http/sse MCP servers.', {
code: 'MCP_URL_REQUIRED',
statusCode: 400,
});
}
return {
type: input.transport,
url: input.url,
headers: input.headers ?? {},
};
}
protected normalizeServerConfig(
scope: McpScope,
name: string,
rawConfig: unknown,
): ProviderMcpServer | null {
if (!rawConfig || typeof rawConfig !== 'object') {
return null;
}
const config = rawConfig as Record<string, unknown>;
if (typeof config.command === 'string') {
return {
provider: 'gemini',
name,
scope,
transport: 'stdio',
command: config.command,
args: readStringArray(config.args),
env: readStringRecord(config.env),
cwd: readOptionalString(config.cwd),
};
}
if (typeof config.url === 'string') {
const transport = readOptionalString(config.type) === 'sse' ? 'sse' : 'http';
return {
provider: 'gemini',
name,
scope,
transport,
url: config.url,
headers: readStringRecord(config.headers),
};
}
return null;
}
}

View File

@@ -0,0 +1,235 @@
import sessionManager from '@/sessionManager.js';
import { getGeminiCliSessionMessages } from '@/projects.js';
import { AbstractProvider } from '@/modules/providers/shared/base/abstract.provider.js';
import { GeminiProviderAuth } from '@/modules/providers/list/gemini/gemini-auth.provider.js';
import { GeminiMcpProvider } from '@/modules/providers/list/gemini/gemini-mcp.provider.js';
import type { IProviderAuth } from '@/shared/interfaces.js';
import type { FetchHistoryOptions, FetchHistoryResult, NormalizedMessage } from '@/shared/types.js';
import { createNormalizedMessage, generateMessageId, readObjectRecord } from '@/shared/utils.js';
const PROVIDER = 'gemini';
type RawProviderMessage = Record<string, any>;
function readRawProviderMessage(raw: unknown): RawProviderMessage | null {
return readObjectRecord(raw) as RawProviderMessage | null;
}
export class GeminiProvider extends AbstractProvider {
readonly mcp = new GeminiMcpProvider();
readonly auth: IProviderAuth = new GeminiProviderAuth();
constructor() {
super('gemini');
}
/**
* Normalizes live Gemini stream-json events into the shared message shape.
*
* Gemini history uses a different session file shape, so fetchHistory handles
* that separately after loading raw persisted messages.
*/
normalizeMessage(rawMessage: unknown, sessionId: string | null): NormalizedMessage[] {
const raw = readRawProviderMessage(rawMessage);
if (!raw) {
return [];
}
const ts = raw.timestamp || new Date().toISOString();
const baseId = raw.uuid || generateMessageId('gemini');
if (raw.type === 'message' && raw.role === 'assistant') {
const content = raw.content || '';
const messages: NormalizedMessage[] = [];
if (content) {
messages.push(createNormalizedMessage({
id: baseId,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'stream_delta',
content,
}));
}
if (raw.delta !== true) {
messages.push(createNormalizedMessage({
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'stream_end',
}));
}
return messages;
}
if (raw.type === 'tool_use') {
return [createNormalizedMessage({
id: baseId,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'tool_use',
toolName: raw.tool_name,
toolInput: raw.parameters || {},
toolId: raw.tool_id || baseId,
})];
}
if (raw.type === 'tool_result') {
return [createNormalizedMessage({
id: baseId,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'tool_result',
toolId: raw.tool_id || '',
content: raw.output === undefined ? '' : String(raw.output),
isError: raw.status === 'error',
})];
}
if (raw.type === 'result') {
const messages = [createNormalizedMessage({
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'stream_end',
})];
if (raw.stats?.total_tokens) {
messages.push(createNormalizedMessage({
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'status',
text: 'Complete',
tokens: raw.stats.total_tokens,
canInterrupt: false,
}));
}
return messages;
}
if (raw.type === 'error') {
return [createNormalizedMessage({
id: baseId,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'error',
content: raw.error || raw.message || 'Unknown Gemini streaming error',
})];
}
return [];
}
/**
* Loads Gemini history from the in-memory session manager first, then falls
* back to Gemini CLI session files on disk.
*/
async fetchHistory(
sessionId: string,
_options: FetchHistoryOptions = {},
): Promise<FetchHistoryResult> {
let rawMessages: RawProviderMessage[];
try {
rawMessages = sessionManager.getSessionMessages(sessionId) as RawProviderMessage[];
if (rawMessages.length === 0) {
rawMessages = await getGeminiCliSessionMessages(sessionId) as RawProviderMessage[];
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.warn(`[GeminiProvider] Failed to load session ${sessionId}:`, message);
return { messages: [], total: 0, hasMore: false, offset: 0, limit: null };
}
const normalized: NormalizedMessage[] = [];
for (let i = 0; i < rawMessages.length; i++) {
const raw = rawMessages[i];
const ts = raw.timestamp || new Date().toISOString();
const baseId = raw.uuid || generateMessageId('gemini');
const role = raw.message?.role || raw.role;
const content = raw.message?.content || raw.content;
if (!role || !content) {
continue;
}
const normalizedRole = role === 'user' ? 'user' : 'assistant';
if (Array.isArray(content)) {
for (let partIdx = 0; partIdx < content.length; partIdx++) {
const part = content[partIdx];
if (part.type === 'text' && part.text) {
normalized.push(createNormalizedMessage({
id: `${baseId}_${partIdx}`,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'text',
role: normalizedRole,
content: part.text,
}));
} else if (part.type === 'tool_use') {
normalized.push(createNormalizedMessage({
id: `${baseId}_${partIdx}`,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'tool_use',
toolName: part.name,
toolInput: part.input,
toolId: part.id || generateMessageId('gemini_tool'),
}));
} else if (part.type === 'tool_result') {
normalized.push(createNormalizedMessage({
id: `${baseId}_${partIdx}`,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'tool_result',
toolId: part.tool_use_id || '',
content: part.content === undefined ? '' : String(part.content),
isError: Boolean(part.is_error),
}));
}
}
} else if (typeof content === 'string' && content.trim()) {
normalized.push(createNormalizedMessage({
id: baseId,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'text',
role: normalizedRole,
content,
}));
}
}
const toolResultMap = new Map<string, NormalizedMessage>();
for (const msg of normalized) {
if (msg.kind === 'tool_result' && msg.toolId) {
toolResultMap.set(msg.toolId, msg);
}
}
for (const msg of normalized) {
if (msg.kind === 'tool_use' && msg.toolId && toolResultMap.has(msg.toolId)) {
const toolResult = toolResultMap.get(msg.toolId);
if (toolResult) {
msg.toolResult = { content: toolResult.content, isError: toolResult.isError };
}
}
}
return {
messages: normalized,
total: normalized.length,
hasMore: false,
offset: 0,
limit: null,
};
}
}

View File

@@ -0,0 +1,36 @@
import { ClaudeProvider } from '@/modules/providers/list/claude/claude.provider.js';
import { CodexProvider } from '@/modules/providers/list/codex/codex.provider.js';
import { CursorProvider } from '@/modules/providers/list/cursor/cursor.provider.js';
import { GeminiProvider } from '@/modules/providers/list/gemini/gemini.provider.js';
import type { IProvider } from '@/shared/interfaces.js';
import type { LLMProvider } from '@/shared/types.js';
import { AppError } from '@/shared/utils.js';
const providers: Record<LLMProvider, IProvider> = {
claude: new ClaudeProvider(),
codex: new CodexProvider(),
cursor: new CursorProvider(),
gemini: new GeminiProvider(),
};
/**
* Central registry for resolving concrete provider implementations by id.
*/
export const providerRegistry = {
listProviders(): IProvider[] {
return Object.values(providers);
},
resolveProvider(provider: string): IProvider {
const key = provider as LLMProvider;
const resolvedProvider = providers[key];
if (!resolvedProvider) {
throw new AppError(`Unsupported provider "${provider}".`, {
code: 'UNSUPPORTED_PROVIDER',
statusCode: 400,
});
}
return resolvedProvider;
},
};

View File

@@ -0,0 +1,217 @@
import express, { type Request, type Response } from 'express';
import { providerAuthService } from '@/modules/providers/services/provider-auth.service.js';
import { providerMcpService } from '@/modules/providers/services/mcp.service.js';
import type { LLMProvider, McpScope, McpTransport, UpsertProviderMcpServerInput } from '@/shared/types.js';
import { AppError, asyncHandler, createApiSuccessResponse } from '@/shared/utils.js';
const router = express.Router();
const readPathParam = (value: unknown, name: string): string => {
if (typeof value === 'string') {
return value;
}
if (Array.isArray(value) && typeof value[0] === 'string') {
return value[0];
}
throw new AppError(`${name} path parameter is invalid.`, {
code: 'INVALID_PATH_PARAMETER',
statusCode: 400,
});
};
const normalizeProviderParam = (value: unknown): string =>
readPathParam(value, 'provider').trim().toLowerCase();
const readOptionalQueryString = (value: unknown): string | undefined => {
if (typeof value !== 'string') {
return undefined;
}
const normalized = value.trim();
return normalized.length > 0 ? normalized : undefined;
};
const parseMcpScope = (value: unknown): McpScope | undefined => {
if (value === undefined) {
return undefined;
}
const normalized = readOptionalQueryString(value);
if (!normalized) {
return undefined;
}
if (normalized === 'user' || normalized === 'local' || normalized === 'project') {
return normalized;
}
throw new AppError(`Unsupported MCP scope "${normalized}".`, {
code: 'INVALID_MCP_SCOPE',
statusCode: 400,
});
};
const parseMcpTransport = (value: unknown): McpTransport => {
const normalized = readOptionalQueryString(value);
if (!normalized) {
throw new AppError('transport is required.', {
code: 'MCP_TRANSPORT_REQUIRED',
statusCode: 400,
});
}
if (normalized === 'stdio' || normalized === 'http' || normalized === 'sse') {
return normalized;
}
throw new AppError(`Unsupported MCP transport "${normalized}".`, {
code: 'INVALID_MCP_TRANSPORT',
statusCode: 400,
});
};
const parseMcpUpsertPayload = (payload: unknown): UpsertProviderMcpServerInput => {
if (!payload || typeof payload !== 'object') {
throw new AppError('Request body must be an object.', {
code: 'INVALID_REQUEST_BODY',
statusCode: 400,
});
}
const body = payload as Record<string, unknown>;
const name = readOptionalQueryString(body.name);
if (!name) {
throw new AppError('name is required.', {
code: 'MCP_NAME_REQUIRED',
statusCode: 400,
});
}
const transport = parseMcpTransport(body.transport);
const scope = parseMcpScope(body.scope);
const workspacePath = readOptionalQueryString(body.workspacePath);
return {
name,
transport,
scope,
workspacePath,
command: readOptionalQueryString(body.command),
args: Array.isArray(body.args) ? body.args.filter((entry): entry is string => typeof entry === 'string') : undefined,
env: typeof body.env === 'object' && body.env !== null
? Object.fromEntries(
Object.entries(body.env as Record<string, unknown>).filter(
(entry): entry is [string, string] => typeof entry[1] === 'string',
),
)
: undefined,
cwd: readOptionalQueryString(body.cwd),
url: readOptionalQueryString(body.url),
headers: typeof body.headers === 'object' && body.headers !== null
? Object.fromEntries(
Object.entries(body.headers as Record<string, unknown>).filter(
(entry): entry is [string, string] => typeof entry[1] === 'string',
),
)
: undefined,
envVars: Array.isArray(body.envVars)
? body.envVars.filter((entry): entry is string => typeof entry === 'string')
: undefined,
bearerTokenEnvVar: readOptionalQueryString(body.bearerTokenEnvVar),
envHttpHeaders: typeof body.envHttpHeaders === 'object' && body.envHttpHeaders !== null
? Object.fromEntries(
Object.entries(body.envHttpHeaders as Record<string, unknown>).filter(
(entry): entry is [string, string] => typeof entry[1] === 'string',
),
)
: undefined,
};
};
const parseProvider = (value: unknown): LLMProvider => {
const normalized = normalizeProviderParam(value);
if (normalized === 'claude' || normalized === 'codex' || normalized === 'cursor' || normalized === 'gemini') {
return normalized;
}
throw new AppError(`Unsupported provider "${normalized}".`, {
code: 'UNSUPPORTED_PROVIDER',
statusCode: 400,
});
};
router.get(
'/:provider/auth/status',
asyncHandler(async (req: Request, res: Response) => {
const provider = parseProvider(req.params.provider);
const status = await providerAuthService.getProviderAuthStatus(provider);
res.json(status);
}),
);
router.get(
'/:provider/mcp/servers',
asyncHandler(async (req: Request, res: Response) => {
const provider = parseProvider(req.params.provider);
const workspacePath = readOptionalQueryString(req.query.workspacePath);
const scope = parseMcpScope(req.query.scope);
if (scope) {
const servers = await providerMcpService.listProviderMcpServersForScope(provider, scope, { workspacePath });
res.json(createApiSuccessResponse({ provider, scope, servers }));
return;
}
const groupedServers = await providerMcpService.listProviderMcpServers(provider, { workspacePath });
res.json(createApiSuccessResponse({ provider, scopes: groupedServers }));
}),
);
router.post(
'/:provider/mcp/servers',
asyncHandler(async (req: Request, res: Response) => {
const provider = parseProvider(req.params.provider);
const payload = parseMcpUpsertPayload(req.body);
const server = await providerMcpService.upsertProviderMcpServer(provider, payload);
res.status(201).json(createApiSuccessResponse({ server }));
}),
);
router.delete(
'/:provider/mcp/servers/:name',
asyncHandler(async (req: Request, res: Response) => {
const provider = parseProvider(req.params.provider);
const scope = parseMcpScope(req.query.scope);
const workspacePath = readOptionalQueryString(req.query.workspacePath);
const result = await providerMcpService.removeProviderMcpServer(provider, {
name: readPathParam(req.params.name, 'name'),
scope,
workspacePath,
});
res.json(createApiSuccessResponse(result));
}),
);
router.post(
'/mcp/servers/global',
asyncHandler(async (req: Request, res: Response) => {
const payload = parseMcpUpsertPayload(req.body);
if (payload.scope === 'local') {
throw new AppError('Global MCP add supports only "user" or "project" scopes.', {
code: 'INVALID_GLOBAL_MCP_SCOPE',
statusCode: 400,
});
}
const results = await providerMcpService.addMcpServerToAllProviders({
...payload,
scope: payload.scope === 'user' ? 'user' : 'project',
});
res.status(201).json(createApiSuccessResponse({ results }));
}),
);
export default router;

View File

@@ -0,0 +1,94 @@
import os from 'node:os';
import { providerRegistry } from '@/modules/providers/provider.registry.js';
import type { LLMProvider, McpScope, ProviderMcpServer, UpsertProviderMcpServerInput } from '@/shared/types.js';
import { AppError } from '@/shared/utils.js';
/** Cursor MCP is not supported on Windows hosts (no Cursor CLI integration). */
function includeProviderInGlobalMcp(providerId: LLMProvider): boolean {
if (providerId === 'cursor' && os.platform() === 'win32') {
return false;
}
return true;
}
export const providerMcpService = {
/**
* Lists MCP servers for one provider grouped by supported scopes.
*/
async listProviderMcpServers(
providerName: string,
options?: { workspacePath?: string },
): Promise<Record<McpScope, ProviderMcpServer[]>> {
const provider = providerRegistry.resolveProvider(providerName);
return provider.mcp.listServers(options);
},
/**
* Lists MCP servers for one provider scope.
*/
async listProviderMcpServersForScope(
providerName: string,
scope: McpScope,
options?: { workspacePath?: string },
): Promise<ProviderMcpServer[]> {
const provider = providerRegistry.resolveProvider(providerName);
return provider.mcp.listServersForScope(scope, options);
},
/**
* Adds or updates one provider MCP server.
*/
async upsertProviderMcpServer(
providerName: string,
input: UpsertProviderMcpServerInput,
): Promise<ProviderMcpServer> {
const provider = providerRegistry.resolveProvider(providerName);
return provider.mcp.upsertServer(input);
},
/**
* Removes one provider MCP server.
*/
async removeProviderMcpServer(
providerName: string,
input: { name: string; scope?: McpScope; workspacePath?: string },
): Promise<{ removed: boolean; provider: LLMProvider; name: string; scope: McpScope }> {
const provider = providerRegistry.resolveProvider(providerName);
return provider.mcp.removeServer(input);
},
/**
* Adds one HTTP/stdio MCP server to every provider.
*/
async addMcpServerToAllProviders(
input: Omit<UpsertProviderMcpServerInput, 'scope'> & { scope?: Exclude<McpScope, 'local'> },
): Promise<Array<{ provider: LLMProvider; created: boolean; error?: string }>> {
if (input.transport !== 'stdio' && input.transport !== 'http') {
throw new AppError('Global MCP add supports only "stdio" and "http".', {
code: 'INVALID_GLOBAL_MCP_TRANSPORT',
statusCode: 400,
});
}
const scope = input.scope ?? 'project';
const results: Array<{ provider: LLMProvider; created: boolean; error?: string }> = [];
const providers = providerRegistry.listProviders().filter((p) => includeProviderInGlobalMcp(p.id));
for (const provider of providers) {
try {
await provider.mcp.upsertServer({ ...input, scope });
results.push({ provider: provider.id, created: true });
} catch (error) {
results.push({
provider: provider.id,
created: false,
error: error instanceof Error ? error.message : 'Unknown error',
});
}
}
return results;
},
};

View File

@@ -0,0 +1,12 @@
import { providerRegistry } from '@/modules/providers/provider.registry.js';
import type { ProviderAuthStatus } from '@/shared/types.js';
export const providerAuthService = {
/**
* Resolves a provider and returns its installation/authentication status.
*/
async getProviderAuthStatus(providerName: string): Promise<ProviderAuthStatus> {
const provider = providerRegistry.resolveProvider(providerName);
return provider.auth.getStatus();
},
};

View File

@@ -0,0 +1,45 @@
import { providerRegistry } from '@/modules/providers/provider.registry.js';
import type {
FetchHistoryOptions,
FetchHistoryResult,
LLMProvider,
NormalizedMessage,
} from '@/shared/types.js';
/**
* Application service for provider-backed session message operations.
*
* Callers pass a provider id and this service resolves the concrete provider
* class, keeping normalization/history call sites decoupled from implementation
* file layout.
*/
export const sessionsService = {
/**
* Lists provider ids that can load session history and normalize live messages.
*/
listProviderIds(): LLMProvider[] {
return providerRegistry.listProviders().map((provider) => provider.id);
},
/**
* Normalizes one provider-native event into frontend session message events.
*/
normalizeMessage(
providerName: string,
raw: unknown,
sessionId: string | null,
): NormalizedMessage[] {
return providerRegistry.resolveProvider(providerName).normalizeMessage(raw, sessionId);
},
/**
* Fetches normalized persisted session history for one provider/session pair.
*/
fetchHistory(
providerName: string,
sessionId: string,
options?: FetchHistoryOptions,
): Promise<FetchHistoryResult> {
return providerRegistry.resolveProvider(providerName).fetchHistory(sessionId, options);
},
};

View File

@@ -0,0 +1,31 @@
import type { IProvider, IProviderAuth, IProviderMcp } from '@/shared/interfaces.js';
import type {
FetchHistoryOptions,
FetchHistoryResult,
LLMProvider,
NormalizedMessage,
} from '@/shared/types.js';
/**
* Shared provider base.
*
* Concrete providers must expose auth/MCP handlers and implement message
* normalization/history loading because those behaviors depend on native
* SDK/CLI formats.
*/
export abstract class AbstractProvider implements IProvider {
readonly id: LLMProvider;
abstract readonly mcp: IProviderMcp;
abstract readonly auth: IProviderAuth;
protected constructor(id: LLMProvider) {
this.id = id;
}
abstract normalizeMessage(raw: unknown, sessionId: string | null): NormalizedMessage[];
abstract fetchHistory(
sessionId: string,
options?: FetchHistoryOptions,
): Promise<FetchHistoryResult>;
}

View File

@@ -0,0 +1,151 @@
import path from 'node:path';
import type { IProviderMcp } from '@/shared/interfaces.js';
import type { LLMProvider, McpScope, McpTransport, ProviderMcpServer, UpsertProviderMcpServerInput } from '@/shared/types.js';
import { AppError } from '@/shared/utils.js';
const resolveWorkspacePath = (workspacePath?: string): string =>
path.resolve(workspacePath ?? process.cwd());
const normalizeServerName = (name: string): string => {
const normalized = name.trim();
if (!normalized) {
throw new AppError('MCP server name is required.', {
code: 'MCP_SERVER_NAME_REQUIRED',
statusCode: 400,
});
}
return normalized;
};
/**
* Shared MCP provider for provider-specific config readers/writers.
*/
export abstract class McpProvider implements IProviderMcp {
protected readonly provider: LLMProvider;
protected readonly supportedScopes: McpScope[];
protected readonly supportedTransports: McpTransport[];
protected constructor(
provider: LLMProvider,
supportedScopes: McpScope[],
supportedTransports: McpTransport[],
) {
this.provider = provider;
this.supportedScopes = supportedScopes;
this.supportedTransports = supportedTransports;
}
async listServers(options?: { workspacePath?: string }): Promise<Record<McpScope, ProviderMcpServer[]>> {
const grouped: Record<McpScope, ProviderMcpServer[]> = {
user: [],
local: [],
project: [],
};
for (const scope of this.supportedScopes) {
grouped[scope] = await this.listServersForScope(scope, options);
}
return grouped;
}
async listServersForScope(
scope: McpScope,
options?: { workspacePath?: string },
): Promise<ProviderMcpServer[]> {
if (!this.supportedScopes.includes(scope)) {
return [];
}
const workspacePath = resolveWorkspacePath(options?.workspacePath);
const scopedServers = await this.readScopedServers(scope, workspacePath);
return Object.entries(scopedServers)
.map(([name, rawConfig]) => this.normalizeServerConfig(scope, name, rawConfig))
.filter((entry): entry is ProviderMcpServer => entry !== null);
}
async upsertServer(input: UpsertProviderMcpServerInput): Promise<ProviderMcpServer> {
const scope = input.scope ?? 'project';
this.assertScopeAndTransport(scope, input.transport);
const workspacePath = resolveWorkspacePath(input.workspacePath);
const normalizedName = normalizeServerName(input.name);
const scopedServers = await this.readScopedServers(scope, workspacePath);
scopedServers[normalizedName] = this.buildServerConfig(input);
await this.writeScopedServers(scope, workspacePath, scopedServers);
return {
provider: this.provider,
name: normalizedName,
scope,
transport: input.transport,
command: input.command,
args: input.args,
env: input.env,
cwd: input.cwd,
url: input.url,
headers: input.headers,
envVars: input.envVars,
bearerTokenEnvVar: input.bearerTokenEnvVar,
envHttpHeaders: input.envHttpHeaders,
};
}
async removeServer(
input: { name: string; scope?: McpScope; workspacePath?: string },
): Promise<{ removed: boolean; provider: LLMProvider; name: string; scope: McpScope }> {
const scope = input.scope ?? 'project';
this.assertScope(scope);
const workspacePath = resolveWorkspacePath(input.workspacePath);
const normalizedName = normalizeServerName(input.name);
const scopedServers = await this.readScopedServers(scope, workspacePath);
const removed = Object.prototype.hasOwnProperty.call(scopedServers, normalizedName);
if (removed) {
delete scopedServers[normalizedName];
await this.writeScopedServers(scope, workspacePath, scopedServers);
}
return { removed, provider: this.provider, name: normalizedName, scope };
}
protected abstract readScopedServers(
scope: McpScope,
workspacePath: string,
): Promise<Record<string, unknown>>;
protected abstract writeScopedServers(
scope: McpScope,
workspacePath: string,
servers: Record<string, unknown>,
): Promise<void>;
protected abstract buildServerConfig(input: UpsertProviderMcpServerInput): Record<string, unknown>;
protected abstract normalizeServerConfig(
scope: McpScope,
name: string,
rawConfig: unknown,
): ProviderMcpServer | null;
protected assertScope(scope: McpScope): void {
if (!this.supportedScopes.includes(scope)) {
throw new AppError(`Provider "${this.provider}" does not support "${scope}" MCP scope.`, {
code: 'MCP_SCOPE_NOT_SUPPORTED',
statusCode: 400,
});
}
}
protected assertScopeAndTransport(scope: McpScope, transport: McpTransport): void {
this.assertScope(scope);
if (!this.supportedTransports.includes(transport)) {
throw new AppError(`Provider "${this.provider}" does not support "${transport}" MCP transport.`, {
code: 'MCP_TRANSPORT_NOT_SUPPORTED',
statusCode: 400,
});
}
}
}

View File

@@ -0,0 +1,293 @@
import assert from 'node:assert/strict';
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import test from 'node:test';
import TOML from '@iarna/toml';
import { providerMcpService } from '@/modules/providers/services/mcp.service.js';
import { AppError } from '@/shared/utils.js';
const patchHomeDir = (nextHomeDir: string) => {
const original = os.homedir;
(os as any).homedir = () => nextHomeDir;
return () => {
(os as any).homedir = original;
};
};
const readJson = async (filePath: string): Promise<Record<string, unknown>> => {
const content = await fs.readFile(filePath, 'utf8');
return JSON.parse(content) as Record<string, unknown>;
};
/**
* This test covers Claude MCP support for all scopes (user/local/project) and all transports (stdio/http/sse),
* including add, update/list, and remove operations.
*/
test('providerMcpService handles claude MCP scopes/transports with file-backed persistence', { concurrency: false }, async () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'llm-mcp-claude-'));
const workspacePath = path.join(tempRoot, 'workspace');
await fs.mkdir(workspacePath, { recursive: true });
const restoreHomeDir = patchHomeDir(tempRoot);
try {
await providerMcpService.upsertProviderMcpServer('claude', {
name: 'claude-user-stdio',
scope: 'user',
transport: 'stdio',
command: 'npx',
args: ['-y', 'my-server'],
env: { API_KEY: 'secret' },
});
await providerMcpService.upsertProviderMcpServer('claude', {
name: 'claude-local-http',
scope: 'local',
transport: 'http',
url: 'https://example.com/mcp',
headers: { Authorization: 'Bearer token' },
workspacePath,
});
await providerMcpService.upsertProviderMcpServer('claude', {
name: 'claude-project-sse',
scope: 'project',
transport: 'sse',
url: 'https://example.com/sse',
headers: { 'X-API-Key': 'abc' },
workspacePath,
});
const grouped = await providerMcpService.listProviderMcpServers('claude', { workspacePath });
assert.ok(grouped.user.some((server) => server.name === 'claude-user-stdio' && server.transport === 'stdio'));
assert.ok(grouped.local.some((server) => server.name === 'claude-local-http' && server.transport === 'http'));
assert.ok(grouped.project.some((server) => server.name === 'claude-project-sse' && server.transport === 'sse'));
// update behavior is the same upsert route with same name
await providerMcpService.upsertProviderMcpServer('claude', {
name: 'claude-project-sse',
scope: 'project',
transport: 'sse',
url: 'https://example.com/sse-updated',
headers: { 'X-API-Key': 'updated' },
workspacePath,
});
const projectConfig = await readJson(path.join(workspacePath, '.mcp.json'));
const projectServers = projectConfig.mcpServers as Record<string, unknown>;
const projectServer = projectServers['claude-project-sse'] as Record<string, unknown>;
assert.equal(projectServer.url, 'https://example.com/sse-updated');
const removeResult = await providerMcpService.removeProviderMcpServer('claude', {
name: 'claude-local-http',
scope: 'local',
workspacePath,
});
assert.equal(removeResult.removed, true);
} finally {
restoreHomeDir();
await fs.rm(tempRoot, { recursive: true, force: true });
}
});
/**
* This test covers Codex MCP support for user/project scopes, stdio/http formats,
* and validation for unsupported scope/transport combinations.
*/
test('providerMcpService handles codex MCP TOML config and capability validation', { concurrency: false }, async () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'llm-mcp-codex-'));
const workspacePath = path.join(tempRoot, 'workspace');
await fs.mkdir(workspacePath, { recursive: true });
const restoreHomeDir = patchHomeDir(tempRoot);
try {
await providerMcpService.upsertProviderMcpServer('codex', {
name: 'codex-user-stdio',
scope: 'user',
transport: 'stdio',
command: 'python',
args: ['server.py'],
env: { API_KEY: 'x' },
envVars: ['API_KEY'],
cwd: '/tmp',
});
await providerMcpService.upsertProviderMcpServer('codex', {
name: 'codex-project-http',
scope: 'project',
transport: 'http',
url: 'https://codex.example.com/mcp',
headers: { 'X-Custom-Header': 'value' },
envHttpHeaders: { 'X-API-Key': 'MY_API_KEY_ENV' },
bearerTokenEnvVar: 'MY_API_TOKEN',
workspacePath,
});
const userTomlPath = path.join(tempRoot, '.codex', 'config.toml');
const userConfig = TOML.parse(await fs.readFile(userTomlPath, 'utf8')) as Record<string, unknown>;
const userServers = userConfig.mcp_servers as Record<string, unknown>;
const userStdio = userServers['codex-user-stdio'] as Record<string, unknown>;
assert.equal(userStdio.command, 'python');
const projectTomlPath = path.join(workspacePath, '.codex', 'config.toml');
const projectConfig = TOML.parse(await fs.readFile(projectTomlPath, 'utf8')) as Record<string, unknown>;
const projectServers = projectConfig.mcp_servers as Record<string, unknown>;
const projectHttp = projectServers['codex-project-http'] as Record<string, unknown>;
assert.equal(projectHttp.url, 'https://codex.example.com/mcp');
await assert.rejects(
providerMcpService.upsertProviderMcpServer('codex', {
name: 'codex-local',
scope: 'local',
transport: 'stdio',
command: 'node',
}),
(error: unknown) =>
error instanceof AppError &&
error.code === 'MCP_SCOPE_NOT_SUPPORTED' &&
error.statusCode === 400,
);
await assert.rejects(
providerMcpService.upsertProviderMcpServer('codex', {
name: 'codex-sse',
scope: 'project',
transport: 'sse',
url: 'https://example.com/sse',
workspacePath,
}),
(error: unknown) =>
error instanceof AppError &&
error.code === 'MCP_TRANSPORT_NOT_SUPPORTED' &&
error.statusCode === 400,
);
} finally {
restoreHomeDir();
await fs.rm(tempRoot, { recursive: true, force: true });
}
});
/**
* This test covers Gemini/Cursor MCP JSON formats and user/project scope persistence.
*/
test('providerMcpService handles gemini and cursor MCP JSON config formats', { concurrency: false }, async () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'llm-mcp-gc-'));
const workspacePath = path.join(tempRoot, 'workspace');
await fs.mkdir(workspacePath, { recursive: true });
const restoreHomeDir = patchHomeDir(tempRoot);
try {
await providerMcpService.upsertProviderMcpServer('gemini', {
name: 'gemini-stdio',
scope: 'user',
transport: 'stdio',
command: 'node',
args: ['server.js'],
env: { TOKEN: '$TOKEN' },
cwd: './server',
});
await providerMcpService.upsertProviderMcpServer('gemini', {
name: 'gemini-http',
scope: 'project',
transport: 'http',
url: 'https://gemini.example.com/mcp',
headers: { Authorization: 'Bearer token' },
workspacePath,
});
await providerMcpService.upsertProviderMcpServer('cursor', {
name: 'cursor-stdio',
scope: 'project',
transport: 'stdio',
command: 'npx',
args: ['-y', 'mcp-server'],
env: { API_KEY: 'value' },
workspacePath,
});
await providerMcpService.upsertProviderMcpServer('cursor', {
name: 'cursor-http',
scope: 'user',
transport: 'http',
url: 'http://localhost:3333/mcp',
headers: { API_KEY: 'value' },
});
const geminiUserConfig = await readJson(path.join(tempRoot, '.gemini', 'settings.json'));
const geminiUserServer = (geminiUserConfig.mcpServers as Record<string, unknown>)['gemini-stdio'] as Record<string, unknown>;
assert.equal(geminiUserServer.command, 'node');
assert.equal(geminiUserServer.type, undefined);
const geminiProjectConfig = await readJson(path.join(workspacePath, '.gemini', 'settings.json'));
const geminiProjectServer = (geminiProjectConfig.mcpServers as Record<string, unknown>)['gemini-http'] as Record<string, unknown>;
assert.equal(geminiProjectServer.type, 'http');
const cursorUserConfig = await readJson(path.join(tempRoot, '.cursor', 'mcp.json'));
const cursorHttpServer = (cursorUserConfig.mcpServers as Record<string, unknown>)['cursor-http'] as Record<string, unknown>;
assert.equal(cursorHttpServer.url, 'http://localhost:3333/mcp');
assert.equal(cursorHttpServer.type, undefined);
} finally {
restoreHomeDir();
await fs.rm(tempRoot, { recursive: true, force: true });
}
});
/**
* This test covers the global MCP adder requirement: only http/stdio are allowed and
* one payload is written to all providers.
*/
test('providerMcpService global adder writes to all providers and rejects unsupported transports', { concurrency: false }, async () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'llm-mcp-global-'));
const workspacePath = path.join(tempRoot, 'workspace');
await fs.mkdir(workspacePath, { recursive: true });
const restoreHomeDir = patchHomeDir(tempRoot);
try {
const globalResult = await providerMcpService.addMcpServerToAllProviders({
name: 'global-http',
scope: 'project',
transport: 'http',
url: 'https://global.example.com/mcp',
workspacePath,
});
const expectCursorGlobal = process.platform !== 'win32';
assert.equal(globalResult.length, expectCursorGlobal ? 4 : 3);
assert.ok(globalResult.every((entry) => entry.created === true));
const claudeProject = await readJson(path.join(workspacePath, '.mcp.json'));
assert.ok((claudeProject.mcpServers as Record<string, unknown>)['global-http']);
const codexProject = TOML.parse(await fs.readFile(path.join(workspacePath, '.codex', 'config.toml'), 'utf8')) as Record<string, unknown>;
assert.ok((codexProject.mcp_servers as Record<string, unknown>)['global-http']);
const geminiProject = await readJson(path.join(workspacePath, '.gemini', 'settings.json'));
assert.ok((geminiProject.mcpServers as Record<string, unknown>)['global-http']);
if (expectCursorGlobal) {
const cursorProject = await readJson(path.join(workspacePath, '.cursor', 'mcp.json'));
assert.ok((cursorProject.mcpServers as Record<string, unknown>)['global-http']);
}
await assert.rejects(
providerMcpService.addMcpServerToAllProviders({
name: 'global-sse',
scope: 'project',
transport: 'sse',
url: 'https://example.com/sse',
workspacePath,
}),
(error: unknown) =>
error instanceof AppError &&
error.code === 'INVALID_GLOBAL_MCP_TRANSPORT' &&
error.statusCode === 400,
);
} finally {
restoreHomeDir();
await fs.rm(tempRoot, { recursive: true, force: true });
}
});

View File

@@ -15,9 +15,8 @@
import { Codex } from '@openai/codex-sdk';
import { notifyRunFailed, notifyRunStopped } from './services/notification-orchestrator.js';
import { codexAdapter } from './providers/codex/adapter.js';
import { createNormalizedMessage } from './providers/types.js';
import { getStatusChecker } from './providers/registry.js';
import { sessionsService } from './modules/providers/services/sessions.service.js';
import { createNormalizedMessage } from './shared/utils.js';
// Track active sessions
const activeCodexSessions = new Map();
@@ -265,7 +264,7 @@ export async function queryCodex(command, options = {}, ws) {
const transformed = transformCodexEvent(event);
// Normalize the transformed event into NormalizedMessage(s) via adapter
const normalizedMsgs = codexAdapter.normalizeMessage(transformed, currentSessionId);
const normalizedMsgs = sessionsService.normalizeMessage('codex', transformed, currentSessionId);
for (const msg of normalizedMsgs) {
sendMessage(ws, msg);
}
@@ -309,14 +308,7 @@ export async function queryCodex(command, options = {}, ws) {
if (!wasAborted) {
console.error('[Codex] Error:', error);
// Check if Codex SDK is available for a clearer error message
const installed = getStatusChecker('codex')?.checkInstalled() ?? true;
const errorContent = !installed
? 'Codex CLI is not configured. Please set up authentication first.'
: error.message;
sendMessage(ws, createNormalizedMessage({ kind: 'error', content: errorContent, sessionId: currentSessionId, provider: 'codex' }));
sendMessage(ws, createNormalizedMessage({ kind: 'error', content: error.message, sessionId: currentSessionId, provider: 'codex' }));
if (!terminalFailure) {
notifyRunFailed({
userId: ws?.userId || null,

View File

@@ -62,7 +62,8 @@ import fsSync from 'fs';
import path from 'path';
import readline from 'readline';
import crypto from 'crypto';
import Database from 'better-sqlite3';
import sqlite3 from 'sqlite3';
import { open } from 'sqlite';
import os from 'os';
import sessionManager from './sessionManager.js';
import { applyCustomSessionNames } from './database/db.js';
@@ -1163,9 +1164,8 @@ async function isProjectEmpty(projectName) {
}
}
// Remove a project from the UI.
// When deleteData=true, also delete session/memory files on disk (destructive).
async function deleteProject(projectName, force = false, deleteData = false) {
// Delete a project (force=true to delete even with sessions)
async function deleteProject(projectName, force = false) {
const projectDir = path.join(os.homedir(), '.claude', 'projects', projectName);
try {
@@ -1175,50 +1175,48 @@ async function deleteProject(projectName, force = false, deleteData = false) {
}
const config = await loadProjectConfig();
let projectPath = config[projectName]?.path || config[projectName]?.originalPath;
// Destructive path: delete underlying data when explicitly requested
if (deleteData) {
let projectPath = config[projectName]?.path || config[projectName]?.originalPath;
if (!projectPath) {
projectPath = await extractProjectDirectory(projectName);
// Fallback to extractProjectDirectory if projectPath is not in config
if (!projectPath) {
projectPath = await extractProjectDirectory(projectName);
}
// Remove the project directory (includes all Claude sessions)
await fs.rm(projectDir, { recursive: true, force: true });
// Delete all Codex sessions associated with this project
if (projectPath) {
try {
const codexSessions = await getCodexSessions(projectPath, { limit: 0 });
for (const session of codexSessions) {
try {
await deleteCodexSession(session.id);
} catch (err) {
console.warn(`Failed to delete Codex session ${session.id}:`, err.message);
}
}
} catch (err) {
console.warn('Failed to delete Codex sessions:', err.message);
}
// Remove the Claude project directory (session logs, memory, subagent data)
await fs.rm(projectDir, { recursive: true, force: true });
// Delete Codex sessions associated with this project
if (projectPath) {
try {
const codexSessions = await getCodexSessions(projectPath, { limit: 0 });
for (const session of codexSessions) {
try {
await deleteCodexSession(session.id);
} catch (err) {
console.warn(`Failed to delete Codex session ${session.id}:`, err.message);
}
}
} catch (err) {
console.warn('Failed to delete Codex sessions:', err.message);
}
// Delete Cursor sessions directory if it exists
try {
const hash = crypto.createHash('md5').update(projectPath).digest('hex');
const cursorProjectDir = path.join(os.homedir(), '.cursor', 'chats', hash);
await fs.rm(cursorProjectDir, { recursive: true, force: true });
} catch (err) {
// Cursor dir may not exist, ignore
}
// Delete Cursor sessions directory if it exists
try {
const hash = crypto.createHash('md5').update(projectPath).digest('hex');
const cursorProjectDir = path.join(os.homedir(), '.cursor', 'chats', hash);
await fs.rm(cursorProjectDir, { recursive: true, force: true });
} catch (err) {
// Cursor dir may not exist, ignore
}
}
// Always remove from project config
// Remove from project config
delete config[projectName];
await saveProjectConfig(config);
return true;
} catch (error) {
console.error(`Error removing project ${projectName}:`, error);
console.error(`Error deleting project ${projectName}:`, error);
throw error;
}
}
@@ -1307,10 +1305,16 @@ async function getCursorSessions(projectPath) {
} catch (_) { }
// Open SQLite database
const db = new Database(storeDbPath, { readonly: true, fileMustExist: true });
const db = await open({
filename: storeDbPath,
driver: sqlite3.Database,
mode: sqlite3.OPEN_READONLY
});
// Get metadata from meta table
const metaRows = db.prepare('SELECT key, value FROM meta').all();
const metaRows = await db.all(`
SELECT key, value FROM meta
`);
// Parse metadata
let metadata = {};
@@ -1332,9 +1336,11 @@ async function getCursorSessions(projectPath) {
}
// Get message count
const messageCountResult = db.prepare('SELECT COUNT(*) as count FROM blobs').get();
const messageCountResult = await db.get(`
SELECT COUNT(*) as count FROM blobs
`);
db.close();
await db.close();
// Extract session info
const sessionName = metadata.title || metadata.sessionTitle || 'Untitled Session';

View File

@@ -1,278 +0,0 @@
/**
* Claude provider adapter.
*
* Normalizes Claude SDK session history into NormalizedMessage format.
* @module adapters/claude
*/
import { getSessionMessages } from '../../projects.js';
import { createNormalizedMessage, generateMessageId } from '../types.js';
import { isInternalContent } from '../utils.js';
const PROVIDER = 'claude';
/**
* Normalize a raw JSONL message or realtime SDK event into NormalizedMessage(s).
* Handles both history entries (JSONL `{ message: { role, content } }`) and
* realtime streaming events (`content_block_delta`, `content_block_stop`, etc.).
* @param {object} raw - A single entry from JSONL or a live SDK event
* @param {string} sessionId
* @returns {import('../types.js').NormalizedMessage[]}
*/
export function normalizeMessage(raw, sessionId) {
// ── Streaming events (realtime) ──────────────────────────────────────────
if (raw.type === 'content_block_delta' && raw.delta?.text) {
return [createNormalizedMessage({ kind: 'stream_delta', content: raw.delta.text, sessionId, provider: PROVIDER })];
}
if (raw.type === 'content_block_stop') {
return [createNormalizedMessage({ kind: 'stream_end', sessionId, provider: PROVIDER })];
}
// ── History / full-message events ────────────────────────────────────────
const messages = [];
const ts = raw.timestamp || new Date().toISOString();
const baseId = raw.uuid || generateMessageId('claude');
// User message
if (raw.message?.role === 'user' && raw.message?.content) {
if (Array.isArray(raw.message.content)) {
// Handle tool_result parts
for (const part of raw.message.content) {
if (part.type === 'tool_result') {
messages.push(createNormalizedMessage({
id: `${baseId}_tr_${part.tool_use_id}`,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'tool_result',
toolId: part.tool_use_id,
content: typeof part.content === 'string' ? part.content : JSON.stringify(part.content),
isError: Boolean(part.is_error),
subagentTools: raw.subagentTools,
toolUseResult: raw.toolUseResult,
}));
} else if (part.type === 'text') {
// Regular text parts from user
const text = part.text || '';
if (text && !isInternalContent(text)) {
messages.push(createNormalizedMessage({
id: `${baseId}_text`,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'text',
role: 'user',
content: text,
}));
}
}
}
// If no text parts were found, check if it's a pure user message
if (messages.length === 0) {
const textParts = raw.message.content
.filter(p => p.type === 'text')
.map(p => p.text)
.filter(Boolean)
.join('\n');
if (textParts && !isInternalContent(textParts)) {
messages.push(createNormalizedMessage({
id: `${baseId}_text`,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'text',
role: 'user',
content: textParts,
}));
}
}
} else if (typeof raw.message.content === 'string') {
const text = raw.message.content;
if (text && !isInternalContent(text)) {
messages.push(createNormalizedMessage({
id: baseId,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'text',
role: 'user',
content: text,
}));
}
}
return messages;
}
// Thinking message
if (raw.type === 'thinking' && raw.message?.content) {
messages.push(createNormalizedMessage({
id: baseId,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'thinking',
content: raw.message.content,
}));
return messages;
}
// Tool use result (codex-style in Claude)
if (raw.type === 'tool_use' && raw.toolName) {
messages.push(createNormalizedMessage({
id: baseId,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'tool_use',
toolName: raw.toolName,
toolInput: raw.toolInput,
toolId: raw.toolCallId || baseId,
}));
return messages;
}
if (raw.type === 'tool_result') {
messages.push(createNormalizedMessage({
id: baseId,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'tool_result',
toolId: raw.toolCallId || '',
content: raw.output || '',
isError: false,
}));
return messages;
}
// Assistant message
if (raw.message?.role === 'assistant' && raw.message?.content) {
if (Array.isArray(raw.message.content)) {
let partIndex = 0;
for (const part of raw.message.content) {
if (part.type === 'text' && part.text) {
messages.push(createNormalizedMessage({
id: `${baseId}_${partIndex}`,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'text',
role: 'assistant',
content: part.text,
}));
} else if (part.type === 'tool_use') {
messages.push(createNormalizedMessage({
id: `${baseId}_${partIndex}`,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'tool_use',
toolName: part.name,
toolInput: part.input,
toolId: part.id,
}));
} else if (part.type === 'thinking' && part.thinking) {
messages.push(createNormalizedMessage({
id: `${baseId}_${partIndex}`,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'thinking',
content: part.thinking,
}));
}
partIndex++;
}
} else if (typeof raw.message.content === 'string') {
messages.push(createNormalizedMessage({
id: baseId,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'text',
role: 'assistant',
content: raw.message.content,
}));
}
return messages;
}
return messages;
}
/**
* @type {import('../types.js').ProviderAdapter}
*/
export const claudeAdapter = {
normalizeMessage,
/**
* Fetch session history from JSONL files, returning normalized messages.
*/
async fetchHistory(sessionId, opts = {}) {
const { projectName, limit = null, offset = 0 } = opts;
if (!projectName) {
return { messages: [], total: 0, hasMore: false, offset: 0, limit: null };
}
let result;
try {
result = await getSessionMessages(projectName, sessionId, limit, offset);
} catch (error) {
console.warn(`[ClaudeAdapter] Failed to load session ${sessionId}:`, error.message);
return { messages: [], total: 0, hasMore: false, offset: 0, limit: null };
}
// getSessionMessages returns either an array (no limit) or { messages, total, hasMore }
const rawMessages = Array.isArray(result) ? result : (result.messages || []);
const total = Array.isArray(result) ? rawMessages.length : (result.total || 0);
const hasMore = Array.isArray(result) ? false : Boolean(result.hasMore);
// First pass: collect tool results for attachment to tool_use messages
const toolResultMap = new Map();
for (const raw of rawMessages) {
if (raw.message?.role === 'user' && Array.isArray(raw.message?.content)) {
for (const part of raw.message.content) {
if (part.type === 'tool_result') {
toolResultMap.set(part.tool_use_id, {
content: part.content,
isError: Boolean(part.is_error),
timestamp: raw.timestamp,
subagentTools: raw.subagentTools,
toolUseResult: raw.toolUseResult,
});
}
}
}
}
// Second pass: normalize all messages
const normalized = [];
for (const raw of rawMessages) {
const entries = normalizeMessage(raw, sessionId);
normalized.push(...entries);
}
// Attach tool results to their corresponding tool_use messages
for (const msg of normalized) {
if (msg.kind === 'tool_use' && msg.toolId && toolResultMap.has(msg.toolId)) {
const tr = toolResultMap.get(msg.toolId);
msg.toolResult = {
content: typeof tr.content === 'string' ? tr.content : JSON.stringify(tr.content),
isError: tr.isError,
toolUseResult: tr.toolUseResult,
};
msg.subagentTools = tr.subagentTools;
}
}
return {
messages: normalized,
total,
hasMore,
offset,
limit,
};
},
};

View File

@@ -1,136 +0,0 @@
/**
* Claude Provider Status
*
* Checks whether Claude Code CLI is installed and whether the user
* has valid authentication credentials.
*
* @module providers/claude/status
*/
import { execFileSync } from 'child_process';
import { promises as fs } from 'fs';
import path from 'path';
import os from 'os';
/**
* Check if Claude Code CLI is installed and available.
* Uses CLAUDE_CLI_PATH env var if set, otherwise looks for 'claude' in PATH.
* @returns {boolean}
*/
export function checkInstalled() {
const cliPath = process.env.CLAUDE_CLI_PATH || 'claude';
try {
execFileSync(cliPath, ['--version'], { stdio: 'ignore', timeout: 5000 });
return true;
} catch {
return false;
}
}
/**
* Full status check: installation + authentication.
* @returns {Promise<import('../types.js').ProviderStatus>}
*/
export async function checkStatus() {
const installed = checkInstalled();
if (!installed) {
return {
installed,
authenticated: false,
email: null,
method: null,
error: 'Claude Code CLI is not installed'
};
}
const credentialsResult = await checkCredentials();
if (credentialsResult.authenticated) {
return {
installed,
authenticated: true,
email: credentialsResult.email || 'Authenticated',
method: credentialsResult.method || null,
error: null
};
}
return {
installed,
authenticated: false,
email: credentialsResult.email || null,
method: credentialsResult.method || null,
error: credentialsResult.error || 'Not authenticated'
};
}
// ─── Internal helpers ───────────────────────────────────────────────────────
async function loadSettingsEnv() {
try {
const settingsPath = path.join(os.homedir(), '.claude', 'settings.json');
const content = await fs.readFile(settingsPath, 'utf8');
const settings = JSON.parse(content);
if (settings?.env && typeof settings.env === 'object') {
return settings.env;
}
} catch {
// Ignore missing or malformed settings.
}
return {};
}
/**
* Checks Claude authentication credentials.
*
* Priority 1: ANTHROPIC_API_KEY environment variable
* Priority 1b: ~/.claude/settings.json env values
* Priority 2: ~/.claude/.credentials.json OAuth tokens
*/
async function checkCredentials() {
if (process.env.ANTHROPIC_API_KEY && process.env.ANTHROPIC_API_KEY.trim()) {
return { authenticated: true, email: 'API Key Auth', method: 'api_key' };
}
const settingsEnv = await loadSettingsEnv();
if (typeof settingsEnv.ANTHROPIC_API_KEY === 'string' && settingsEnv.ANTHROPIC_API_KEY.trim()) {
return { authenticated: true, email: 'API Key Auth', method: 'api_key' };
}
if (typeof settingsEnv.ANTHROPIC_AUTH_TOKEN === 'string' && settingsEnv.ANTHROPIC_AUTH_TOKEN.trim()) {
return { authenticated: true, email: 'Configured via settings.json', method: 'api_key' };
}
try {
const credPath = path.join(os.homedir(), '.claude', '.credentials.json');
const content = await fs.readFile(credPath, 'utf8');
const creds = JSON.parse(content);
const oauth = creds.claudeAiOauth;
if (oauth && oauth.accessToken) {
const isExpired = oauth.expiresAt && Date.now() >= oauth.expiresAt;
if (!isExpired) {
return {
authenticated: true,
email: creds.email || creds.user || null,
method: 'credentials_file'
};
}
return {
authenticated: false,
email: creds.email || creds.user || null,
method: 'credentials_file',
error: 'OAuth token has expired. Please re-authenticate with claude login'
};
}
return { authenticated: false, email: null, method: null };
} catch {
return { authenticated: false, email: null, method: null };
}
}

View File

@@ -1,248 +0,0 @@
/**
* Codex (OpenAI) provider adapter.
*
* Normalizes Codex SDK session history into NormalizedMessage format.
* @module adapters/codex
*/
import { getCodexSessionMessages } from '../../projects.js';
import { createNormalizedMessage, generateMessageId } from '../types.js';
const PROVIDER = 'codex';
/**
* Normalize a raw Codex JSONL message into NormalizedMessage(s).
* @param {object} raw - A single parsed message from Codex JSONL
* @param {string} sessionId
* @returns {import('../types.js').NormalizedMessage[]}
*/
function normalizeCodexHistoryEntry(raw, sessionId) {
const ts = raw.timestamp || new Date().toISOString();
const baseId = raw.uuid || generateMessageId('codex');
// User message
if (raw.message?.role === 'user') {
const content = typeof raw.message.content === 'string'
? raw.message.content
: Array.isArray(raw.message.content)
? raw.message.content.map(p => typeof p === 'string' ? p : p?.text || '').filter(Boolean).join('\n')
: String(raw.message.content || '');
if (!content.trim()) return [];
return [createNormalizedMessage({
id: baseId,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'text',
role: 'user',
content,
})];
}
// Assistant message
if (raw.message?.role === 'assistant') {
const content = typeof raw.message.content === 'string'
? raw.message.content
: Array.isArray(raw.message.content)
? raw.message.content.map(p => typeof p === 'string' ? p : p?.text || '').filter(Boolean).join('\n')
: '';
if (!content.trim()) return [];
return [createNormalizedMessage({
id: baseId,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'text',
role: 'assistant',
content,
})];
}
// Thinking/reasoning
if (raw.type === 'thinking' || raw.isReasoning) {
return [createNormalizedMessage({
id: baseId,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'thinking',
content: raw.message?.content || '',
})];
}
// Tool use
if (raw.type === 'tool_use' || raw.toolName) {
return [createNormalizedMessage({
id: baseId,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'tool_use',
toolName: raw.toolName || 'Unknown',
toolInput: raw.toolInput,
toolId: raw.toolCallId || baseId,
})];
}
// Tool result
if (raw.type === 'tool_result') {
return [createNormalizedMessage({
id: baseId,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'tool_result',
toolId: raw.toolCallId || '',
content: raw.output || '',
isError: Boolean(raw.isError),
})];
}
return [];
}
/**
* Normalize a raw Codex event (history JSONL or transformed SDK event) into NormalizedMessage(s).
* @param {object} raw - A history entry (has raw.message.role) or transformed SDK event (has raw.type)
* @param {string} sessionId
* @returns {import('../types.js').NormalizedMessage[]}
*/
export function normalizeMessage(raw, sessionId) {
// History format: has message.role
if (raw.message?.role) {
return normalizeCodexHistoryEntry(raw, sessionId);
}
const ts = raw.timestamp || new Date().toISOString();
const baseId = raw.uuid || generateMessageId('codex');
// SDK event format (output of transformCodexEvent)
if (raw.type === 'item') {
switch (raw.itemType) {
case 'agent_message':
return [createNormalizedMessage({
id: baseId, sessionId, timestamp: ts, provider: PROVIDER,
kind: 'text', role: 'assistant', content: raw.message?.content || '',
})];
case 'reasoning':
return [createNormalizedMessage({
id: baseId, sessionId, timestamp: ts, provider: PROVIDER,
kind: 'thinking', content: raw.message?.content || '',
})];
case 'command_execution':
return [createNormalizedMessage({
id: baseId, sessionId, timestamp: ts, provider: PROVIDER,
kind: 'tool_use', toolName: 'Bash', toolInput: { command: raw.command },
toolId: baseId,
output: raw.output, exitCode: raw.exitCode, status: raw.status,
})];
case 'file_change':
return [createNormalizedMessage({
id: baseId, sessionId, timestamp: ts, provider: PROVIDER,
kind: 'tool_use', toolName: 'FileChanges', toolInput: raw.changes,
toolId: baseId, status: raw.status,
})];
case 'mcp_tool_call':
return [createNormalizedMessage({
id: baseId, sessionId, timestamp: ts, provider: PROVIDER,
kind: 'tool_use', toolName: raw.tool || 'MCP', toolInput: raw.arguments,
toolId: baseId, server: raw.server, result: raw.result,
error: raw.error, status: raw.status,
})];
case 'web_search':
return [createNormalizedMessage({
id: baseId, sessionId, timestamp: ts, provider: PROVIDER,
kind: 'tool_use', toolName: 'WebSearch', toolInput: { query: raw.query },
toolId: baseId,
})];
case 'todo_list':
return [createNormalizedMessage({
id: baseId, sessionId, timestamp: ts, provider: PROVIDER,
kind: 'tool_use', toolName: 'TodoList', toolInput: { items: raw.items },
toolId: baseId,
})];
case 'error':
return [createNormalizedMessage({
id: baseId, sessionId, timestamp: ts, provider: PROVIDER,
kind: 'error', content: raw.message?.content || 'Unknown error',
})];
default:
// Unknown item type — pass through as generic tool_use
return [createNormalizedMessage({
id: baseId, sessionId, timestamp: ts, provider: PROVIDER,
kind: 'tool_use', toolName: raw.itemType || 'Unknown',
toolInput: raw.item || raw, toolId: baseId,
})];
}
}
if (raw.type === 'turn_complete') {
return [createNormalizedMessage({
id: baseId, sessionId, timestamp: ts, provider: PROVIDER,
kind: 'complete',
})];
}
if (raw.type === 'turn_failed') {
return [createNormalizedMessage({
id: baseId, sessionId, timestamp: ts, provider: PROVIDER,
kind: 'error', content: raw.error?.message || 'Turn failed',
})];
}
return [];
}
/**
* @type {import('../types.js').ProviderAdapter}
*/
export const codexAdapter = {
normalizeMessage,
/**
* Fetch session history from Codex JSONL files.
*/
async fetchHistory(sessionId, opts = {}) {
const { limit = null, offset = 0 } = opts;
let result;
try {
result = await getCodexSessionMessages(sessionId, limit, offset);
} catch (error) {
console.warn(`[CodexAdapter] Failed to load session ${sessionId}:`, error.message);
return { messages: [], total: 0, hasMore: false, offset: 0, limit: null };
}
const rawMessages = Array.isArray(result) ? result : (result.messages || []);
const total = Array.isArray(result) ? rawMessages.length : (result.total || 0);
const hasMore = Array.isArray(result) ? false : Boolean(result.hasMore);
const tokenUsage = result.tokenUsage || null;
const normalized = [];
for (const raw of rawMessages) {
const entries = normalizeCodexHistoryEntry(raw, sessionId);
normalized.push(...entries);
}
// Attach tool results to tool_use messages
const toolResultMap = new Map();
for (const msg of normalized) {
if (msg.kind === 'tool_result' && msg.toolId) {
toolResultMap.set(msg.toolId, msg);
}
}
for (const msg of normalized) {
if (msg.kind === 'tool_use' && msg.toolId && toolResultMap.has(msg.toolId)) {
const tr = toolResultMap.get(msg.toolId);
msg.toolResult = { content: tr.content, isError: tr.isError };
}
}
return {
messages: normalized,
total,
hasMore,
offset,
limit,
tokenUsage,
};
},
};

View File

@@ -1,78 +0,0 @@
/**
* Codex Provider Status
*
* Checks whether the user has valid Codex authentication credentials.
* Codex uses an SDK that makes direct API calls (no external binary),
* so installation check always returns true if the server is running.
*
* @module providers/codex/status
*/
import { promises as fs } from 'fs';
import path from 'path';
import os from 'os';
/**
* Check if Codex is installed.
* Codex SDK is bundled with this application — no external binary needed.
* @returns {boolean}
*/
export function checkInstalled() {
return true;
}
/**
* Full status check: installation + authentication.
* @returns {Promise<import('../types.js').ProviderStatus>}
*/
export async function checkStatus() {
const installed = checkInstalled();
const result = await checkCredentials();
return {
installed,
authenticated: result.authenticated,
email: result.email || null,
error: result.error || null
};
}
// ─── Internal helpers ───────────────────────────────────────────────────────
async function checkCredentials() {
try {
const authPath = path.join(os.homedir(), '.codex', 'auth.json');
const content = await fs.readFile(authPath, 'utf8');
const auth = JSON.parse(content);
const tokens = auth.tokens || {};
if (tokens.id_token || tokens.access_token) {
let email = 'Authenticated';
if (tokens.id_token) {
try {
const parts = tokens.id_token.split('.');
if (parts.length >= 2) {
const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8'));
email = payload.email || payload.user || 'Authenticated';
}
} catch {
email = 'Authenticated';
}
}
return { authenticated: true, email };
}
if (auth.OPENAI_API_KEY) {
return { authenticated: true, email: 'API Key Auth' };
}
return { authenticated: false, email: null, error: 'No valid tokens found' };
} catch (error) {
if (error.code === 'ENOENT') {
return { authenticated: false, email: null, error: 'Codex not configured' };
}
return { authenticated: false, email: null, error: error.message };
}
}

View File

@@ -1,348 +0,0 @@
/**
* Cursor provider adapter.
*
* Normalizes Cursor CLI session history into NormalizedMessage format.
* @module adapters/cursor
*/
import path from 'path';
import os from 'os';
import crypto from 'crypto';
import { createNormalizedMessage, generateMessageId } from '../types.js';
const PROVIDER = 'cursor';
/**
* Load raw blobs from Cursor's SQLite store.db, parse the DAG structure,
* and return sorted message blobs in chronological order.
* @param {string} sessionId
* @param {string} projectPath - Absolute project path (used to compute cwdId hash)
* @returns {Promise<Array<{id: string, sequence: number, rowid: number, content: object}>>}
*/
async function loadCursorBlobs(sessionId, projectPath) {
// Lazy-import better-sqlite3 so the module doesn't fail if it's unavailable
const { default: Database } = await import('better-sqlite3');
const cwdId = crypto.createHash('md5').update(projectPath || process.cwd()).digest('hex');
const storeDbPath = path.join(os.homedir(), '.cursor', 'chats', cwdId, sessionId, 'store.db');
const db = new Database(storeDbPath, { readonly: true, fileMustExist: true });
try {
const allBlobs = db.prepare('SELECT rowid, id, data FROM blobs').all();
const blobMap = new Map();
const parentRefs = new Map();
const childRefs = new Map();
const jsonBlobs = [];
for (const blob of allBlobs) {
blobMap.set(blob.id, blob);
if (blob.data && blob.data[0] === 0x7B) {
try {
const parsed = JSON.parse(blob.data.toString('utf8'));
jsonBlobs.push({ ...blob, parsed });
} catch {
// skip unparseable blobs
}
} else if (blob.data) {
const parents = [];
let i = 0;
while (i < blob.data.length - 33) {
if (blob.data[i] === 0x0A && blob.data[i + 1] === 0x20) {
const parentHash = blob.data.slice(i + 2, i + 34).toString('hex');
if (blobMap.has(parentHash)) {
parents.push(parentHash);
}
i += 34;
} else {
i++;
}
}
if (parents.length > 0) {
parentRefs.set(blob.id, parents);
for (const parentId of parents) {
if (!childRefs.has(parentId)) childRefs.set(parentId, []);
childRefs.get(parentId).push(blob.id);
}
}
}
}
// Topological sort (DFS)
const visited = new Set();
const sorted = [];
function visit(nodeId) {
if (visited.has(nodeId)) return;
visited.add(nodeId);
for (const pid of (parentRefs.get(nodeId) || [])) visit(pid);
const b = blobMap.get(nodeId);
if (b) sorted.push(b);
}
for (const blob of allBlobs) {
if (!parentRefs.has(blob.id)) visit(blob.id);
}
for (const blob of allBlobs) visit(blob.id);
// Order JSON blobs by DAG appearance
const messageOrder = new Map();
let orderIndex = 0;
for (const blob of sorted) {
if (blob.data && blob.data[0] !== 0x7B) {
for (const jb of jsonBlobs) {
try {
const idBytes = Buffer.from(jb.id, 'hex');
if (blob.data.includes(idBytes) && !messageOrder.has(jb.id)) {
messageOrder.set(jb.id, orderIndex++);
}
} catch { /* skip */ }
}
}
}
const sortedJsonBlobs = jsonBlobs.sort((a, b) => {
const oa = messageOrder.get(a.id) ?? Number.MAX_SAFE_INTEGER;
const ob = messageOrder.get(b.id) ?? Number.MAX_SAFE_INTEGER;
return oa !== ob ? oa - ob : a.rowid - b.rowid;
});
const messages = [];
for (let idx = 0; idx < sortedJsonBlobs.length; idx++) {
const blob = sortedJsonBlobs[idx];
const parsed = blob.parsed;
if (!parsed) continue;
const role = parsed?.role || parsed?.message?.role;
if (role === 'system') continue;
messages.push({
id: blob.id,
sequence: idx + 1,
rowid: blob.rowid,
content: parsed,
});
}
return messages;
} finally {
db.close();
}
}
/**
* Normalize a realtime NDJSON event from Cursor CLI into NormalizedMessage(s).
* History uses normalizeCursorBlobs (SQLite DAG), this handles streaming NDJSON.
* @param {object|string} raw - A parsed NDJSON event or a raw text line
* @param {string} sessionId
* @returns {import('../types.js').NormalizedMessage[]}
*/
export function normalizeMessage(raw, sessionId) {
// Structured assistant message with content array
if (raw && typeof raw === 'object' && raw.type === 'assistant' && raw.message?.content?.[0]?.text) {
return [createNormalizedMessage({ kind: 'stream_delta', content: raw.message.content[0].text, sessionId, provider: PROVIDER })];
}
// Plain string line (non-JSON output)
if (typeof raw === 'string' && raw.trim()) {
return [createNormalizedMessage({ kind: 'stream_delta', content: raw, sessionId, provider: PROVIDER })];
}
return [];
}
/**
* @type {import('../types.js').ProviderAdapter}
*/
export const cursorAdapter = {
normalizeMessage,
/**
* Fetch session history for Cursor from SQLite store.db.
*/
async fetchHistory(sessionId, opts = {}) {
const { projectPath = '', limit = null, offset = 0 } = opts;
try {
const blobs = await loadCursorBlobs(sessionId, projectPath);
const allNormalized = cursorAdapter.normalizeCursorBlobs(blobs, sessionId);
// Apply pagination
if (limit !== null && limit > 0) {
const start = offset;
const page = allNormalized.slice(start, start + limit);
return {
messages: page,
total: allNormalized.length,
hasMore: start + limit < allNormalized.length,
offset,
limit,
};
}
return {
messages: allNormalized,
total: allNormalized.length,
hasMore: false,
offset: 0,
limit: null,
};
} catch (error) {
// DB doesn't exist or is unreadable — return empty
console.warn(`[CursorAdapter] Failed to load session ${sessionId}:`, error.message);
return { messages: [], total: 0, hasMore: false, offset: 0, limit: null };
}
},
/**
* Normalize raw Cursor blob messages into NormalizedMessage[].
* @param {any[]} blobs - Raw cursor blobs from store.db ({id, sequence, rowid, content})
* @param {string} sessionId
* @returns {import('../types.js').NormalizedMessage[]}
*/
normalizeCursorBlobs(blobs, sessionId) {
const messages = [];
const toolUseMap = new Map();
// Use a fixed base timestamp so messages have stable, monotonically-increasing
// timestamps based on their sequence number rather than wall-clock time.
const baseTime = Date.now();
for (let i = 0; i < blobs.length; i++) {
const blob = blobs[i];
const content = blob.content;
const ts = new Date(baseTime + (blob.sequence ?? i) * 100).toISOString();
const baseId = blob.id || generateMessageId('cursor');
try {
if (!content?.role || !content?.content) {
// Try nested message format
if (content?.message?.role && content?.message?.content) {
if (content.message.role === 'system') continue;
const role = content.message.role === 'user' ? 'user' : 'assistant';
let text = '';
if (Array.isArray(content.message.content)) {
text = content.message.content
.map(p => typeof p === 'string' ? p : p?.text || '')
.filter(Boolean)
.join('\n');
} else if (typeof content.message.content === 'string') {
text = content.message.content;
}
if (text?.trim()) {
messages.push(createNormalizedMessage({
id: baseId,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'text',
role,
content: text,
sequence: blob.sequence,
rowid: blob.rowid,
}));
}
}
continue;
}
if (content.role === 'system') continue;
// Tool results
if (content.role === 'tool') {
const toolItems = Array.isArray(content.content) ? content.content : [];
for (const item of toolItems) {
if (item?.type !== 'tool-result') continue;
const toolCallId = item.toolCallId || content.id;
messages.push(createNormalizedMessage({
id: `${baseId}_tr`,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'tool_result',
toolId: toolCallId,
content: item.result || '',
isError: false,
}));
}
continue;
}
const role = content.role === 'user' ? 'user' : 'assistant';
if (Array.isArray(content.content)) {
for (let partIdx = 0; partIdx < content.content.length; partIdx++) {
const part = content.content[partIdx];
if (part?.type === 'text' && part?.text) {
messages.push(createNormalizedMessage({
id: `${baseId}_${partIdx}`,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'text',
role,
content: part.text,
sequence: blob.sequence,
rowid: blob.rowid,
}));
} else if (part?.type === 'reasoning' && part?.text) {
messages.push(createNormalizedMessage({
id: `${baseId}_${partIdx}`,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'thinking',
content: part.text,
}));
} else if (part?.type === 'tool-call' || part?.type === 'tool_use') {
const toolName = (part.toolName || part.name || 'Unknown Tool') === 'ApplyPatch'
? 'Edit' : (part.toolName || part.name || 'Unknown Tool');
const toolId = part.toolCallId || part.id || `tool_${i}_${partIdx}`;
messages.push(createNormalizedMessage({
id: `${baseId}_${partIdx}`,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'tool_use',
toolName,
toolInput: part.args || part.input,
toolId,
}));
toolUseMap.set(toolId, messages[messages.length - 1]);
}
}
} else if (typeof content.content === 'string' && content.content.trim()) {
messages.push(createNormalizedMessage({
id: baseId,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'text',
role,
content: content.content,
sequence: blob.sequence,
rowid: blob.rowid,
}));
}
} catch (error) {
console.warn('Error normalizing cursor blob:', error);
}
}
// Attach tool results to tool_use messages
for (const msg of messages) {
if (msg.kind === 'tool_result' && msg.toolId && toolUseMap.has(msg.toolId)) {
const toolUse = toolUseMap.get(msg.toolId);
toolUse.toolResult = {
content: msg.content,
isError: msg.isError,
};
}
}
// Sort by sequence/rowid
messages.sort((a, b) => {
if (a.sequence !== undefined && b.sequence !== undefined) return a.sequence - b.sequence;
if (a.rowid !== undefined && b.rowid !== undefined) return a.rowid - b.rowid;
return new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime();
});
return messages;
},
};

View File

@@ -1,128 +0,0 @@
/**
* Cursor Provider Status
*
* Checks whether cursor-agent CLI is installed and whether the user
* is logged in.
*
* @module providers/cursor/status
*/
import { execFileSync, spawn } from 'child_process';
/**
* Check if cursor-agent CLI is installed.
* @returns {boolean}
*/
export function checkInstalled() {
try {
execFileSync('cursor-agent', ['--version'], { stdio: 'ignore', timeout: 5000 });
return true;
} catch {
return false;
}
}
/**
* Full status check: installation + authentication.
* @returns {Promise<import('../types.js').ProviderStatus>}
*/
export async function checkStatus() {
const installed = checkInstalled();
if (!installed) {
return {
installed,
authenticated: false,
email: null,
error: 'Cursor CLI is not installed'
};
}
const result = await checkCursorLogin();
return {
installed,
authenticated: result.authenticated,
email: result.email || null,
error: result.error || null
};
}
// ─── Internal helpers ───────────────────────────────────────────────────────
function checkCursorLogin() {
return new Promise((resolve) => {
let processCompleted = false;
const timeout = setTimeout(() => {
if (!processCompleted) {
processCompleted = true;
if (childProcess) {
childProcess.kill();
}
resolve({
authenticated: false,
email: null,
error: 'Command timeout'
});
}
}, 5000);
let childProcess;
try {
childProcess = spawn('cursor-agent', ['status']);
} catch {
clearTimeout(timeout);
processCompleted = true;
resolve({
authenticated: false,
email: null,
error: 'Cursor CLI not found or not installed'
});
return;
}
let stdout = '';
let stderr = '';
childProcess.stdout.on('data', (data) => {
stdout += data.toString();
});
childProcess.stderr.on('data', (data) => {
stderr += data.toString();
});
childProcess.on('close', (code) => {
if (processCompleted) return;
processCompleted = true;
clearTimeout(timeout);
if (code === 0) {
const emailMatch = stdout.match(/Logged in as ([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})/i);
if (emailMatch) {
resolve({ authenticated: true, email: emailMatch[1] });
} else if (stdout.includes('Logged in')) {
resolve({ authenticated: true, email: 'Logged in' });
} else {
resolve({ authenticated: false, email: null, error: 'Not logged in' });
}
} else {
resolve({ authenticated: false, email: null, error: stderr || 'Not logged in' });
}
});
childProcess.on('error', () => {
if (processCompleted) return;
processCompleted = true;
clearTimeout(timeout);
resolve({
authenticated: false,
email: null,
error: 'Cursor CLI not found or not installed'
});
});
});
}

View File

@@ -1,186 +0,0 @@
/**
* Gemini provider adapter.
*
* Normalizes Gemini CLI session history into NormalizedMessage format.
* @module adapters/gemini
*/
import sessionManager from '../../sessionManager.js';
import { getGeminiCliSessionMessages } from '../../projects.js';
import { createNormalizedMessage, generateMessageId } from '../types.js';
const PROVIDER = 'gemini';
/**
* Normalize a realtime NDJSON event from Gemini CLI into NormalizedMessage(s).
* Handles: message (delta/final), tool_use, tool_result, result, error.
* @param {object} raw - A parsed NDJSON event
* @param {string} sessionId
* @returns {import('../types.js').NormalizedMessage[]}
*/
export function normalizeMessage(raw, sessionId) {
const ts = raw.timestamp || new Date().toISOString();
const baseId = raw.uuid || generateMessageId('gemini');
if (raw.type === 'message' && raw.role === 'assistant') {
const content = raw.content || '';
const msgs = [];
if (content) {
msgs.push(createNormalizedMessage({ id: baseId, sessionId, timestamp: ts, provider: PROVIDER, kind: 'stream_delta', content }));
}
// If not a delta, also send stream_end
if (raw.delta !== true) {
msgs.push(createNormalizedMessage({ sessionId, timestamp: ts, provider: PROVIDER, kind: 'stream_end' }));
}
return msgs;
}
if (raw.type === 'tool_use') {
return [createNormalizedMessage({
id: baseId, sessionId, timestamp: ts, provider: PROVIDER,
kind: 'tool_use', toolName: raw.tool_name, toolInput: raw.parameters || {},
toolId: raw.tool_id || baseId,
})];
}
if (raw.type === 'tool_result') {
return [createNormalizedMessage({
id: baseId, sessionId, timestamp: ts, provider: PROVIDER,
kind: 'tool_result', toolId: raw.tool_id || '',
content: raw.output === undefined ? '' : String(raw.output),
isError: raw.status === 'error',
})];
}
if (raw.type === 'result') {
const msgs = [createNormalizedMessage({ sessionId, timestamp: ts, provider: PROVIDER, kind: 'stream_end' })];
if (raw.stats?.total_tokens) {
msgs.push(createNormalizedMessage({
sessionId, timestamp: ts, provider: PROVIDER,
kind: 'status', text: 'Complete', tokens: raw.stats.total_tokens, canInterrupt: false,
}));
}
return msgs;
}
if (raw.type === 'error') {
return [createNormalizedMessage({
id: baseId, sessionId, timestamp: ts, provider: PROVIDER,
kind: 'error', content: raw.error || raw.message || 'Unknown Gemini streaming error',
})];
}
return [];
}
/**
* @type {import('../types.js').ProviderAdapter}
*/
export const geminiAdapter = {
normalizeMessage,
/**
* Fetch session history for Gemini.
* First tries in-memory session manager, then falls back to CLI sessions on disk.
*/
async fetchHistory(sessionId, opts = {}) {
let rawMessages;
try {
rawMessages = sessionManager.getSessionMessages(sessionId);
// Fallback to Gemini CLI sessions on disk
if (rawMessages.length === 0) {
rawMessages = await getGeminiCliSessionMessages(sessionId);
}
} catch (error) {
console.warn(`[GeminiAdapter] Failed to load session ${sessionId}:`, error.message);
return { messages: [], total: 0, hasMore: false, offset: 0, limit: null };
}
const normalized = [];
for (let i = 0; i < rawMessages.length; i++) {
const raw = rawMessages[i];
const ts = raw.timestamp || new Date().toISOString();
const baseId = raw.uuid || generateMessageId('gemini');
// sessionManager format: { type: 'message', message: { role, content }, timestamp }
// CLI format: { role: 'user'|'gemini'|'assistant', content: string|array }
const role = raw.message?.role || raw.role;
const content = raw.message?.content || raw.content;
if (!role || !content) continue;
const normalizedRole = (role === 'user') ? 'user' : 'assistant';
if (Array.isArray(content)) {
for (let partIdx = 0; partIdx < content.length; partIdx++) {
const part = content[partIdx];
if (part.type === 'text' && part.text) {
normalized.push(createNormalizedMessage({
id: `${baseId}_${partIdx}`,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'text',
role: normalizedRole,
content: part.text,
}));
} else if (part.type === 'tool_use') {
normalized.push(createNormalizedMessage({
id: `${baseId}_${partIdx}`,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'tool_use',
toolName: part.name,
toolInput: part.input,
toolId: part.id || generateMessageId('gemini_tool'),
}));
} else if (part.type === 'tool_result') {
normalized.push(createNormalizedMessage({
id: `${baseId}_${partIdx}`,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'tool_result',
toolId: part.tool_use_id || '',
content: part.content === undefined ? '' : String(part.content),
isError: Boolean(part.is_error),
}));
}
}
} else if (typeof content === 'string' && content.trim()) {
normalized.push(createNormalizedMessage({
id: baseId,
sessionId,
timestamp: ts,
provider: PROVIDER,
kind: 'text',
role: normalizedRole,
content,
}));
}
}
// Attach tool results to tool_use messages
const toolResultMap = new Map();
for (const msg of normalized) {
if (msg.kind === 'tool_result' && msg.toolId) {
toolResultMap.set(msg.toolId, msg);
}
}
for (const msg of normalized) {
if (msg.kind === 'tool_use' && msg.toolId && toolResultMap.has(msg.toolId)) {
const tr = toolResultMap.get(msg.toolId);
msg.toolResult = { content: tr.content, isError: tr.isError };
}
}
return {
messages: normalized,
total: normalized.length,
hasMore: false,
offset: 0,
limit: null,
};
},
};

View File

@@ -1,111 +0,0 @@
/**
* Gemini Provider Status
*
* Checks whether Gemini CLI is installed and whether the user
* has valid authentication credentials.
*
* @module providers/gemini/status
*/
import { execFileSync } from 'child_process';
import { promises as fs } from 'fs';
import path from 'path';
import os from 'os';
/**
* Check if Gemini CLI is installed.
* Uses GEMINI_PATH env var if set, otherwise looks for 'gemini' in PATH.
* @returns {boolean}
*/
export function checkInstalled() {
const cliPath = process.env.GEMINI_PATH || 'gemini';
try {
execFileSync(cliPath, ['--version'], { stdio: 'ignore', timeout: 5000 });
return true;
} catch {
return false;
}
}
/**
* Full status check: installation + authentication.
* @returns {Promise<import('../types.js').ProviderStatus>}
*/
export async function checkStatus() {
const installed = checkInstalled();
if (!installed) {
return {
installed,
authenticated: false,
email: null,
error: 'Gemini CLI is not installed'
};
}
const result = await checkCredentials();
return {
installed,
authenticated: result.authenticated,
email: result.email || null,
error: result.error || null
};
}
// ─── Internal helpers ───────────────────────────────────────────────────────
async function checkCredentials() {
if (process.env.GEMINI_API_KEY && process.env.GEMINI_API_KEY.trim()) {
return { authenticated: true, email: 'API Key Auth' };
}
try {
const credsPath = path.join(os.homedir(), '.gemini', 'oauth_creds.json');
const content = await fs.readFile(credsPath, 'utf8');
const creds = JSON.parse(content);
if (creds.access_token) {
let email = 'OAuth Session';
try {
const tokenRes = await fetch(`https://oauth2.googleapis.com/tokeninfo?access_token=${creds.access_token}`);
if (tokenRes.ok) {
const tokenInfo = await tokenRes.json();
if (tokenInfo.email) {
email = tokenInfo.email;
}
} else if (!creds.refresh_token) {
return {
authenticated: false,
email: null,
error: 'Access token invalid and no refresh token found'
};
} else {
// Token might be expired but we have a refresh token, so CLI will refresh it
email = await getActiveAccountEmail() || email;
}
} catch {
// Network error, fallback to checking local accounts file
email = await getActiveAccountEmail() || email;
}
return { authenticated: true, email };
}
return { authenticated: false, email: null, error: 'No valid tokens found in oauth_creds' };
} catch {
return { authenticated: false, email: null, error: 'Gemini CLI not configured' };
}
}
async function getActiveAccountEmail() {
try {
const accPath = path.join(os.homedir(), '.gemini', 'google_accounts.json');
const accContent = await fs.readFile(accPath, 'utf8');
const accounts = JSON.parse(accContent);
return accounts.active || null;
} catch {
return null;
}
}

View File

@@ -1,67 +0,0 @@
/**
* Provider Registry
*
* Centralizes provider adapter and status checker lookup. All code that needs
* a provider adapter or status checker should go through this registry instead
* of importing individual modules directly.
*
* @module providers/registry
*/
import { claudeAdapter } from './claude/adapter.js';
import { cursorAdapter } from './cursor/adapter.js';
import { codexAdapter } from './codex/adapter.js';
import { geminiAdapter } from './gemini/adapter.js';
import * as claudeStatus from './claude/status.js';
import * as cursorStatus from './cursor/status.js';
import * as codexStatus from './codex/status.js';
import * as geminiStatus from './gemini/status.js';
/**
* @typedef {import('./types.js').ProviderAdapter} ProviderAdapter
* @typedef {import('./types.js').SessionProvider} SessionProvider
*/
/** @type {Map<string, ProviderAdapter>} */
const providers = new Map();
/** @type {Map<string, { checkInstalled: () => boolean, checkStatus: () => Promise<import('./types.js').ProviderStatus> }>} */
const statusCheckers = new Map();
// Register built-in providers
providers.set('claude', claudeAdapter);
providers.set('cursor', cursorAdapter);
providers.set('codex', codexAdapter);
providers.set('gemini', geminiAdapter);
statusCheckers.set('claude', claudeStatus);
statusCheckers.set('cursor', cursorStatus);
statusCheckers.set('codex', codexStatus);
statusCheckers.set('gemini', geminiStatus);
/**
* Get a provider adapter by name.
* @param {string} name - Provider name (e.g., 'claude', 'cursor', 'codex', 'gemini')
* @returns {ProviderAdapter | undefined}
*/
export function getProvider(name) {
return providers.get(name);
}
/**
* Get a provider status checker by name.
* @param {string} name - Provider name
* @returns {{ checkInstalled: () => boolean, checkStatus: () => Promise<import('./types.js').ProviderStatus> } | undefined}
*/
export function getStatusChecker(name) {
return statusCheckers.get(name);
}
/**
* Get all registered provider names.
* @returns {string[]}
*/
export function getAllProviders() {
return Array.from(providers.keys());
}

View File

@@ -1,132 +0,0 @@
/**
* Provider Types & Interface
*
* Defines the normalized message format and the provider adapter interface.
* All providers normalize their native formats into NormalizedMessage
* before sending over REST or WebSocket.
*
* @module providers/types
*/
// ─── Session Provider ────────────────────────────────────────────────────────
/**
* @typedef {'claude' | 'cursor' | 'codex' | 'gemini'} SessionProvider
*/
// ─── Message Kind ────────────────────────────────────────────────────────────
/**
* @typedef {'text' | 'tool_use' | 'tool_result' | 'thinking' | 'stream_delta' | 'stream_end'
* | 'error' | 'complete' | 'status' | 'permission_request' | 'permission_cancelled'
* | 'session_created' | 'interactive_prompt' | 'task_notification'} MessageKind
*/
// ─── NormalizedMessage ───────────────────────────────────────────────────────
/**
* @typedef {Object} NormalizedMessage
* @property {string} id - Unique message id (for dedup between server + realtime)
* @property {string} sessionId
* @property {string} timestamp - ISO 8601
* @property {SessionProvider} provider
* @property {MessageKind} kind
*
* Additional fields depending on kind:
* - text: role ('user'|'assistant'), content, images?
* - tool_use: toolName, toolInput, toolId
* - tool_result: toolId, content, isError
* - thinking: content
* - stream_delta: content
* - stream_end: (no extra fields)
* - error: content
* - complete: (no extra fields)
* - status: text, tokens?, canInterrupt?
* - permission_request: requestId, toolName, input, context?
* - permission_cancelled: requestId
* - session_created: newSessionId
* - interactive_prompt: content
* - task_notification: status, summary
*/
// ─── Fetch History ───────────────────────────────────────────────────────────
/**
* @typedef {Object} FetchHistoryOptions
* @property {string} [projectName] - Project name (required for Claude)
* @property {string} [projectPath] - Absolute project path (required for Cursor cwdId hash)
* @property {number|null} [limit] - Page size (null = all messages)
* @property {number} [offset] - Pagination offset (default: 0)
*/
/**
* @typedef {Object} FetchHistoryResult
* @property {NormalizedMessage[]} messages - Normalized messages
* @property {number} total - Total number of messages in the session
* @property {boolean} hasMore - Whether more messages exist before the current page
* @property {number} offset - Current offset
* @property {number|null} limit - Page size used
* @property {object} [tokenUsage] - Token usage data (provider-specific)
*/
// ─── Provider Status ────────────────────────────────────────────────────────
/**
* Result of a provider status check (installation + authentication).
*
* @typedef {Object} ProviderStatus
* @property {boolean} installed - Whether the provider's CLI/SDK is available
* @property {boolean} authenticated - Whether valid credentials exist
* @property {string|null} email - User email or auth method identifier
* @property {string|null} [method] - Auth method (e.g. 'api_key', 'credentials_file')
* @property {string|null} [error] - Error message if not installed or not authenticated
*/
// ─── Provider Adapter Interface ──────────────────────────────────────────────
/**
* Every provider adapter MUST implement this interface.
*
* @typedef {Object} ProviderAdapter
*
* @property {(sessionId: string, opts?: FetchHistoryOptions) => Promise<FetchHistoryResult>} fetchHistory
* Read persisted session messages from disk/database and return them as NormalizedMessage[].
* The backend calls this from the unified GET /api/sessions/:id/messages endpoint.
*
* Provider implementations:
* - Claude: reads ~/.claude/projects/{projectName}/*.jsonl
* - Cursor: reads from SQLite store.db (via normalizeCursorBlobs helper)
* - Codex: reads ~/.codex/sessions/*.jsonl
* - Gemini: reads from in-memory sessionManager or ~/.gemini/tmp/ JSON files
*
* @property {(raw: any, sessionId: string) => NormalizedMessage[]} normalizeMessage
* Normalize a provider-specific event (JSONL entry or live SDK event) into NormalizedMessage[].
* Used by provider files to convert both history and realtime events.
*/
// ─── Runtime Helpers ─────────────────────────────────────────────────────────
/**
* Generate a unique message ID.
* Uses crypto.randomUUID() to avoid collisions across server restarts and workers.
* @param {string} [prefix='msg'] - Optional prefix
* @returns {string}
*/
export function generateMessageId(prefix = 'msg') {
return `${prefix}_${crypto.randomUUID()}`;
}
/**
* Create a NormalizedMessage with common fields pre-filled.
* @param {Partial<NormalizedMessage> & {kind: MessageKind, provider: SessionProvider}} fields
* @returns {NormalizedMessage}
*/
export function createNormalizedMessage(fields) {
return {
...fields,
id: fields.id || generateMessageId(fields.kind),
sessionId: fields.sessionId || '',
timestamp: fields.timestamp || new Date().toISOString(),
provider: fields.provider,
};
}

View File

@@ -1,29 +0,0 @@
/**
* Shared provider utilities.
*
* @module providers/utils
*/
/**
* Prefixes that indicate internal/system content which should be hidden from the UI.
* @type {readonly string[]}
*/
export const INTERNAL_CONTENT_PREFIXES = Object.freeze([
'<command-name>',
'<command-message>',
'<command-args>',
'<local-command-stdout>',
'<system-reminder>',
'Caveat:',
'This session is being continued from a previous',
'[Request interrupted',
]);
/**
* Check if user text content is internal/system that should be skipped.
* @param {string} content
* @returns {boolean}
*/
export function isInternalContent(content) {
return INTERNAL_CONTENT_PREFIXES.some(prefix => content.startsWith(prefix));
}

View File

@@ -1,27 +0,0 @@
/**
* CLI Auth Routes
*
* Thin router that delegates to per-provider status checkers
* registered in the provider registry.
*
* @module routes/cli-auth
*/
import express from 'express';
import { getAllProviders, getStatusChecker } from '../providers/registry.js';
const router = express.Router();
for (const provider of getAllProviders()) {
router.get(`/${provider}/status`, async (req, res) => {
try {
const checker = getStatusChecker(provider);
res.json(await checker.checkStatus());
} catch (error) {
console.error(`Error checking ${provider} status:`, error);
res.status(500).json({ authenticated: false, error: error.message });
}
});
}
export default router;

View File

@@ -1,73 +1,9 @@
import express from 'express';
import { spawn } from 'child_process';
import { promises as fs } from 'fs';
import path from 'path';
import os from 'os';
import TOML from '@iarna/toml';
import { getCodexSessions, deleteCodexSession } from '../projects.js';
import { applyCustomSessionNames, sessionNamesDb } from '../database/db.js';
import { deleteCodexSession } from '../projects.js';
import { sessionNamesDb } from '../database/db.js';
const router = express.Router();
function createCliResponder(res) {
let responded = false;
return (status, payload) => {
if (responded || res.headersSent) {
return;
}
responded = true;
res.status(status).json(payload);
};
}
router.get('/config', async (req, res) => {
try {
const configPath = path.join(os.homedir(), '.codex', 'config.toml');
const content = await fs.readFile(configPath, 'utf8');
const config = TOML.parse(content);
res.json({
success: true,
config: {
model: config.model || null,
mcpServers: config.mcp_servers || {},
approvalMode: config.approval_mode || 'suggest'
}
});
} catch (error) {
if (error.code === 'ENOENT') {
res.json({
success: true,
config: {
model: null,
mcpServers: {},
approvalMode: 'suggest'
}
});
} else {
console.error('Error reading Codex config:', error);
res.status(500).json({ success: false, error: error.message });
}
}
});
router.get('/sessions', async (req, res) => {
try {
const { projectPath } = req.query;
if (!projectPath) {
return res.status(400).json({ success: false, error: 'projectPath query parameter required' });
}
const sessions = await getCodexSessions(projectPath);
applyCustomSessionNames(sessions, 'codex');
res.json({ success: true, sessions });
} catch (error) {
console.error('Error fetching Codex sessions:', error);
res.status(500).json({ success: false, error: error.message });
}
});
router.delete('/sessions/:sessionId', async (req, res) => {
try {
const { sessionId } = req.params;
@@ -80,250 +16,4 @@ router.delete('/sessions/:sessionId', async (req, res) => {
}
});
// MCP Server Management Routes
router.get('/mcp/cli/list', async (req, res) => {
try {
const respond = createCliResponder(res);
const proc = spawn('codex', ['mcp', 'list'], { stdio: ['pipe', 'pipe', 'pipe'] });
let stdout = '';
let stderr = '';
proc.stdout?.on('data', (data) => { stdout += data.toString(); });
proc.stderr?.on('data', (data) => { stderr += data.toString(); });
proc.on('close', (code) => {
if (code === 0) {
respond(200, { success: true, output: stdout, servers: parseCodexListOutput(stdout) });
} else {
respond(500, { error: 'Codex CLI command failed', details: stderr || `Exited with code ${code}` });
}
});
proc.on('error', (error) => {
const isMissing = error?.code === 'ENOENT';
respond(isMissing ? 503 : 500, {
error: isMissing ? 'Codex CLI not installed' : 'Failed to run Codex CLI',
details: error.message,
code: error.code
});
});
} catch (error) {
res.status(500).json({ error: 'Failed to list MCP servers', details: error.message });
}
});
router.post('/mcp/cli/add', async (req, res) => {
try {
const { name, command, args = [], env = {} } = req.body;
if (!name || !command) {
return res.status(400).json({ error: 'name and command are required' });
}
// Build: codex mcp add <name> [-e KEY=VAL]... -- <command> [args...]
let cliArgs = ['mcp', 'add', name];
Object.entries(env).forEach(([key, value]) => {
cliArgs.push('-e', `${key}=${value}`);
});
cliArgs.push('--', command);
if (args && args.length > 0) {
cliArgs.push(...args);
}
const respond = createCliResponder(res);
const proc = spawn('codex', cliArgs, { stdio: ['pipe', 'pipe', 'pipe'] });
let stdout = '';
let stderr = '';
proc.stdout?.on('data', (data) => { stdout += data.toString(); });
proc.stderr?.on('data', (data) => { stderr += data.toString(); });
proc.on('close', (code) => {
if (code === 0) {
respond(200, { success: true, output: stdout, message: `MCP server "${name}" added successfully` });
} else {
respond(400, { error: 'Codex CLI command failed', details: stderr || `Exited with code ${code}` });
}
});
proc.on('error', (error) => {
const isMissing = error?.code === 'ENOENT';
respond(isMissing ? 503 : 500, {
error: isMissing ? 'Codex CLI not installed' : 'Failed to run Codex CLI',
details: error.message,
code: error.code
});
});
} catch (error) {
res.status(500).json({ error: 'Failed to add MCP server', details: error.message });
}
});
router.delete('/mcp/cli/remove/:name', async (req, res) => {
try {
const { name } = req.params;
const respond = createCliResponder(res);
const proc = spawn('codex', ['mcp', 'remove', name], { stdio: ['pipe', 'pipe', 'pipe'] });
let stdout = '';
let stderr = '';
proc.stdout?.on('data', (data) => { stdout += data.toString(); });
proc.stderr?.on('data', (data) => { stderr += data.toString(); });
proc.on('close', (code) => {
if (code === 0) {
respond(200, { success: true, output: stdout, message: `MCP server "${name}" removed successfully` });
} else {
respond(400, { error: 'Codex CLI command failed', details: stderr || `Exited with code ${code}` });
}
});
proc.on('error', (error) => {
const isMissing = error?.code === 'ENOENT';
respond(isMissing ? 503 : 500, {
error: isMissing ? 'Codex CLI not installed' : 'Failed to run Codex CLI',
details: error.message,
code: error.code
});
});
} catch (error) {
res.status(500).json({ error: 'Failed to remove MCP server', details: error.message });
}
});
router.get('/mcp/cli/get/:name', async (req, res) => {
try {
const { name } = req.params;
const respond = createCliResponder(res);
const proc = spawn('codex', ['mcp', 'get', name], { stdio: ['pipe', 'pipe', 'pipe'] });
let stdout = '';
let stderr = '';
proc.stdout?.on('data', (data) => { stdout += data.toString(); });
proc.stderr?.on('data', (data) => { stderr += data.toString(); });
proc.on('close', (code) => {
if (code === 0) {
respond(200, { success: true, output: stdout, server: parseCodexGetOutput(stdout) });
} else {
respond(404, { error: 'Codex CLI command failed', details: stderr || `Exited with code ${code}` });
}
});
proc.on('error', (error) => {
const isMissing = error?.code === 'ENOENT';
respond(isMissing ? 503 : 500, {
error: isMissing ? 'Codex CLI not installed' : 'Failed to run Codex CLI',
details: error.message,
code: error.code
});
});
} catch (error) {
res.status(500).json({ error: 'Failed to get MCP server details', details: error.message });
}
});
router.get('/mcp/config/read', async (req, res) => {
try {
const configPath = path.join(os.homedir(), '.codex', 'config.toml');
let configData = null;
try {
const fileContent = await fs.readFile(configPath, 'utf8');
configData = TOML.parse(fileContent);
} catch (error) {
// Config file doesn't exist
}
if (!configData) {
return res.json({ success: true, configPath, servers: [] }); }
const servers = [];
if (configData.mcp_servers && typeof configData.mcp_servers === 'object') {
for (const [name, config] of Object.entries(configData.mcp_servers)) {
servers.push({
id: name,
name: name,
type: 'stdio',
scope: 'user',
config: {
command: config.command || '',
args: config.args || [],
env: config.env || {}
},
raw: config
});
}
}
res.json({ success: true, configPath, servers });
} catch (error) {
res.status(500).json({ error: 'Failed to read Codex configuration', details: error.message });
}
});
function parseCodexListOutput(output) {
const servers = [];
const lines = output.split('\n').filter(line => line.trim());
for (const line of lines) {
if (line.includes(':')) {
const colonIndex = line.indexOf(':');
const name = line.substring(0, colonIndex).trim();
if (!name) continue;
const rest = line.substring(colonIndex + 1).trim();
let description = rest;
let status = 'unknown';
if (rest.includes('✓') || rest.includes('✗')) {
const statusMatch = rest.match(/(.*?)\s*-\s*([✓✗].*)$/);
if (statusMatch) {
description = statusMatch[1].trim();
status = statusMatch[2].includes('✓') ? 'connected' : 'failed';
}
}
servers.push({ name, type: 'stdio', status, description });
}
}
return servers;
}
function parseCodexGetOutput(output) {
try {
const jsonMatch = output.match(/\{[\s\S]*\}/);
if (jsonMatch) {
return JSON.parse(jsonMatch[0]);
}
const server = { raw_output: output };
const lines = output.split('\n');
for (const line of lines) {
if (line.includes('Name:')) server.name = line.split(':')[1]?.trim();
else if (line.includes('Type:')) server.type = line.split(':')[1]?.trim();
else if (line.includes('Command:')) server.command = line.split(':')[1]?.trim();
}
return server;
} catch (error) {
return { raw_output: output, parse_error: error.message };
}
}
export default router;

View File

@@ -451,55 +451,6 @@ router.post('/list', async (req, res) => {
}
});
/**
* POST /api/commands/load
* Load a specific command file and return its content and metadata
*/
router.post('/load', async (req, res) => {
try {
const { commandPath } = req.body;
if (!commandPath) {
return res.status(400).json({
error: 'Command path is required'
});
}
// Security: Prevent path traversal
const resolvedPath = path.resolve(commandPath);
if (!resolvedPath.startsWith(path.resolve(os.homedir())) &&
!resolvedPath.includes('.claude/commands')) {
return res.status(403).json({
error: 'Access denied',
message: 'Command must be in .claude/commands directory'
});
}
// Read and parse the command file
const content = await fs.readFile(commandPath, 'utf8');
const { data: metadata, content: commandContent } = parseFrontmatter(content);
res.json({
path: commandPath,
metadata,
content: commandContent
});
} catch (error) {
if (error.code === 'ENOENT') {
return res.status(404).json({
error: 'Command not found',
message: `Command file not found: ${req.body.commandPath}`
});
}
console.error('Error loading command:', error);
res.status(500).json({
error: 'Failed to load command',
message: error.message
});
}
});
/**
* POST /api/commands/execute
* Execute a command with argument replacement

View File

@@ -2,10 +2,7 @@ import express from 'express';
import { promises as fs } from 'fs';
import path from 'path';
import os from 'os';
import Database from 'better-sqlite3';
import crypto from 'crypto';
import { CURSOR_MODELS } from '../../shared/modelConstants.js';
import { applyCustomSessionNames } from '../database/db.js';
const router = express.Router();
@@ -13,20 +10,20 @@ const router = express.Router();
router.get('/config', async (req, res) => {
try {
const configPath = path.join(os.homedir(), '.cursor', 'cli-config.json');
try {
const configContent = await fs.readFile(configPath, 'utf8');
const config = JSON.parse(configContent);
res.json({
success: true,
config: config,
path: configPath
config,
path: configPath,
});
} catch (error) {
// Config doesn't exist or is invalid
console.log('Cursor config not found or invalid:', error.message);
// Return default config
res.json({
success: true,
@@ -34,531 +31,23 @@ router.get('/config', async (req, res) => {
version: 1,
model: {
modelId: CURSOR_MODELS.DEFAULT,
displayName: "GPT-5"
displayName: 'GPT-5',
},
permissions: {
allow: [],
deny: []
}
deny: [],
},
},
isDefault: true
isDefault: true,
});
}
} catch (error) {
console.error('Error reading Cursor config:', error);
res.status(500).json({
error: 'Failed to read Cursor configuration',
details: error.message
res.status(500).json({
error: 'Failed to read Cursor configuration',
details: error.message,
});
}
});
// POST /api/cursor/config - Update Cursor CLI configuration
router.post('/config', async (req, res) => {
try {
const { permissions, model } = req.body;
const configPath = path.join(os.homedir(), '.cursor', 'cli-config.json');
// Read existing config or create default
let config = {
version: 1,
editor: {
vimMode: false
},
hasChangedDefaultModel: false,
privacyCache: {
ghostMode: false,
privacyMode: 3,
updatedAt: Date.now()
}
};
try {
const existing = await fs.readFile(configPath, 'utf8');
config = JSON.parse(existing);
} catch (error) {
// Config doesn't exist, use defaults
console.log('Creating new Cursor config');
}
// Update permissions if provided
if (permissions) {
config.permissions = {
allow: permissions.allow || [],
deny: permissions.deny || []
};
}
// Update model if provided
if (model) {
config.model = model;
config.hasChangedDefaultModel = true;
}
// Ensure directory exists
const configDir = path.dirname(configPath);
await fs.mkdir(configDir, { recursive: true });
// Write updated config
await fs.writeFile(configPath, JSON.stringify(config, null, 2));
res.json({
success: true,
config: config,
message: 'Cursor configuration updated successfully'
});
} catch (error) {
console.error('Error updating Cursor config:', error);
res.status(500).json({
error: 'Failed to update Cursor configuration',
details: error.message
});
}
});
// GET /api/cursor/mcp - Read Cursor MCP servers configuration
router.get('/mcp', async (req, res) => {
try {
const mcpPath = path.join(os.homedir(), '.cursor', 'mcp.json');
try {
const mcpContent = await fs.readFile(mcpPath, 'utf8');
const mcpConfig = JSON.parse(mcpContent);
// Convert to UI-friendly format
const servers = [];
if (mcpConfig.mcpServers && typeof mcpConfig.mcpServers === 'object') {
for (const [name, config] of Object.entries(mcpConfig.mcpServers)) {
const server = {
id: name,
name: name,
type: 'stdio',
scope: 'cursor',
config: {},
raw: config
};
// Determine transport type and extract config
if (config.command) {
server.type = 'stdio';
server.config.command = config.command;
server.config.args = config.args || [];
server.config.env = config.env || {};
} else if (config.url) {
server.type = config.transport || 'http';
server.config.url = config.url;
server.config.headers = config.headers || {};
}
servers.push(server);
}
}
res.json({
success: true,
servers: servers,
path: mcpPath
});
} catch (error) {
// MCP config doesn't exist
console.log('Cursor MCP config not found:', error.message);
res.json({
success: true,
servers: [],
isDefault: true
});
}
} catch (error) {
console.error('Error reading Cursor MCP config:', error);
res.status(500).json({
error: 'Failed to read Cursor MCP configuration',
details: error.message
});
}
});
// POST /api/cursor/mcp/add - Add MCP server to Cursor configuration
router.post('/mcp/add', async (req, res) => {
try {
const { name, type = 'stdio', command, args = [], url, headers = {}, env = {} } = req.body;
const mcpPath = path.join(os.homedir(), '.cursor', 'mcp.json');
console.log(` Adding MCP server to Cursor config: ${name}`);
// Read existing config or create new
let mcpConfig = { mcpServers: {} };
try {
const existing = await fs.readFile(mcpPath, 'utf8');
mcpConfig = JSON.parse(existing);
if (!mcpConfig.mcpServers) {
mcpConfig.mcpServers = {};
}
} catch (error) {
console.log('Creating new Cursor MCP config');
}
// Build server config based on type
let serverConfig = {};
if (type === 'stdio') {
serverConfig = {
command: command,
args: args,
env: env
};
} else if (type === 'http' || type === 'sse') {
serverConfig = {
url: url,
transport: type,
headers: headers
};
}
// Add server to config
mcpConfig.mcpServers[name] = serverConfig;
// Ensure directory exists
const mcpDir = path.dirname(mcpPath);
await fs.mkdir(mcpDir, { recursive: true });
// Write updated config
await fs.writeFile(mcpPath, JSON.stringify(mcpConfig, null, 2));
res.json({
success: true,
message: `MCP server "${name}" added to Cursor configuration`,
config: mcpConfig
});
} catch (error) {
console.error('Error adding MCP server to Cursor:', error);
res.status(500).json({
error: 'Failed to add MCP server',
details: error.message
});
}
});
// DELETE /api/cursor/mcp/:name - Remove MCP server from Cursor configuration
router.delete('/mcp/:name', async (req, res) => {
try {
const { name } = req.params;
const mcpPath = path.join(os.homedir(), '.cursor', 'mcp.json');
console.log(`🗑️ Removing MCP server from Cursor config: ${name}`);
// Read existing config
let mcpConfig = { mcpServers: {} };
try {
const existing = await fs.readFile(mcpPath, 'utf8');
mcpConfig = JSON.parse(existing);
} catch (error) {
return res.status(404).json({
error: 'Cursor MCP configuration not found'
});
}
// Check if server exists
if (!mcpConfig.mcpServers || !mcpConfig.mcpServers[name]) {
return res.status(404).json({
error: `MCP server "${name}" not found in Cursor configuration`
});
}
// Remove server from config
delete mcpConfig.mcpServers[name];
// Write updated config
await fs.writeFile(mcpPath, JSON.stringify(mcpConfig, null, 2));
res.json({
success: true,
message: `MCP server "${name}" removed from Cursor configuration`,
config: mcpConfig
});
} catch (error) {
console.error('Error removing MCP server from Cursor:', error);
res.status(500).json({
error: 'Failed to remove MCP server',
details: error.message
});
}
});
// POST /api/cursor/mcp/add-json - Add MCP server using JSON format
router.post('/mcp/add-json', async (req, res) => {
try {
const { name, jsonConfig } = req.body;
const mcpPath = path.join(os.homedir(), '.cursor', 'mcp.json');
console.log(` Adding MCP server to Cursor config via JSON: ${name}`);
// Validate and parse JSON config
let parsedConfig;
try {
parsedConfig = typeof jsonConfig === 'string' ? JSON.parse(jsonConfig) : jsonConfig;
} catch (parseError) {
return res.status(400).json({
error: 'Invalid JSON configuration',
details: parseError.message
});
}
// Read existing config or create new
let mcpConfig = { mcpServers: {} };
try {
const existing = await fs.readFile(mcpPath, 'utf8');
mcpConfig = JSON.parse(existing);
if (!mcpConfig.mcpServers) {
mcpConfig.mcpServers = {};
}
} catch (error) {
console.log('Creating new Cursor MCP config');
}
// Add server to config
mcpConfig.mcpServers[name] = parsedConfig;
// Ensure directory exists
const mcpDir = path.dirname(mcpPath);
await fs.mkdir(mcpDir, { recursive: true });
// Write updated config
await fs.writeFile(mcpPath, JSON.stringify(mcpConfig, null, 2));
res.json({
success: true,
message: `MCP server "${name}" added to Cursor configuration via JSON`,
config: mcpConfig
});
} catch (error) {
console.error('Error adding MCP server to Cursor via JSON:', error);
res.status(500).json({
error: 'Failed to add MCP server',
details: error.message
});
}
});
// GET /api/cursor/sessions - Get Cursor sessions from SQLite database
router.get('/sessions', async (req, res) => {
try {
const { projectPath } = req.query;
// Calculate cwdID hash for the project path (Cursor uses MD5 hash)
const cwdId = crypto.createHash('md5').update(projectPath || process.cwd()).digest('hex');
const cursorChatsPath = path.join(os.homedir(), '.cursor', 'chats', cwdId);
// Check if the directory exists
try {
await fs.access(cursorChatsPath);
} catch (error) {
// No sessions for this project
return res.json({
success: true,
sessions: [],
cwdId: cwdId,
path: cursorChatsPath
});
}
// List all session directories
const sessionDirs = await fs.readdir(cursorChatsPath);
const sessions = [];
for (const sessionId of sessionDirs) {
const sessionPath = path.join(cursorChatsPath, sessionId);
const storeDbPath = path.join(sessionPath, 'store.db');
let dbStatMtimeMs = null;
try {
// Check if store.db exists
await fs.access(storeDbPath);
// Capture store.db mtime as a reliable fallback timestamp (last activity)
try {
const stat = await fs.stat(storeDbPath);
dbStatMtimeMs = stat.mtimeMs;
} catch (_) {}
// Open SQLite database
const db = new Database(storeDbPath, { readonly: true, fileMustExist: true });
// Get metadata from meta table
const metaRows = db.prepare('SELECT key, value FROM meta').all();
let sessionData = {
id: sessionId,
name: 'Untitled Session',
createdAt: null,
mode: null,
projectPath: projectPath,
lastMessage: null,
messageCount: 0
};
// Parse meta table entries
for (const row of metaRows) {
if (row.value) {
try {
// Try to decode as hex-encoded JSON
const hexMatch = row.value.toString().match(/^[0-9a-fA-F]+$/);
if (hexMatch) {
const jsonStr = Buffer.from(row.value, 'hex').toString('utf8');
const data = JSON.parse(jsonStr);
if (row.key === 'agent') {
sessionData.name = data.name || sessionData.name;
// Normalize createdAt to ISO string in milliseconds
let createdAt = data.createdAt;
if (typeof createdAt === 'number') {
if (createdAt < 1e12) {
createdAt = createdAt * 1000; // seconds -> ms
}
sessionData.createdAt = new Date(createdAt).toISOString();
} else if (typeof createdAt === 'string') {
const n = Number(createdAt);
if (!Number.isNaN(n)) {
const ms = n < 1e12 ? n * 1000 : n;
sessionData.createdAt = new Date(ms).toISOString();
} else {
// Assume it's already an ISO/date string
const d = new Date(createdAt);
sessionData.createdAt = isNaN(d.getTime()) ? null : d.toISOString();
}
} else {
sessionData.createdAt = sessionData.createdAt || null;
}
sessionData.mode = data.mode;
sessionData.agentId = data.agentId;
sessionData.latestRootBlobId = data.latestRootBlobId;
}
} else {
// If not hex, use raw value for simple keys
if (row.key === 'name') {
sessionData.name = row.value.toString();
}
}
} catch (e) {
console.log(`Could not parse meta value for key ${row.key}:`, e.message);
}
}
}
// Get message count from JSON blobs only (actual messages, not DAG structure)
try {
const blobCount = db.prepare(`SELECT COUNT(*) as count FROM blobs WHERE substr(data, 1, 1) = X'7B'`).get();
sessionData.messageCount = blobCount.count;
// Get the most recent JSON blob for preview (actual message, not DAG structure)
const lastBlob = db.prepare(`SELECT data FROM blobs WHERE substr(data, 1, 1) = X'7B' ORDER BY rowid DESC LIMIT 1`).get();
if (lastBlob && lastBlob.data) {
try {
// Try to extract readable preview from blob (may contain binary with embedded JSON)
const raw = lastBlob.data.toString('utf8');
let preview = '';
// Attempt direct JSON parse
try {
const parsed = JSON.parse(raw);
if (parsed?.content) {
if (Array.isArray(parsed.content)) {
const firstText = parsed.content.find(p => p?.type === 'text' && p.text)?.text || '';
preview = firstText;
} else if (typeof parsed.content === 'string') {
preview = parsed.content;
}
}
} catch (_) {}
if (!preview) {
// Strip non-printable and try to find JSON chunk
const cleaned = raw.replace(/[^\x09\x0A\x0D\x20-\x7E]/g, '');
const s = cleaned;
const start = s.indexOf('{');
const end = s.lastIndexOf('}');
if (start !== -1 && end > start) {
const jsonStr = s.slice(start, end + 1);
try {
const parsed = JSON.parse(jsonStr);
if (parsed?.content) {
if (Array.isArray(parsed.content)) {
const firstText = parsed.content.find(p => p?.type === 'text' && p.text)?.text || '';
preview = firstText;
} else if (typeof parsed.content === 'string') {
preview = parsed.content;
}
}
} catch (_) {
preview = s;
}
} else {
preview = s;
}
}
if (preview && preview.length > 0) {
sessionData.lastMessage = preview.substring(0, 100) + (preview.length > 100 ? '...' : '');
}
} catch (e) {
console.log('Could not parse blob data:', e.message);
}
}
} catch (e) {
console.log('Could not read blobs:', e.message);
}
db.close();
// Finalize createdAt: use parsed meta value when valid, else fall back to store.db mtime
if (!sessionData.createdAt) {
if (dbStatMtimeMs && Number.isFinite(dbStatMtimeMs)) {
sessionData.createdAt = new Date(dbStatMtimeMs).toISOString();
}
}
sessions.push(sessionData);
} catch (error) {
console.log(`Could not read session ${sessionId}:`, error.message);
}
}
// Fallback: ensure createdAt is a valid ISO string (use session directory mtime as last resort)
for (const s of sessions) {
if (!s.createdAt) {
try {
const sessionDir = path.join(cursorChatsPath, s.id);
const st = await fs.stat(sessionDir);
s.createdAt = new Date(st.mtimeMs).toISOString();
} catch {
s.createdAt = new Date().toISOString();
}
}
}
// Sort sessions by creation date (newest first)
sessions.sort((a, b) => {
if (!a.createdAt) return 1;
if (!b.createdAt) return -1;
return new Date(b.createdAt) - new Date(a.createdAt);
});
applyCustomSessionNames(sessions, 'cursor');
res.json({
success: true,
sessions: sessions,
cwdId: cwdId,
path: cursorChatsPath
});
} catch (error) {
console.error('Error reading Cursor sessions:', error);
res.status(500).json({
error: 'Failed to read Cursor sessions',
details: error.message
});
}
});
export default router;
export default router;

View File

@@ -7,7 +7,7 @@
*/
import express from 'express';
import { detectTaskMasterMCPServer, getAllMCPServers } from '../utils/mcp-detector.js';
import { detectTaskMasterMCPServer } from '../utils/mcp-detector.js';
const router = express.Router();
@@ -28,21 +28,4 @@ router.get('/taskmaster-server', async (req, res) => {
}
});
/**
* GET /api/mcp-utils/all-servers
* Get all configured MCP servers
*/
router.get('/all-servers', async (req, res) => {
try {
const result = await getAllMCPServers();
res.json(result);
} catch (error) {
console.error('MCP servers detection error:', error);
res.status(500).json({
error: 'Failed to get MCP servers',
message: error.message
});
}
});
export default router;
export default router;

View File

@@ -1,552 +0,0 @@
import express from 'express';
import { promises as fs } from 'fs';
import path from 'path';
import os from 'os';
import { fileURLToPath } from 'url';
import { dirname } from 'path';
import { spawn } from 'child_process';
const router = express.Router();
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
// Claude CLI command routes
// GET /api/mcp/cli/list - List MCP servers using Claude CLI
router.get('/cli/list', async (req, res) => {
try {
console.log('📋 Listing MCP servers using Claude CLI');
const { spawn } = await import('child_process');
const { promisify } = await import('util');
const exec = promisify(spawn);
const process = spawn('claude', ['mcp', 'list'], {
stdio: ['pipe', 'pipe', 'pipe']
});
let stdout = '';
let stderr = '';
process.stdout.on('data', (data) => {
stdout += data.toString();
});
process.stderr.on('data', (data) => {
stderr += data.toString();
});
process.on('close', (code) => {
if (code === 0) {
res.json({ success: true, output: stdout, servers: parseClaudeListOutput(stdout) });
} else {
console.error('Claude CLI error:', stderr);
res.status(500).json({ error: 'Claude CLI command failed', details: stderr });
}
});
process.on('error', (error) => {
console.error('Error running Claude CLI:', error);
res.status(500).json({ error: 'Failed to run Claude CLI', details: error.message });
});
} catch (error) {
console.error('Error listing MCP servers via CLI:', error);
res.status(500).json({ error: 'Failed to list MCP servers', details: error.message });
}
});
// POST /api/mcp/cli/add - Add MCP server using Claude CLI
router.post('/cli/add', async (req, res) => {
try {
const { name, type = 'stdio', command, args = [], url, headers = {}, env = {}, scope = 'user', projectPath } = req.body;
console.log(` Adding MCP server using Claude CLI (${scope} scope):`, name);
const { spawn } = await import('child_process');
let cliArgs = ['mcp', 'add'];
// Add scope flag
cliArgs.push('--scope', scope);
if (type === 'http') {
cliArgs.push('--transport', 'http', name, url);
// Add headers if provided
Object.entries(headers).forEach(([key, value]) => {
cliArgs.push('--header', `${key}: ${value}`);
});
} else if (type === 'sse') {
cliArgs.push('--transport', 'sse', name, url);
// Add headers if provided
Object.entries(headers).forEach(([key, value]) => {
cliArgs.push('--header', `${key}: ${value}`);
});
} else {
// stdio (default): claude mcp add --scope user <name> <command> [args...]
cliArgs.push(name);
// Add environment variables
Object.entries(env).forEach(([key, value]) => {
cliArgs.push('-e', `${key}=${value}`);
});
cliArgs.push(command);
if (args && args.length > 0) {
cliArgs.push(...args);
}
}
console.log('🔧 Running Claude CLI command:', 'claude', cliArgs.join(' '));
// For local scope, we need to run the command in the project directory
const spawnOptions = {
stdio: ['pipe', 'pipe', 'pipe']
};
if (scope === 'local' && projectPath) {
spawnOptions.cwd = projectPath;
console.log('📁 Running in project directory:', projectPath);
}
const process = spawn('claude', cliArgs, spawnOptions);
let stdout = '';
let stderr = '';
process.stdout.on('data', (data) => {
stdout += data.toString();
});
process.stderr.on('data', (data) => {
stderr += data.toString();
});
process.on('close', (code) => {
if (code === 0) {
res.json({ success: true, output: stdout, message: `MCP server "${name}" added successfully` });
} else {
console.error('Claude CLI error:', stderr);
res.status(400).json({ error: 'Claude CLI command failed', details: stderr });
}
});
process.on('error', (error) => {
console.error('Error running Claude CLI:', error);
res.status(500).json({ error: 'Failed to run Claude CLI', details: error.message });
});
} catch (error) {
console.error('Error adding MCP server via CLI:', error);
res.status(500).json({ error: 'Failed to add MCP server', details: error.message });
}
});
// POST /api/mcp/cli/add-json - Add MCP server using JSON format
router.post('/cli/add-json', async (req, res) => {
try {
const { name, jsonConfig, scope = 'user', projectPath } = req.body;
console.log(' Adding MCP server using JSON format:', name);
// Validate and parse JSON config
let parsedConfig;
try {
parsedConfig = typeof jsonConfig === 'string' ? JSON.parse(jsonConfig) : jsonConfig;
} catch (parseError) {
return res.status(400).json({
error: 'Invalid JSON configuration',
details: parseError.message
});
}
// Validate required fields
if (!parsedConfig.type) {
return res.status(400).json({
error: 'Invalid configuration',
details: 'Missing required field: type'
});
}
if (parsedConfig.type === 'stdio' && !parsedConfig.command) {
return res.status(400).json({
error: 'Invalid configuration',
details: 'stdio type requires a command field'
});
}
if ((parsedConfig.type === 'http' || parsedConfig.type === 'sse') && !parsedConfig.url) {
return res.status(400).json({
error: 'Invalid configuration',
details: `${parsedConfig.type} type requires a url field`
});
}
const { spawn } = await import('child_process');
// Build the command: claude mcp add-json --scope <scope> <name> '<json>'
const cliArgs = ['mcp', 'add-json', '--scope', scope, name];
// Add the JSON config as a properly formatted string
const jsonString = JSON.stringify(parsedConfig);
cliArgs.push(jsonString);
console.log('🔧 Running Claude CLI command:', 'claude', cliArgs[0], cliArgs[1], cliArgs[2], cliArgs[3], cliArgs[4], jsonString);
// For local scope, we need to run the command in the project directory
const spawnOptions = {
stdio: ['pipe', 'pipe', 'pipe']
};
if (scope === 'local' && projectPath) {
spawnOptions.cwd = projectPath;
console.log('📁 Running in project directory:', projectPath);
}
const process = spawn('claude', cliArgs, spawnOptions);
let stdout = '';
let stderr = '';
process.stdout.on('data', (data) => {
stdout += data.toString();
});
process.stderr.on('data', (data) => {
stderr += data.toString();
});
process.on('close', (code) => {
if (code === 0) {
res.json({ success: true, output: stdout, message: `MCP server "${name}" added successfully via JSON` });
} else {
console.error('Claude CLI error:', stderr);
res.status(400).json({ error: 'Claude CLI command failed', details: stderr });
}
});
process.on('error', (error) => {
console.error('Error running Claude CLI:', error);
res.status(500).json({ error: 'Failed to run Claude CLI', details: error.message });
});
} catch (error) {
console.error('Error adding MCP server via JSON:', error);
res.status(500).json({ error: 'Failed to add MCP server', details: error.message });
}
});
// DELETE /api/mcp/cli/remove/:name - Remove MCP server using Claude CLI
router.delete('/cli/remove/:name', async (req, res) => {
try {
const { name } = req.params;
const { scope } = req.query; // Get scope from query params
// Handle the ID format (remove scope prefix if present)
let actualName = name;
let actualScope = scope;
// If the name includes a scope prefix like "local:test", extract it
if (name.includes(':')) {
const [prefix, serverName] = name.split(':');
actualName = serverName;
actualScope = actualScope || prefix; // Use prefix as scope if not provided in query
}
console.log('🗑️ Removing MCP server using Claude CLI:', actualName, 'scope:', actualScope);
const { spawn } = await import('child_process');
// Build command args based on scope
let cliArgs = ['mcp', 'remove'];
// Add scope flag if it's local scope
if (actualScope === 'local') {
cliArgs.push('--scope', 'local');
} else if (actualScope === 'user' || !actualScope) {
// User scope is default, but we can be explicit
cliArgs.push('--scope', 'user');
}
cliArgs.push(actualName);
console.log('🔧 Running Claude CLI command:', 'claude', cliArgs.join(' '));
const process = spawn('claude', cliArgs, {
stdio: ['pipe', 'pipe', 'pipe']
});
let stdout = '';
let stderr = '';
process.stdout.on('data', (data) => {
stdout += data.toString();
});
process.stderr.on('data', (data) => {
stderr += data.toString();
});
process.on('close', (code) => {
if (code === 0) {
res.json({ success: true, output: stdout, message: `MCP server "${name}" removed successfully` });
} else {
console.error('Claude CLI error:', stderr);
res.status(400).json({ error: 'Claude CLI command failed', details: stderr });
}
});
process.on('error', (error) => {
console.error('Error running Claude CLI:', error);
res.status(500).json({ error: 'Failed to run Claude CLI', details: error.message });
});
} catch (error) {
console.error('Error removing MCP server via CLI:', error);
res.status(500).json({ error: 'Failed to remove MCP server', details: error.message });
}
});
// GET /api/mcp/cli/get/:name - Get MCP server details using Claude CLI
router.get('/cli/get/:name', async (req, res) => {
try {
const { name } = req.params;
console.log('📄 Getting MCP server details using Claude CLI:', name);
const { spawn } = await import('child_process');
const process = spawn('claude', ['mcp', 'get', name], {
stdio: ['pipe', 'pipe', 'pipe']
});
let stdout = '';
let stderr = '';
process.stdout.on('data', (data) => {
stdout += data.toString();
});
process.stderr.on('data', (data) => {
stderr += data.toString();
});
process.on('close', (code) => {
if (code === 0) {
res.json({ success: true, output: stdout, server: parseClaudeGetOutput(stdout) });
} else {
console.error('Claude CLI error:', stderr);
res.status(404).json({ error: 'Claude CLI command failed', details: stderr });
}
});
process.on('error', (error) => {
console.error('Error running Claude CLI:', error);
res.status(500).json({ error: 'Failed to run Claude CLI', details: error.message });
});
} catch (error) {
console.error('Error getting MCP server details via CLI:', error);
res.status(500).json({ error: 'Failed to get MCP server details', details: error.message });
}
});
// GET /api/mcp/config/read - Read MCP servers directly from Claude config files
router.get('/config/read', async (req, res) => {
try {
console.log('📖 Reading MCP servers from Claude config files');
const homeDir = os.homedir();
const configPaths = [
path.join(homeDir, '.claude.json'),
path.join(homeDir, '.claude', 'settings.json')
];
let configData = null;
let configPath = null;
// Try to read from either config file
for (const filepath of configPaths) {
try {
const fileContent = await fs.readFile(filepath, 'utf8');
configData = JSON.parse(fileContent);
configPath = filepath;
console.log(`✅ Found Claude config at: ${filepath}`);
break;
} catch (error) {
// File doesn't exist or is not valid JSON, try next
console.log(` Config not found or invalid at: ${filepath}`);
}
}
if (!configData) {
return res.json({
success: false,
message: 'No Claude configuration file found',
servers: []
});
}
// Extract MCP servers from the config
const servers = [];
// Check for user-scoped MCP servers (at root level)
if (configData.mcpServers && typeof configData.mcpServers === 'object' && Object.keys(configData.mcpServers).length > 0) {
console.log('🔍 Found user-scoped MCP servers:', Object.keys(configData.mcpServers));
for (const [name, config] of Object.entries(configData.mcpServers)) {
const server = {
id: name,
name: name,
type: 'stdio', // Default type
scope: 'user', // User scope - available across all projects
config: {},
raw: config // Include raw config for full details
};
// Determine transport type and extract config
if (config.command) {
server.type = 'stdio';
server.config.command = config.command;
server.config.args = config.args || [];
server.config.env = config.env || {};
} else if (config.url) {
server.type = config.transport || 'http';
server.config.url = config.url;
server.config.headers = config.headers || {};
}
servers.push(server);
}
}
// Check for local-scoped MCP servers (project-specific)
const currentProjectPath = process.cwd();
// Check under 'projects' key
if (configData.projects && configData.projects[currentProjectPath]) {
const projectConfig = configData.projects[currentProjectPath];
if (projectConfig.mcpServers && typeof projectConfig.mcpServers === 'object' && Object.keys(projectConfig.mcpServers).length > 0) {
console.log(`🔍 Found local-scoped MCP servers for ${currentProjectPath}:`, Object.keys(projectConfig.mcpServers));
for (const [name, config] of Object.entries(projectConfig.mcpServers)) {
const server = {
id: `local:${name}`, // Prefix with scope for uniqueness
name: name, // Keep original name
type: 'stdio', // Default type
scope: 'local', // Local scope - only for this project
projectPath: currentProjectPath,
config: {},
raw: config // Include raw config for full details
};
// Determine transport type and extract config
if (config.command) {
server.type = 'stdio';
server.config.command = config.command;
server.config.args = config.args || [];
server.config.env = config.env || {};
} else if (config.url) {
server.type = config.transport || 'http';
server.config.url = config.url;
server.config.headers = config.headers || {};
}
servers.push(server);
}
}
}
console.log(`📋 Found ${servers.length} MCP servers in config`);
res.json({
success: true,
configPath: configPath,
servers: servers
});
} catch (error) {
console.error('Error reading Claude config:', error);
res.status(500).json({
error: 'Failed to read Claude configuration',
details: error.message
});
}
});
// Helper functions to parse Claude CLI output
function parseClaudeListOutput(output) {
const servers = [];
const lines = output.split('\n').filter(line => line.trim());
for (const line of lines) {
// Skip the header line
if (line.includes('Checking MCP server health')) continue;
// Parse lines like "test: test test - ✗ Failed to connect"
// or "server-name: command or description - ✓ Connected"
if (line.includes(':')) {
const colonIndex = line.indexOf(':');
const name = line.substring(0, colonIndex).trim();
// Skip empty names
if (!name) continue;
// Extract the rest after the name
const rest = line.substring(colonIndex + 1).trim();
// Try to extract description and status
let description = rest;
let status = 'unknown';
let type = 'stdio'; // default type
// Check for status indicators
if (rest.includes('✓') || rest.includes('✗')) {
const statusMatch = rest.match(/(.*?)\s*-\s*([✓✗].*)$/);
if (statusMatch) {
description = statusMatch[1].trim();
status = statusMatch[2].includes('✓') ? 'connected' : 'failed';
}
}
// Try to determine type from description
if (description.startsWith('http://') || description.startsWith('https://')) {
type = 'http';
}
servers.push({
name,
type,
status: status || 'active',
description
});
}
}
console.log('🔍 Parsed Claude CLI servers:', servers);
return servers;
}
function parseClaudeGetOutput(output) {
// Parse the output from 'claude mcp get <name>' command
// This is a simple parser - might need adjustment based on actual output format
try {
// Try to extract JSON if present
const jsonMatch = output.match(/\{[\s\S]*\}/);
if (jsonMatch) {
return JSON.parse(jsonMatch[0]);
}
// Otherwise, parse as text
const server = { raw_output: output };
const lines = output.split('\n');
for (const line of lines) {
if (line.includes('Name:')) {
server.name = line.split(':')[1]?.trim();
} else if (line.includes('Type:')) {
server.type = line.split(':')[1]?.trim();
} else if (line.includes('Command:')) {
server.command = line.split(':')[1]?.trim();
} else if (line.includes('URL:')) {
server.url = line.split(':')[1]?.trim();
}
}
return server;
} catch (error) {
return { raw_output: output, parse_error: error.message };
}
}
export default router;

View File

@@ -10,7 +10,7 @@
*/
import express from 'express';
import { getProvider, getAllProviders } from '../providers/registry.js';
import { sessionsService } from '../modules/providers/services/sessions.service.js';
const router = express.Router();
@@ -29,7 +29,7 @@ const router = express.Router();
router.get('/:sessionId/messages', async (req, res) => {
try {
const { sessionId } = req.params;
const provider = req.query.provider || 'claude';
const provider = String(req.query.provider || 'claude').trim().toLowerCase();
const projectName = req.query.projectName || '';
const projectPath = req.query.projectPath || '';
const limitParam = req.query.limit;
@@ -38,13 +38,13 @@ router.get('/:sessionId/messages', async (req, res) => {
: null;
const offset = parseInt(req.query.offset || '0', 10);
const adapter = getProvider(provider);
if (!adapter) {
const available = getAllProviders().join(', ');
const availableProviders = sessionsService.listProviderIds();
if (!availableProviders.includes(provider)) {
const available = availableProviders.join(', ');
return res.status(400).json({ error: `Unknown provider: ${provider}. Available: ${available}` });
}
const result = await adapter.fetchHistory(sessionId, {
const result = await sessionsService.fetchHistory(provider, sessionId, {
projectName,
projectPath,
limit,

View File

@@ -273,4 +273,14 @@ router.post('/push/unsubscribe', async (req, res) => {
}
});
// Host OS for UI (e.g. hide Cursor agent when the backend runs on Windows).
router.get('/server-env', async (req, res) => {
try {
res.json({ platform: process.platform });
} catch (error) {
console.error('Error reading server environment:', error);
res.status(500).json({ error: 'Failed to read server environment' });
}
});
export default router;

View File

@@ -13,16 +13,10 @@ import fs from 'fs';
import path from 'path';
import { promises as fsPromises } from 'fs';
import { spawn } from 'child_process';
import { fileURLToPath } from 'url';
import { dirname } from 'path';
import os from 'os';
import { extractProjectDirectory } from '../projects.js';
import { detectTaskMasterMCPServer } from '../utils/mcp-detector.js';
import { broadcastTaskMasterProjectUpdate, broadcastTaskMasterTasksUpdate } from '../utils/taskmaster-websocket.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const router = express.Router();
/**
@@ -100,140 +94,6 @@ async function checkTaskMasterInstallation() {
});
}
/**
* Detect .taskmaster folder presence in a given project directory
* @param {string} projectPath - Absolute path to project directory
* @returns {Promise<Object>} Detection result with status and metadata
*/
async function detectTaskMasterFolder(projectPath) {
try {
const taskMasterPath = path.join(projectPath, '.taskmaster');
// Check if .taskmaster directory exists
try {
const stats = await fsPromises.stat(taskMasterPath);
if (!stats.isDirectory()) {
return {
hasTaskmaster: false,
reason: '.taskmaster exists but is not a directory'
};
}
} catch (error) {
if (error.code === 'ENOENT') {
return {
hasTaskmaster: false,
reason: '.taskmaster directory not found'
};
}
throw error;
}
// Check for key TaskMaster files
const keyFiles = [
'tasks/tasks.json',
'config.json'
];
const fileStatus = {};
let hasEssentialFiles = true;
for (const file of keyFiles) {
const filePath = path.join(taskMasterPath, file);
try {
await fsPromises.access(filePath, fs.constants.R_OK);
fileStatus[file] = true;
} catch (error) {
fileStatus[file] = false;
if (file === 'tasks/tasks.json') {
hasEssentialFiles = false;
}
}
}
// Parse tasks.json if it exists for metadata
let taskMetadata = null;
if (fileStatus['tasks/tasks.json']) {
try {
const tasksPath = path.join(taskMasterPath, 'tasks/tasks.json');
const tasksContent = await fsPromises.readFile(tasksPath, 'utf8');
const tasksData = JSON.parse(tasksContent);
// Handle both tagged and legacy formats
let tasks = [];
if (tasksData.tasks) {
// Legacy format
tasks = tasksData.tasks;
} else {
// Tagged format - get tasks from all tags
Object.values(tasksData).forEach(tagData => {
if (tagData.tasks) {
tasks = tasks.concat(tagData.tasks);
}
});
}
// Calculate task statistics
const stats = tasks.reduce((acc, task) => {
acc.total++;
acc[task.status] = (acc[task.status] || 0) + 1;
// Count subtasks
if (task.subtasks) {
task.subtasks.forEach(subtask => {
acc.subtotalTasks++;
acc.subtasks = acc.subtasks || {};
acc.subtasks[subtask.status] = (acc.subtasks[subtask.status] || 0) + 1;
});
}
return acc;
}, {
total: 0,
subtotalTasks: 0,
pending: 0,
'in-progress': 0,
done: 0,
review: 0,
deferred: 0,
cancelled: 0,
subtasks: {}
});
taskMetadata = {
taskCount: stats.total,
subtaskCount: stats.subtotalTasks,
completed: stats.done || 0,
pending: stats.pending || 0,
inProgress: stats['in-progress'] || 0,
review: stats.review || 0,
completionPercentage: stats.total > 0 ? Math.round((stats.done / stats.total) * 100) : 0,
lastModified: (await fsPromises.stat(tasksPath)).mtime.toISOString()
};
} catch (parseError) {
console.warn('Failed to parse tasks.json:', parseError.message);
taskMetadata = { error: 'Failed to parse tasks.json' };
}
}
return {
hasTaskmaster: true,
hasEssentialFiles,
files: fileStatus,
metadata: taskMetadata,
path: taskMasterPath
};
} catch (error) {
console.error('Error detecting TaskMaster folder:', error);
return {
hasTaskmaster: false,
reason: `Error checking directory: ${error.message}`
};
}
}
// MCP detection is now handled by the centralized utility
// API Routes
/**
@@ -271,298 +131,6 @@ router.get('/installation-status', async (req, res) => {
}
});
/**
* GET /api/taskmaster/detect/:projectName
* Detect TaskMaster configuration for a specific project
*/
router.get('/detect/:projectName', async (req, res) => {
try {
const { projectName } = req.params;
// Use the existing extractProjectDirectory function to get actual project path
let projectPath;
try {
projectPath = await extractProjectDirectory(projectName);
} catch (error) {
console.error('Error extracting project directory:', error);
return res.status(404).json({
error: 'Project path not found',
projectName,
message: error.message
});
}
// Verify the project path exists
try {
await fsPromises.access(projectPath, fs.constants.R_OK);
} catch (error) {
return res.status(404).json({
error: 'Project path not accessible',
projectPath,
projectName,
message: error.message
});
}
// Run detection in parallel
const [taskMasterResult, mcpResult] = await Promise.all([
detectTaskMasterFolder(projectPath),
detectTaskMasterMCPServer()
]);
// Determine overall status
let status = 'not-configured';
if (taskMasterResult.hasTaskmaster && taskMasterResult.hasEssentialFiles) {
if (mcpResult.hasMCPServer && mcpResult.isConfigured) {
status = 'fully-configured';
} else {
status = 'taskmaster-only';
}
} else if (mcpResult.hasMCPServer && mcpResult.isConfigured) {
status = 'mcp-only';
}
const responseData = {
projectName,
projectPath,
status,
taskmaster: taskMasterResult,
mcp: mcpResult,
timestamp: new Date().toISOString()
};
res.json(responseData);
} catch (error) {
console.error('TaskMaster detection error:', error);
res.status(500).json({
error: 'Failed to detect TaskMaster configuration',
message: error.message
});
}
});
/**
* GET /api/taskmaster/detect-all
* Detect TaskMaster configuration for all known projects
* This endpoint works with the existing projects system
*/
router.get('/detect-all', async (req, res) => {
try {
// Import getProjects from the projects module
const { getProjects } = await import('../projects.js');
const projects = await getProjects();
// Run detection for all projects in parallel
const detectionPromises = projects.map(async (project) => {
try {
// Use the project's fullPath if available, otherwise extract the directory
let projectPath;
if (project.fullPath) {
projectPath = project.fullPath;
} else {
try {
projectPath = await extractProjectDirectory(project.name);
} catch (error) {
throw new Error(`Failed to extract project directory: ${error.message}`);
}
}
const [taskMasterResult, mcpResult] = await Promise.all([
detectTaskMasterFolder(projectPath),
detectTaskMasterMCPServer()
]);
// Determine status
let status = 'not-configured';
if (taskMasterResult.hasTaskmaster && taskMasterResult.hasEssentialFiles) {
if (mcpResult.hasMCPServer && mcpResult.isConfigured) {
status = 'fully-configured';
} else {
status = 'taskmaster-only';
}
} else if (mcpResult.hasMCPServer && mcpResult.isConfigured) {
status = 'mcp-only';
}
return {
projectName: project.name,
displayName: project.displayName,
projectPath,
status,
taskmaster: taskMasterResult,
mcp: mcpResult
};
} catch (error) {
return {
projectName: project.name,
displayName: project.displayName,
status: 'error',
error: error.message
};
}
});
const results = await Promise.all(detectionPromises);
res.json({
projects: results,
summary: {
total: results.length,
fullyConfigured: results.filter(p => p.status === 'fully-configured').length,
taskmasterOnly: results.filter(p => p.status === 'taskmaster-only').length,
mcpOnly: results.filter(p => p.status === 'mcp-only').length,
notConfigured: results.filter(p => p.status === 'not-configured').length,
errors: results.filter(p => p.status === 'error').length
},
timestamp: new Date().toISOString()
});
} catch (error) {
console.error('Bulk TaskMaster detection error:', error);
res.status(500).json({
error: 'Failed to detect TaskMaster configuration for projects',
message: error.message
});
}
});
/**
* POST /api/taskmaster/initialize/:projectName
* Initialize TaskMaster in a project (placeholder for future CLI integration)
*/
router.post('/initialize/:projectName', async (req, res) => {
try {
const { projectName } = req.params;
const { rules } = req.body; // Optional rule profiles
// This will be implemented in a later subtask with CLI integration
res.status(501).json({
error: 'TaskMaster initialization not yet implemented',
message: 'This endpoint will execute task-master init via CLI in a future update',
projectName,
rules
});
} catch (error) {
console.error('TaskMaster initialization error:', error);
res.status(500).json({
error: 'Failed to initialize TaskMaster',
message: error.message
});
}
});
/**
* GET /api/taskmaster/next/:projectName
* Get the next recommended task using task-master CLI
*/
router.get('/next/:projectName', async (req, res) => {
try {
const { projectName } = req.params;
// Get project path
let projectPath;
try {
projectPath = await extractProjectDirectory(projectName);
} catch (error) {
return res.status(404).json({
error: 'Project not found',
message: `Project "${projectName}" does not exist`
});
}
// Try to execute task-master next command
try {
const { spawn } = await import('child_process');
const nextTaskCommand = spawn('task-master', ['next'], {
cwd: projectPath,
stdio: ['pipe', 'pipe', 'pipe']
});
let stdout = '';
let stderr = '';
nextTaskCommand.stdout.on('data', (data) => {
stdout += data.toString();
});
nextTaskCommand.stderr.on('data', (data) => {
stderr += data.toString();
});
await new Promise((resolve, reject) => {
nextTaskCommand.on('close', (code) => {
if (code === 0) {
resolve();
} else {
reject(new Error(`task-master next failed with code ${code}: ${stderr}`));
}
});
nextTaskCommand.on('error', (error) => {
reject(error);
});
});
// Parse the output - task-master next usually returns JSON
let nextTaskData = null;
if (stdout.trim()) {
try {
nextTaskData = JSON.parse(stdout);
} catch (parseError) {
// If not JSON, treat as plain text
nextTaskData = { message: stdout.trim() };
}
}
res.json({
projectName,
projectPath,
nextTask: nextTaskData,
timestamp: new Date().toISOString()
});
} catch (cliError) {
console.warn('Failed to execute task-master CLI:', cliError.message);
// Fallback to loading tasks and finding next one locally
// Use localhost to bypass proxy for internal server-to-server calls
const tasksResponse = await fetch(`http://localhost:${process.env.SERVER_PORT || process.env.PORT || '3001'}/api/taskmaster/tasks/${encodeURIComponent(projectName)}`, {
headers: {
'Authorization': req.headers.authorization
}
});
if (tasksResponse.ok) {
const tasksData = await tasksResponse.json();
const nextTask = tasksData.tasks?.find(task =>
task.status === 'pending' || task.status === 'in-progress'
) || null;
res.json({
projectName,
projectPath,
nextTask,
fallback: true,
message: 'Used fallback method (CLI not available)',
timestamp: new Date().toISOString()
});
} else {
throw new Error('Failed to load tasks via fallback method');
}
}
} catch (error) {
console.error('TaskMaster next task error:', error);
res.status(500).json({
error: 'Failed to get next task',
message: error.message
});
}
});
/**
* GET /api/taskmaster/tasks/:projectName
* Load actual tasks from .taskmaster/tasks/tasks.json
@@ -904,66 +472,6 @@ router.get('/prd/:projectName/:fileName', async (req, res) => {
}
});
/**
* DELETE /api/taskmaster/prd/:projectName/:fileName
* Delete a specific PRD file
*/
router.delete('/prd/:projectName/:fileName', async (req, res) => {
try {
const { projectName, fileName } = req.params;
// Get project path
let projectPath;
try {
projectPath = await extractProjectDirectory(projectName);
} catch (error) {
return res.status(404).json({
error: 'Project not found',
message: `Project "${projectName}" does not exist`
});
}
const filePath = path.join(projectPath, '.taskmaster', 'docs', fileName);
// Check if file exists
try {
await fsPromises.access(filePath, fs.constants.F_OK);
} catch (error) {
return res.status(404).json({
error: 'PRD file not found',
message: `File "${fileName}" does not exist`
});
}
// Delete the file
try {
await fsPromises.unlink(filePath);
res.json({
projectName,
projectPath,
fileName,
message: 'PRD file deleted successfully',
timestamp: new Date().toISOString()
});
} catch (deleteError) {
console.error('Failed to delete PRD file:', deleteError);
return res.status(500).json({
error: 'Failed to delete PRD file',
message: deleteError.message
});
}
} catch (error) {
console.error('PRD delete error:', error);
res.status(500).json({
error: 'Failed to delete PRD file',
message: error.message
});
}
});
/**
* POST /api/taskmaster/init/:projectName
* Initialize TaskMaster in a project

View File

@@ -0,0 +1,48 @@
import type {
FetchHistoryOptions,
FetchHistoryResult,
LLMProvider,
McpScope,
NormalizedMessage,
ProviderAuthStatus,
ProviderMcpServer,
UpsertProviderMcpServerInput,
} from '@/shared/types.js';
/**
* Main provider contract for CLI and SDK integrations.
*
* Each concrete provider owns its MCP/auth handlers plus the provider-specific
* logic for converting native events/history into the app's normalized shape.
*/
export interface IProvider {
readonly id: LLMProvider;
readonly mcp: IProviderMcp;
readonly auth: IProviderAuth;
normalizeMessage(raw: unknown, sessionId: string | null): NormalizedMessage[];
fetchHistory(sessionId: string, options?: FetchHistoryOptions): Promise<FetchHistoryResult>;
}
/**
* Auth contract for one provider.
*/
export interface IProviderAuth {
/**
* Checks whether the provider is installed and has usable credentials.
*/
getStatus(): Promise<ProviderAuthStatus>;
}
/**
* MCP contract for one provider.
*/
export interface IProviderMcp {
listServers(options?: { workspacePath?: string }): Promise<Record<McpScope, ProviderMcpServer[]>>;
listServersForScope(scope: McpScope, options?: { workspacePath?: string }): Promise<ProviderMcpServer[]>;
upsertServer(input: UpsertProviderMcpServerInput): Promise<ProviderMcpServer>;
removeServer(
input: { name: string; scope?: McpScope; workspacePath?: string },
): Promise<{ removed: boolean; provider: LLMProvider; name: string; scope: McpScope }>;
}

179
server/shared/types.ts Normal file
View File

@@ -0,0 +1,179 @@
// -------------- HTTP API response shapes for the server, shared across modules --------------
export type ApiSuccessShape<TData = unknown> = {
success: true;
data: TData;
};
export type ApiErrorShape = {
success: false;
error: {
code: string;
message: string;
details?: unknown;
};
};
// ---------------------------------------------------------------------------------------------
export type LLMProvider = 'claude' | 'codex' | 'gemini' | 'cursor';
// ---------------------------------------------------------------------------------------------
export type MessageKind =
| 'text'
| 'tool_use'
| 'tool_result'
| 'thinking'
| 'stream_delta'
| 'stream_end'
| 'error'
| 'complete'
| 'status'
| 'permission_request'
| 'permission_cancelled'
| 'session_created'
| 'interactive_prompt'
| 'task_notification';
/**
* Provider-neutral message event emitted over REST and realtime transports.
*
* Providers all produce their own native SDK/CLI event shapes, so this type keeps
* the common envelope strict while allowing provider-specific details to ride
* along as optional properties.
*/
export type NormalizedMessage = {
id: string;
sessionId: string;
timestamp: string;
provider: LLMProvider;
kind: MessageKind;
role?: 'user' | 'assistant';
content?: string;
images?: unknown;
toolName?: string;
toolInput?: unknown;
toolId?: string;
toolResult?: {
content?: string;
isError?: boolean;
toolUseResult?: unknown;
};
isError?: boolean;
text?: string;
tokens?: number;
canInterrupt?: boolean;
requestId?: string;
input?: unknown;
context?: unknown;
reason?: string;
newSessionId?: string;
status?: string;
summary?: string;
tokenBudget?: unknown;
subagentTools?: unknown;
toolUseResult?: unknown;
sequence?: number;
rowid?: number;
[key: string]: unknown;
};
/**
* Pagination and provider lookup options for reading persisted session history.
*/
export type FetchHistoryOptions = {
/** Claude project folder name. Required by Claude history lookup. */
projectName?: string;
/** Absolute workspace path. Required by Cursor to compute its chat hash. */
projectPath?: string;
/** Page size. `null` means all messages. */
limit?: number | null;
/** Pagination offset from the newest messages. */
offset?: number;
};
/**
* Provider-neutral history result returned by the unified messages endpoint.
*/
export type FetchHistoryResult = {
messages: NormalizedMessage[];
total: number;
hasMore: boolean;
offset: number;
limit: number | null;
tokenUsage?: unknown;
};
// ---------------------------------------------------------------------------------------------
export type AppErrorOptions = {
code?: string;
statusCode?: number;
details?: unknown;
};
// -------------------- MCP related shared types --------------------
export type McpScope = 'user' | 'local' | 'project';
export type McpTransport = 'stdio' | 'http' | 'sse';
/**
* Provider MCP server descriptor normalized for frontend consumption.
*/
export type ProviderMcpServer = {
provider: LLMProvider;
name: string;
scope: McpScope;
transport: McpTransport;
command?: string;
args?: string[];
env?: Record<string, string>;
cwd?: string;
url?: string;
headers?: Record<string, string>;
envVars?: string[];
bearerTokenEnvVar?: string;
envHttpHeaders?: Record<string, string>;
};
/**
* Shared payload shape for MCP server create/update operations.
*/
export type UpsertProviderMcpServerInput = {
name: string;
scope?: McpScope;
transport: McpTransport;
workspacePath?: string;
command?: string;
args?: string[];
env?: Record<string, string>;
cwd?: string;
url?: string;
headers?: Record<string, string>;
envVars?: string[];
bearerTokenEnvVar?: string;
envHttpHeaders?: Record<string, string>;
};
// ---------------------------------------------------------------------------------------------
// -------------------- Provider auth status types --------------------
/**
* Result of a provider status check (installation + authentication).
*
* installed - Whether the provider's CLI/SDK is available
* provider - Provider id the status belongs to
* authenticated - Whether valid credentials exist
* email - User email or auth method identifier
* method - Auth method (e.g. 'api_key', 'credentials_file')
* [error] - Error message if not installed or not authenticated
*/
export type ProviderAuthStatus = {
installed: boolean;
provider: LLMProvider;
authenticated: boolean;
email: string | null;
method: string | null;
error?: string;
};

208
server/shared/utils.ts Normal file
View File

@@ -0,0 +1,208 @@
import { randomUUID } from 'node:crypto';
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import path from 'node:path';
import type { NextFunction, Request, RequestHandler, Response } from 'express';
import type {
ApiErrorShape,
ApiSuccessShape,
AppErrorOptions,
NormalizedMessage,
} from '@/shared/types.js';
type NormalizedMessageInput =
{
kind: NormalizedMessage['kind'];
provider: NormalizedMessage['provider'];
id?: string | null;
sessionId?: string | null;
timestamp?: string | null;
} & Record<string, unknown>;
export function createApiSuccessResponse<TData>(
data: TData,
): ApiSuccessShape<TData> {
return {
success: true,
data,
};
}
export function createApiErrorResponse(
code: string,
message: string,
details?: unknown
): ApiErrorShape {
return {
success: false,
error: {
code,
message,
details,
}
};
}
export function asyncHandler(
handler: (req: Request, res: Response, next: NextFunction) => Promise<unknown>
): RequestHandler {
return (req, res, next) => {
void Promise.resolve(handler(req, res, next)).catch(next);
};
}
// --------- Global app error class for consistent error handling across the server ---------
export class AppError extends Error {
readonly code: string;
readonly statusCode: number;
readonly details?: unknown;
constructor(message: string, options: AppErrorOptions = {}) {
super(message);
this.name = 'AppError';
this.code = options.code ?? 'INTERNAL_ERROR';
this.statusCode = options.statusCode ?? 500;
this.details = options.details;
}
}
// -------------------------------------------------------------------------------------------
// ------------------------ Normalized provider message helpers ------------------------
/**
* Generates a stable unique id for normalized provider messages.
*/
export function generateMessageId(prefix = 'msg'): string {
return `${prefix}_${randomUUID()}`;
}
/**
* Creates a normalized provider message and fills the shared envelope fields.
*
* Provider adapters and live SDK handlers pass through provider-specific fields,
* while this helper guarantees every emitted event has an id, session id,
* timestamp, and provider marker.
*/
export function createNormalizedMessage(fields: NormalizedMessageInput): NormalizedMessage {
return {
...fields,
id: fields.id || generateMessageId(fields.kind),
sessionId: fields.sessionId || '',
timestamp: fields.timestamp || new Date().toISOString(),
provider: fields.provider,
};
}
// -------------------------------------------------------------------------------------------
// ------------------------ The following are mainly for provider MCP runtimes ------------------------
/**
* Safely narrows an unknown value to a plain object record.
*
* This deliberately rejects arrays, `null`, and primitive values so callers can
* treat the returned value as a JSON-style object map without repeating the same
* defensive shape checks at every config read site.
*/
export const readObjectRecord = (value: unknown): Record<string, unknown> | null => {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return null;
}
return value as Record<string, unknown>;
};
/**
* Reads an optional string from unknown input and normalizes empty or whitespace-only
* values to `undefined`.
*
* This is useful when parsing config files where a field may be missing, present
* with the wrong type, or present as an empty string that should be treated as
* "not configured".
*/
export const readOptionalString = (value: unknown): string | undefined => {
if (typeof value !== 'string') {
return undefined;
}
const normalized = value.trim();
return normalized.length > 0 ? normalized : undefined;
};
/**
* Reads an optional string array from unknown input.
*
* Non-array values are ignored, and any array entries that are not strings are
* filtered out. This lets provider config readers consume loosely shaped JSON/TOML
* data without failing on incidental invalid members.
*/
export const readStringArray = (value: unknown): string[] | undefined => {
if (!Array.isArray(value)) {
return undefined;
}
return value.filter((entry): entry is string => typeof entry === 'string');
};
/**
* Reads an optional string-to-string map from unknown input.
*
* The function first ensures the source value is a plain object, then keeps only
* keys whose values are strings. If no valid entries remain, it returns `undefined`
* so callers can distinguish "no usable map" from an empty object that was
* intentionally authored downstream.
*/
export const readStringRecord = (value: unknown): Record<string, string> | undefined => {
const record = readObjectRecord(value);
if (!record) {
return undefined;
}
const normalized: Record<string, string> = {};
for (const [key, entry] of Object.entries(record)) {
if (typeof entry === 'string') {
normalized[key] = entry;
}
}
return Object.keys(normalized).length > 0 ? normalized : undefined;
};
/**
* Reads a JSON config file and guarantees a plain object result.
*
* Missing files are treated as an empty config object so provider-specific MCP
* readers can operate against first-run environments without special-case file
* existence checks. If the file exists but contains invalid JSON, the parse error
* is preserved and rethrown.
*/
export const readJsonConfig = async (filePath: string): Promise<Record<string, unknown>> => {
try {
const content = await readFile(filePath, 'utf8');
const parsed = JSON.parse(content) as Record<string, unknown>;
return readObjectRecord(parsed) ?? {};
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
if (code === 'ENOENT') {
return {};
}
throw error;
}
};
/**
* Writes a JSON config file with stable, human-readable formatting.
*
* The parent directory is created automatically so callers can persist config into
* provider-specific folders without pre-creating the directory tree. Output always
* ends with a trailing newline to keep the file diff-friendly.
*/
export const writeJsonConfig = async (filePath: string, data: Record<string, unknown>): Promise<void> => {
await mkdir(path.dirname(filePath), { recursive: true });
await writeFile(filePath, `${JSON.stringify(data, null, 2)}\n`, 'utf8');
};
// -------------------------------------------------------------------------------------------

View File

@@ -1,21 +0,0 @@
// ANSI color codes for terminal output
const colors = {
reset: '\x1b[0m',
bright: '\x1b[1m',
cyan: '\x1b[36m',
green: '\x1b[32m',
yellow: '\x1b[33m',
blue: '\x1b[34m',
dim: '\x1b[2m',
};
const c = {
info: (text) => `${colors.cyan}${text}${colors.reset}`,
ok: (text) => `${colors.green}${text}${colors.reset}`,
warn: (text) => `${colors.yellow}${text}${colors.reset}`,
tip: (text) => `${colors.blue}${text}${colors.reset}`,
bright: (text) => `${colors.bright}${text}${colors.reset}`,
dim: (text) => `${colors.dim}${text}${colors.reset}`,
};
export { colors, c };

View File

@@ -145,54 +145,3 @@ export async function detectTaskMasterMCPServer() {
}
}
/**
* Get all configured MCP servers (not just TaskMaster)
* @returns {Promise<Object>} All MCP servers configuration
*/
export async function getAllMCPServers() {
try {
const homeDir = os.homedir();
const configPaths = [
path.join(homeDir, '.claude.json'),
path.join(homeDir, '.claude', 'settings.json')
];
let configData = null;
let configPath = null;
// Try to read from either config file
for (const filepath of configPaths) {
try {
const fileContent = await fsPromises.readFile(filepath, 'utf8');
configData = JSON.parse(fileContent);
configPath = filepath;
break;
} catch (error) {
continue;
}
}
if (!configData) {
return {
hasConfig: false,
servers: {},
projectServers: {}
};
}
return {
hasConfig: true,
configPath,
servers: configData.mcpServers || {},
projectServers: configData.projects || {}
};
} catch (error) {
console.error('Error getting all MCP servers:', error);
return {
hasConfig: false,
error: error.message,
servers: {},
projectServers: {}
};
}
}

View File

@@ -1,71 +0,0 @@
const ANSI_ESCAPE_SEQUENCE_REGEX = /\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~]|\][^\x07]*(?:\x07|\x1B\\))/g;
const TRAILING_URL_PUNCTUATION_REGEX = /[)\]}>.,;:!?]+$/;
function stripAnsiSequences(value = '') {
return value.replace(ANSI_ESCAPE_SEQUENCE_REGEX, '');
}
function normalizeDetectedUrl(url) {
if (!url || typeof url !== 'string') return null;
const cleaned = url.trim().replace(TRAILING_URL_PUNCTUATION_REGEX, '');
if (!cleaned) return null;
try {
const parsed = new URL(cleaned);
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
return null;
}
return parsed.toString();
} catch {
return null;
}
}
function extractUrlsFromText(value = '') {
const directMatches = value.match(/https?:\/\/[^\s<>"'`\\\x1b\x07]+/gi) || [];
// Handle wrapped terminal URLs split across lines by terminal width.
const wrappedMatches = [];
const continuationRegex = /^[A-Za-z0-9\-._~:/?#\[\]@!$&'()*+,;=%]+$/;
const lines = value.split(/\r?\n/);
for (let i = 0; i < lines.length; i++) {
const line = lines[i].trim();
const startMatch = line.match(/https?:\/\/[^\s<>"'`\\\x1b\x07]+/i);
if (!startMatch) continue;
let combined = startMatch[0];
let j = i + 1;
while (j < lines.length) {
const continuation = lines[j].trim();
if (!continuation) break;
if (!continuationRegex.test(continuation)) break;
combined += continuation;
j++;
}
wrappedMatches.push(combined.replace(/\r?\n\s*/g, ''));
}
return Array.from(new Set([...directMatches, ...wrappedMatches]));
}
function shouldAutoOpenUrlFromOutput(value = '') {
const normalized = value.toLowerCase();
return (
normalized.includes('browser didn\'t open') ||
normalized.includes('open this url') ||
normalized.includes('continue in your browser') ||
normalized.includes('press enter to open') ||
normalized.includes('open_url:')
);
}
export {
ANSI_ESCAPE_SEQUENCE_REGEX,
TRAILING_URL_PUNCTUATION_REGEX,
stripAnsiSequences,
normalizeDetectedUrl,
extractUrlsFromText,
shouldAutoOpenUrlFromOutput
};

View File

@@ -160,9 +160,6 @@ export default function ChatComposer({
(r) => r.toolName === 'AskUserQuestion'
);
// Hide the thinking/status bar while any permission request is pending
const hasPendingPermissions = pendingPermissionRequests.length > 0;
// On mobile, when input is focused, float the input box at the bottom
const mobileFloatingClass = isInputFocused
? 'max-sm:fixed max-sm:bottom-0 max-sm:left-0 max-sm:right-0 max-sm:z-50 max-sm:bg-background max-sm:shadow-[0_-4px_20px_rgba(0,0,0,0.15)]'
@@ -170,7 +167,7 @@ export default function ChatComposer({
return (
<div className={`flex-shrink-0 p-2 pb-2 sm:p-4 sm:pb-4 md:p-4 md:pb-6 ${mobileFloatingClass}`}>
{!hasPendingPermissions && (
{!hasQuestionPanel && (
<div className="flex-1">
<ClaudeStatus
status={claudeStatus}

View File

@@ -1,6 +1,7 @@
import React from "react";
import React, { useEffect, useMemo } from "react";
import { Check, ChevronDown } from "lucide-react";
import { useTranslation } from "react-i18next";
import { useServerPlatform } from "../../../../hooks/useServerPlatform";
import SessionProviderLogo from "../../../llm-logo-provider/SessionProviderLogo";
import {
CLAUDE_MODELS,
@@ -115,6 +116,23 @@ export default function ProviderSelectionEmptyState({
setInput,
}: ProviderSelectionEmptyStateProps) {
const { t } = useTranslation("chat");
const { isWindowsServer } = useServerPlatform();
const visibleProviders = useMemo(
() =>
isWindowsServer
? PROVIDERS.filter((p) => p.id !== "cursor")
: PROVIDERS,
[isWindowsServer],
);
useEffect(() => {
if (isWindowsServer && provider === "cursor") {
setProvider("claude");
localStorage.setItem("selected-provider", "claude");
}
}, [isWindowsServer, provider, setProvider]);
const nextTaskPrompt = t("tasks.nextTaskPrompt", {
defaultValue: "Start the next task",
});
@@ -166,8 +184,10 @@ export default function ProviderSelectionEmptyState({
</div>
{/* Provider cards — horizontal row, equal width */}
<div className="mb-6 grid grid-cols-2 gap-2 sm:grid-cols-4 sm:gap-2.5">
{PROVIDERS.map((p) => {
<div
className={`mb-6 grid grid-cols-2 gap-2 sm:gap-2.5 ${visibleProviders.length >= 4 ? "sm:grid-cols-4" : "sm:grid-cols-3"}`}
>
{visibleProviders.map((p) => {
const active = provider === p.id;
return (
<button

View File

@@ -0,0 +1,58 @@
import type { McpFormState, McpProvider, McpScope, McpTransport } from './types';
export const MCP_PROVIDER_NAMES: Record<McpProvider, string> = {
claude: 'Claude',
cursor: 'Cursor',
codex: 'Codex',
gemini: 'Gemini',
};
export const MCP_SUPPORTED_SCOPES: Record<McpProvider, McpScope[]> = {
claude: ['user', 'project', 'local'],
cursor: ['user', 'project'],
codex: ['user', 'project'],
gemini: ['user', 'project'],
};
export const MCP_SUPPORTED_TRANSPORTS: Record<McpProvider, McpTransport[]> = {
claude: ['stdio', 'http', 'sse'],
cursor: ['stdio', 'http'],
codex: ['stdio', 'http'],
gemini: ['stdio', 'http', 'sse'],
};
export const MCP_GLOBAL_SUPPORTED_SCOPES: McpScope[] = ['user', 'project'];
export const MCP_GLOBAL_SUPPORTED_TRANSPORTS: McpTransport[] = ['stdio', 'http'];
export const MCP_PROVIDER_BUTTON_CLASSES: Record<McpProvider, string> = {
claude: 'bg-purple-600 text-white hover:bg-purple-700',
cursor: 'bg-purple-600 text-white hover:bg-purple-700',
codex: 'bg-gray-800 text-white hover:bg-gray-900 dark:bg-gray-700 dark:hover:bg-gray-600',
gemini: 'bg-blue-600 text-white hover:bg-blue-700',
};
export const MCP_SUPPORTS_WORKING_DIRECTORY: Record<McpProvider, boolean> = {
claude: false,
cursor: false,
codex: true,
gemini: true,
};
export const DEFAULT_MCP_FORM: McpFormState = {
name: '',
scope: 'user',
workspacePath: '',
transport: 'stdio',
command: '',
args: [],
env: {},
cwd: '',
url: '',
headers: {},
envVars: [],
bearerTokenEnvVar: '',
envHttpHeaders: {},
importMode: 'form',
jsonInput: '',
};

View File

@@ -0,0 +1,248 @@
import { type FormEvent, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { DEFAULT_MCP_FORM, MCP_SUPPORTED_SCOPES, MCP_SUPPORTED_TRANSPORTS } from '../constants';
import type { McpFormState, McpProject, McpProvider, McpScope, McpTransport, ProviderMcpServer } from '../types';
import {
formatKeyValueLines,
getErrorMessage,
getProjectPath,
isMcpTransport,
parseKeyValueLines,
parseListLines,
} from '../utils/mcpFormatting';
type UseMcpServerFormArgs = {
provider: McpProvider;
isOpen: boolean;
editingServer: ProviderMcpServer | null;
currentProjects: McpProject[];
supportedScopes?: McpScope[];
supportedTransports?: McpTransport[];
unsupportedTransportMessage?: (transport: McpTransport) => string;
onSubmit: (formData: McpFormState, editingServer: ProviderMcpServer | null) => Promise<void>;
};
type MultilineFieldText = {
args: string;
env: string;
headers: string;
envVars: string;
envHttpHeaders: string;
};
const cloneDefaultForm = (
provider: McpProvider,
supportedScopes = MCP_SUPPORTED_SCOPES[provider],
supportedTransports = MCP_SUPPORTED_TRANSPORTS[provider],
): McpFormState => ({
...DEFAULT_MCP_FORM,
scope: supportedScopes[0],
transport: supportedTransports[0],
args: [],
env: {},
headers: {},
envVars: [],
envHttpHeaders: {},
});
const createFormStateFromServer = (
provider: McpProvider,
server: ProviderMcpServer,
supportedScopes?: McpScope[],
supportedTransports?: McpTransport[],
): McpFormState => ({
...cloneDefaultForm(provider, supportedScopes, supportedTransports),
name: server.name,
scope: server.scope,
workspacePath: server.workspacePath || '',
transport: server.transport,
command: server.command || '',
args: server.args || [],
env: server.env || {},
cwd: server.cwd || '',
url: server.url || '',
headers: server.headers || {},
envVars: server.envVars || [],
bearerTokenEnvVar: server.bearerTokenEnvVar || '',
envHttpHeaders: server.envHttpHeaders || {},
});
const createMultilineTextFromForm = (formData: McpFormState): MultilineFieldText => ({
args: formData.args.join('\n'),
env: formatKeyValueLines(formData.env),
headers: formatKeyValueLines(formData.headers),
envVars: formData.envVars.join('\n'),
envHttpHeaders: formatKeyValueLines(formData.envHttpHeaders),
});
const normalizeScope = (supportedScopes: McpScope[], value: McpScope): McpScope => (
supportedScopes.includes(value) ? value : supportedScopes[0]
);
const normalizeTransport = (supportedTransports: McpTransport[], value: McpTransport): McpTransport => (
supportedTransports.includes(value) ? value : supportedTransports[0]
);
export function useMcpServerForm({
provider,
isOpen,
editingServer,
currentProjects,
supportedScopes = MCP_SUPPORTED_SCOPES[provider],
supportedTransports = MCP_SUPPORTED_TRANSPORTS[provider],
unsupportedTransportMessage,
onSubmit,
}: UseMcpServerFormArgs) {
const { t } = useTranslation('settings');
const [formData, setFormData] = useState<McpFormState>(() => (
cloneDefaultForm(provider, supportedScopes, supportedTransports)
));
const [multilineText, setMultilineText] = useState<MultilineFieldText>(() => (
createMultilineTextFromForm(cloneDefaultForm(provider, supportedScopes, supportedTransports))
));
const [jsonValidationError, setJsonValidationError] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
const isEditing = Boolean(editingServer);
useEffect(() => {
if (!isOpen) {
return;
}
setJsonValidationError('');
if (editingServer) {
const nextFormData = createFormStateFromServer(provider, editingServer, supportedScopes, supportedTransports);
setFormData(nextFormData);
setMultilineText(createMultilineTextFromForm(nextFormData));
return;
}
const nextFormData = cloneDefaultForm(provider, supportedScopes, supportedTransports);
setFormData(nextFormData);
setMultilineText(createMultilineTextFromForm(nextFormData));
}, [editingServer, isOpen, provider, supportedScopes, supportedTransports]);
const projectOptions = useMemo(() => (
currentProjects
.map((project) => ({
value: getProjectPath(project),
label: project.displayName || project.name,
}))
.filter((project) => project.value)
), [currentProjects]);
const updateForm = <K extends keyof McpFormState>(key: K, value: McpFormState[K]) => {
setFormData((prev) => ({ ...prev, [key]: value }));
};
const updateScope = (scope: McpScope) => {
setFormData((prev) => ({
...prev,
scope: normalizeScope(supportedScopes, scope),
workspacePath: scope === 'user' ? '' : prev.workspacePath,
}));
};
const updateTransport = (transport: McpTransport) => {
setFormData((prev) => ({ ...prev, transport: normalizeTransport(supportedTransports, transport) }));
};
const validateJsonInput = (value: string) => {
if (!value.trim()) {
setJsonValidationError('');
return;
}
try {
const parsed = JSON.parse(value) as { type?: unknown; transport?: unknown; command?: unknown; url?: unknown };
const transportInput = parsed.transport || parsed.type;
if (!isMcpTransport(transportInput)) {
setJsonValidationError(t('mcpForm.validation.missingType'));
} else if (!supportedTransports.includes(transportInput)) {
setJsonValidationError(
unsupportedTransportMessage?.(transportInput) ?? `${provider} does not support ${transportInput} MCP servers`,
);
} else if (transportInput === 'stdio' && !parsed.command) {
setJsonValidationError(t('mcpForm.validation.stdioRequiresCommand'));
} else if ((transportInput === 'http' || transportInput === 'sse') && !parsed.url) {
setJsonValidationError(t('mcpForm.validation.httpRequiresUrl', { type: transportInput }));
} else {
setJsonValidationError('');
}
} catch {
setJsonValidationError(t('mcpForm.validation.invalidJson'));
}
};
const updateJsonInput = (value: string) => {
setFormData((prev) => ({ ...prev, jsonInput: value }));
validateJsonInput(value);
};
const updateMultilineText = <K extends keyof MultilineFieldText>(key: K, value: MultilineFieldText[K]) => {
setMultilineText((prev) => ({ ...prev, [key]: value }));
};
const createSubmitFormData = (): McpFormState => ({
...formData,
args: parseListLines(multilineText.args),
env: parseKeyValueLines(multilineText.env),
headers: parseKeyValueLines(multilineText.headers),
envVars: parseListLines(multilineText.envVars),
envHttpHeaders: parseKeyValueLines(multilineText.envHttpHeaders),
});
const canSubmit = useMemo(() => {
if (!formData.name.trim()) {
return false;
}
if (formData.scope !== 'user' && !formData.workspacePath.trim()) {
return false;
}
if (formData.importMode === 'json') {
return Boolean(formData.jsonInput.trim()) && !jsonValidationError;
}
if (formData.transport === 'stdio') {
return Boolean(formData.command.trim());
}
return Boolean(formData.url.trim());
}, [formData, jsonValidationError]);
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
setIsSubmitting(true);
try {
// Textareas keep raw strings while editing so users can create blank
// lines or partial KEY=value entries without the form rewriting them.
await onSubmit(createSubmitFormData(), editingServer);
} catch (error) {
alert(`Error: ${getErrorMessage(error)}`);
} finally {
setIsSubmitting(false);
}
};
return {
formData,
setFormData,
multilineText,
projectOptions,
isEditing,
isSubmitting,
jsonValidationError,
canSubmit,
updateForm,
updateScope,
updateTransport,
updateJsonInput,
updateMultilineText,
handleSubmit,
};
}

View File

@@ -0,0 +1,535 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { authenticatedFetch } from '../../../utils/api';
import { MCP_GLOBAL_SUPPORTED_TRANSPORTS, MCP_PROVIDER_NAMES, MCP_SUPPORTED_SCOPES } from '../constants';
import type {
ApiResponse,
GlobalMcpServerResult,
McpFormState,
McpProject,
McpProvider,
McpScope,
McpTransport,
ProviderMcpServer,
UpsertProviderMcpServerPayload,
} from '../types';
import {
createMcpPayloadFromForm,
getErrorMessage,
getProjectPath,
isMcpScope,
isMcpTransport,
} from '../utils/mcpFormatting';
type ProviderMcpServerResponse = {
provider: McpProvider;
scope: McpScope;
servers: Array<Partial<ProviderMcpServer>>;
};
type GlobalMcpServerResponse = {
results: GlobalMcpServerResult[];
};
type ProjectTarget = {
name: string;
displayName: string;
path: string;
};
type McpServersCacheEntry = {
servers: ProviderMcpServer[];
updatedAt: number;
};
type ScopedProjectRequest = {
scope: McpScope;
project: ProjectTarget;
};
const MCP_CACHE_TTL_MS = 30_000;
const mcpServersCache = new Map<string, McpServersCacheEntry>();
// Settings users often switch between provider tabs repeatedly. A short module
// cache prevents those tab switches from refetching every project config file.
const toResponseJson = async <T>(response: Response): Promise<T> => response.json() as Promise<T>;
const getApiErrorMessage = (payload: unknown, fallback: string): string => {
if (!payload || typeof payload !== 'object') {
return fallback;
}
const record = payload as Record<string, unknown>;
const error = record.error;
if (error && typeof error === 'object') {
const message = (error as Record<string, unknown>).message;
if (typeof message === 'string' && message.trim()) {
return message;
}
}
if (typeof error === 'string' && error.trim()) {
return error;
}
const details = record.details;
if (typeof details === 'string' && details.trim()) {
return details;
}
return fallback;
};
const normalizeTransport = (value: unknown, fallback: McpTransport = 'stdio'): McpTransport => (
isMcpTransport(value) ? value : fallback
);
const normalizeScope = (value: unknown, fallback: McpScope): McpScope => (
isMcpScope(value) ? value : fallback
);
const normalizeServer = (
provider: McpProvider,
scope: McpScope,
server: Partial<ProviderMcpServer>,
project?: ProjectTarget,
): ProviderMcpServer => {
const transport = normalizeTransport(server.transport, server.url ? 'http' : 'stdio');
return {
provider,
name: String(server.name ?? ''),
scope: normalizeScope(server.scope, scope),
transport,
command: server.command,
args: server.args ?? [],
env: server.env ?? {},
cwd: server.cwd,
url: server.url,
headers: server.headers ?? {},
envVars: server.envVars ?? [],
bearerTokenEnvVar: server.bearerTokenEnvVar,
envHttpHeaders: server.envHttpHeaders ?? {},
workspacePath: project?.path || server.workspacePath,
projectName: project?.name || server.projectName,
projectDisplayName: project?.displayName || server.projectDisplayName,
};
};
const createProjectTargets = (projects: McpProject[]): ProjectTarget[] => {
const seen = new Set<string>();
return projects.reduce<ProjectTarget[]>((acc, project) => {
const projectPath = getProjectPath(project);
if (!projectPath || seen.has(projectPath)) {
return acc;
}
seen.add(projectPath);
acc.push({
name: project.name,
displayName: project.displayName || project.name,
path: projectPath,
});
return acc;
}, []);
};
const fetchProviderScopeServers = async (
provider: McpProvider,
scope: McpScope,
project?: ProjectTarget,
): Promise<ProviderMcpServer[]> => {
const params = new URLSearchParams({ scope });
if (project?.path) {
params.set('workspacePath', project.path);
}
const response = await authenticatedFetch(`/api/providers/${provider}/mcp/servers?${params.toString()}`);
const data = await toResponseJson<ApiResponse<ProviderMcpServerResponse>>(response);
if (!response.ok || !data.success) {
throw new Error(getApiErrorMessage(data, `Failed to load ${provider} MCP servers`));
}
return (data.data.servers || []).map((server) => normalizeServer(provider, scope, server, project));
};
const deleteProviderServer = async (
provider: McpProvider,
server: ProviderMcpServer,
): Promise<void> => {
const params = new URLSearchParams({ scope: server.scope });
if (server.workspacePath) {
params.set('workspacePath', server.workspacePath);
}
const response = await authenticatedFetch(
`/api/providers/${provider}/mcp/servers/${encodeURIComponent(server.name)}?${params.toString()}`,
{ method: 'DELETE' },
);
const data = await toResponseJson<ApiResponse<{ removed: boolean }>>(response);
if (!response.ok || !data.success) {
throw new Error(getApiErrorMessage(data, 'Failed to delete MCP server'));
}
};
const saveProviderServer = async (
provider: McpProvider,
payload: UpsertProviderMcpServerPayload,
): Promise<void> => {
const response = await authenticatedFetch(`/api/providers/${provider}/mcp/servers`, {
method: 'POST',
body: JSON.stringify(payload),
});
const data = await toResponseJson<ApiResponse<{ server: ProviderMcpServer }>>(response);
if (!response.ok || !data.success) {
throw new Error(getApiErrorMessage(data, 'Failed to save MCP server'));
}
};
const saveGlobalServer = async (
payload: UpsertProviderMcpServerPayload,
): Promise<GlobalMcpServerResult[]> => {
const response = await authenticatedFetch('/api/providers/mcp/servers/global', {
method: 'POST',
body: JSON.stringify(payload),
});
const data = await toResponseJson<ApiResponse<GlobalMcpServerResponse>>(response);
if (!response.ok || !data.success) {
throw new Error(getApiErrorMessage(data, 'Failed to save MCP server to all providers'));
}
return data.data.results || [];
};
const didServerIdentityChange = (
editingServer: ProviderMcpServer,
payload: UpsertProviderMcpServerPayload,
): boolean => (
editingServer.name !== payload.name
|| editingServer.scope !== payload.scope
|| (editingServer.workspacePath || '') !== (payload.workspacePath || '')
);
const getServerIdentity = (server: ProviderMcpServer): string => (
`${server.provider}:${server.scope}:${server.workspacePath || 'global'}:${server.name}`
);
const getCacheKey = (provider: McpProvider, projects: ProjectTarget[]): string => {
const projectKey = projects.map((project) => project.path).sort().join('|');
return `${provider}:${projectKey}`;
};
const formatGlobalAddFailures = (failures: GlobalMcpServerResult[]): string => (
failures
.map((failure) => `${MCP_PROVIDER_NAMES[failure.provider]}: ${failure.error || 'Unknown error'}`)
.join('; ')
);
const sortServers = (servers: ProviderMcpServer[]): ProviderMcpServer[] => {
const scopeOrder: Record<McpScope, number> = {
user: 0,
project: 1,
local: 2,
};
return [...servers].sort((left, right) => {
const scopeDelta = scopeOrder[left.scope] - scopeOrder[right.scope];
if (scopeDelta !== 0) {
return scopeDelta;
}
const projectDelta = (left.projectDisplayName || '').localeCompare(right.projectDisplayName || '');
if (projectDelta !== 0) {
return projectDelta;
}
return left.name.localeCompare(right.name);
});
};
const mergeServers = (
existingServers: ProviderMcpServer[],
incomingServers: ProviderMcpServer[],
): ProviderMcpServer[] => {
const serversById = new Map<string, ProviderMcpServer>();
existingServers.forEach((server) => {
serversById.set(getServerIdentity(server), server);
});
incomingServers.forEach((server) => {
serversById.set(getServerIdentity(server), server);
});
return sortServers([...serversById.values()]);
};
const replaceScopedServers = (
existingServers: ProviderMcpServer[],
incomingServers: ProviderMcpServer[],
scope: McpScope,
workspacePath?: string,
): ProviderMcpServer[] => {
const remainingServers = existingServers.filter((server) => (
server.scope !== scope || (server.workspacePath || '') !== (workspacePath || '')
));
return mergeServers(remainingServers, incomingServers);
};
type UseMcpServersArgs = {
selectedProvider: McpProvider;
currentProjects: McpProject[];
};
export function useMcpServers({ selectedProvider, currentProjects }: UseMcpServersArgs) {
const [servers, setServers] = useState<ProviderMcpServer[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [loadError, setLoadError] = useState<string | null>(null);
const [deleteError, setDeleteError] = useState<string | null>(null);
const [saveStatus, setSaveStatus] = useState<'success' | 'error' | null>(null);
const [isLoadingProjectScopes, setIsLoadingProjectScopes] = useState(false);
const [isFormOpen, setIsFormOpen] = useState(false);
const [isGlobalFormOpen, setIsGlobalFormOpen] = useState(false);
const [editingServer, setEditingServer] = useState<ProviderMcpServer | null>(null);
const activeLoadIdRef = useRef(0);
const projectTargets = useMemo(() => createProjectTargets(currentProjects), [currentProjects]);
const cacheKey = useMemo(() => getCacheKey(selectedProvider, projectTargets), [projectTargets, selectedProvider]);
const refreshServers = useCallback(async (options: { force?: boolean } = {}) => {
const loadId = activeLoadIdRef.current + 1;
activeLoadIdRef.current = loadId;
const cachedEntry = mcpServersCache.get(cacheKey);
const canUseCache = !options.force && cachedEntry && Date.now() - cachedEntry.updatedAt < MCP_CACHE_TTL_MS;
if (canUseCache) {
setServers(cachedEntry.servers);
setIsLoading(false);
setIsLoadingProjectScopes(false);
setLoadError(null);
return;
}
if (cachedEntry && !options.force) {
setServers(cachedEntry.servers);
} else {
setServers([]);
}
setIsLoading(!cachedEntry);
setIsLoadingProjectScopes(false);
setLoadError(null);
const supportedScopes = MCP_SUPPORTED_SCOPES[selectedProvider];
let nextServers: ProviderMcpServer[] = cachedEntry && !options.force ? cachedEntry.servers : [];
let firstError: string | null = null;
// Load the global/user scope first so the visible list can paint quickly.
// Project and local scopes can involve many project config files, so they
// are appended below as background requests instead of blocking this render.
if (supportedScopes.includes('user')) {
try {
const userServers = await fetchProviderScopeServers(selectedProvider, 'user');
if (activeLoadIdRef.current !== loadId) {
return;
}
nextServers = replaceScopedServers(nextServers, userServers, 'user');
setServers(sortServers(nextServers));
} catch (error) {
firstError = getErrorMessage(error);
}
}
if (activeLoadIdRef.current !== loadId) {
return;
}
setIsLoading(false);
const projectScopeRequests: ScopedProjectRequest[] = [];
projectTargets.forEach((project) => {
if (supportedScopes.includes('project')) {
projectScopeRequests.push({ scope: 'project', project });
}
if (supportedScopes.includes('local')) {
projectScopeRequests.push({ scope: 'local', project });
}
});
if (projectScopeRequests.length === 0) {
const finalServers = sortServers(nextServers);
mcpServersCache.set(cacheKey, { servers: finalServers, updatedAt: Date.now() });
setLoadError(firstError);
return;
}
setIsLoadingProjectScopes(true);
// Update the UI as each project scope resolves. This avoids waiting for the
// slowest project before showing servers from faster config files.
await Promise.all(projectScopeRequests.map(async ({ scope, project }) => {
try {
const scopedServers = await fetchProviderScopeServers(selectedProvider, scope, project);
if (activeLoadIdRef.current !== loadId) {
return;
}
nextServers = replaceScopedServers(nextServers, scopedServers, scope, project.path);
setServers(nextServers);
} catch (error) {
firstError = firstError || getErrorMessage(error);
}
}));
if (activeLoadIdRef.current !== loadId) {
return;
}
const finalServers = sortServers(nextServers);
mcpServersCache.set(cacheKey, { servers: finalServers, updatedAt: Date.now() });
setServers(finalServers);
setLoadError(firstError);
setIsLoadingProjectScopes(false);
}, [cacheKey, projectTargets, selectedProvider]);
const openForm = useCallback((server?: ProviderMcpServer) => {
setEditingServer(server || null);
setIsFormOpen(true);
}, []);
const closeForm = useCallback(() => {
setIsFormOpen(false);
setEditingServer(null);
}, []);
const openGlobalForm = useCallback(() => {
setIsGlobalFormOpen(true);
}, []);
const closeGlobalForm = useCallback(() => {
setIsGlobalFormOpen(false);
}, []);
const submitForm = useCallback(
async (formData: McpFormState, serverBeingEdited: ProviderMcpServer | null) => {
const payload = createMcpPayloadFromForm(selectedProvider, formData);
if (payload.scope !== 'user' && !payload.workspacePath) {
throw new Error('Select a project for project-scoped MCP servers');
}
await saveProviderServer(selectedProvider, payload);
if (serverBeingEdited && didServerIdentityChange(serverBeingEdited, payload)) {
await deleteProviderServer(selectedProvider, serverBeingEdited);
}
mcpServersCache.delete(cacheKey);
await refreshServers({ force: true });
setSaveStatus('success');
closeForm();
},
[cacheKey, closeForm, refreshServers, selectedProvider],
);
const submitGlobalForm = useCallback(
async (formData: McpFormState) => {
const payload = createMcpPayloadFromForm(selectedProvider, formData, {
supportedTransports: MCP_GLOBAL_SUPPORTED_TRANSPORTS,
supportsWorkingDirectory: false,
includeProviderSpecificFields: false,
unsupportedTransportMessage: (transport) =>
`Add MCP Server supports only stdio and http across all providers, not ${transport}.`,
});
if (payload.scope === 'local') {
throw new Error('Add MCP Server supports only user or project scope across all providers.');
}
if (payload.scope !== 'user' && !payload.workspacePath) {
throw new Error('Select a project for project-scoped MCP servers');
}
// The global endpoint updates every provider, so clear every provider
// cache entry instead of only the currently visible provider tab.
const results = await saveGlobalServer(payload);
mcpServersCache.clear();
await refreshServers({ force: true });
const failures = results.filter((result) => !result.created);
if (failures.length > 0) {
setSaveStatus('error');
throw new Error(`Failed to add MCP server to all providers. ${formatGlobalAddFailures(failures)}`);
}
setSaveStatus('success');
closeGlobalForm();
},
[closeGlobalForm, refreshServers, selectedProvider],
);
const deleteServer = useCallback(
async (server: ProviderMcpServer) => {
if (!window.confirm('Are you sure you want to delete this MCP server?')) {
return;
}
setDeleteError(null);
try {
await deleteProviderServer(selectedProvider, server);
mcpServersCache.delete(cacheKey);
await refreshServers({ force: true });
setSaveStatus('success');
} catch (error) {
setDeleteError(getErrorMessage(error));
setSaveStatus('error');
}
},
[cacheKey, refreshServers, selectedProvider],
);
useEffect(() => {
void refreshServers();
}, [refreshServers]);
useEffect(() => {
setIsFormOpen(false);
setIsGlobalFormOpen(false);
setEditingServer(null);
setDeleteError(null);
setSaveStatus(null);
}, [selectedProvider]);
useEffect(() => {
if (saveStatus === null) {
return;
}
const timer = window.setTimeout(() => setSaveStatus(null), 2000);
return () => window.clearTimeout(timer);
}, [saveStatus]);
return {
servers,
isLoading,
isLoadingProjectScopes,
loadError,
deleteError,
saveStatus,
isFormOpen,
isGlobalFormOpen,
editingServer,
openForm,
openGlobalForm,
closeForm,
closeGlobalForm,
submitForm,
submitGlobalForm,
deleteServer,
refreshServers,
};
}

View File

@@ -0,0 +1 @@
export { default as McpServers } from './view/McpServers';

View File

@@ -0,0 +1,90 @@
import type { LLMProvider } from '../../types/app';
export type McpProvider = LLMProvider;
export type McpScope = 'user' | 'local' | 'project';
export type McpTransport = 'stdio' | 'http' | 'sse';
export type McpImportMode = 'form' | 'json';
export type McpFormMode = 'provider' | 'global';
export type KeyValueMap = Record<string, string>;
export type McpProject = {
name: string;
displayName?: string;
fullPath?: string;
path?: string;
};
export type ProviderMcpServer = {
provider: McpProvider;
name: string;
scope: McpScope;
transport: McpTransport;
command?: string;
args?: string[];
env?: KeyValueMap;
cwd?: string;
url?: string;
headers?: KeyValueMap;
envVars?: string[];
bearerTokenEnvVar?: string;
envHttpHeaders?: KeyValueMap;
workspacePath?: string;
projectName?: string;
projectDisplayName?: string;
};
export type McpFormState = {
name: string;
scope: McpScope;
workspacePath: string;
transport: McpTransport;
command: string;
args: string[];
env: KeyValueMap;
cwd: string;
url: string;
headers: KeyValueMap;
envVars: string[];
bearerTokenEnvVar: string;
envHttpHeaders: KeyValueMap;
importMode: McpImportMode;
jsonInput: string;
};
export type UpsertProviderMcpServerPayload = {
name: string;
scope: McpScope;
transport: McpTransport;
workspacePath?: string;
command?: string;
args?: string[];
env?: KeyValueMap;
cwd?: string;
url?: string;
headers?: KeyValueMap;
envVars?: string[];
bearerTokenEnvVar?: string;
envHttpHeaders?: KeyValueMap;
};
export type GlobalMcpServerResult = {
provider: McpProvider;
created: boolean;
error?: string;
};
export type ApiSuccessResponse<T> = {
success: true;
data: T;
};
export type ApiErrorResponse = {
success: false;
error?: {
code?: string;
message?: string;
details?: unknown;
};
};
export type ApiResponse<T> = ApiSuccessResponse<T> | ApiErrorResponse;

View File

@@ -0,0 +1,184 @@
import { MCP_SUPPORTED_TRANSPORTS, MCP_SUPPORTS_WORKING_DIRECTORY } from '../constants';
import type {
KeyValueMap,
McpFormState,
McpProvider,
McpScope,
McpTransport,
UpsertProviderMcpServerPayload,
} from '../types';
type CreateMcpPayloadOptions = {
supportedTransports?: McpTransport[];
supportsWorkingDirectory?: boolean;
includeProviderSpecificFields?: boolean;
unsupportedTransportMessage?: (transport: McpTransport) => string;
};
const isRecord = (value: unknown): value is Record<string, unknown> => (
Boolean(value) && typeof value === 'object' && !Array.isArray(value)
);
const readString = (value: unknown): string | undefined => (
typeof value === 'string' && value.trim() ? value.trim() : undefined
);
const readStringArray = (value: unknown): string[] | undefined => (
Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === 'string') : undefined
);
const readStringRecord = (value: unknown): KeyValueMap | undefined => {
if (!isRecord(value)) {
return undefined;
}
const normalized: KeyValueMap = {};
Object.entries(value).forEach(([key, entry]) => {
if (typeof entry === 'string') {
normalized[key] = entry;
}
});
return Object.keys(normalized).length > 0 ? normalized : undefined;
};
export const formatKeyValueLines = (value: KeyValueMap): string => (
Object.entries(value).map(([key, entry]) => `${key}=${entry}`).join('\n')
);
export const parseKeyValueLines = (value: string): KeyValueMap => {
const normalized: KeyValueMap = {};
value.split('\n').forEach((line) => {
const [key, ...valueParts] = line.split('=');
if (key?.trim()) {
normalized[key.trim()] = valueParts.join('=').trim();
}
});
return normalized;
};
export const parseListLines = (value: string): string[] => (
value.split('\n').map((entry) => entry.trim()).filter(Boolean)
);
export const maskSecret = (value: unknown): string => {
const normalizedValue = String(value ?? '');
if (normalizedValue.length <= 4) {
return '****';
}
return `${normalizedValue.slice(0, 2)}****${normalizedValue.slice(-2)}`;
};
export const isMcpScope = (value: unknown): value is McpScope => (
value === 'user' || value === 'local' || value === 'project'
);
export const isMcpTransport = (value: unknown): value is McpTransport => (
value === 'stdio' || value === 'http' || value === 'sse'
);
export const getProjectPath = (project: { fullPath?: string; path?: string }): string => (
project.fullPath || project.path || ''
);
export const getErrorMessage = (error: unknown): string => (
error instanceof Error ? error.message : 'Unknown error'
);
const assertSupportedTransport = (
provider: McpProvider,
transport: McpTransport,
options?: CreateMcpPayloadOptions,
) => {
const supportedTransports = options?.supportedTransports ?? MCP_SUPPORTED_TRANSPORTS[provider];
if (supportedTransports.includes(transport)) {
return;
}
throw new Error(
options?.unsupportedTransportMessage?.(transport) ?? `${provider} does not support ${transport} MCP servers`,
);
};
export const parseJsonMcpPayload = (
provider: McpProvider,
formData: McpFormState,
options?: CreateMcpPayloadOptions,
): UpsertProviderMcpServerPayload => {
const parsed = JSON.parse(formData.jsonInput) as unknown;
if (!isRecord(parsed)) {
throw new Error('JSON configuration must be an object');
}
const transportInput = readString(parsed.transport) ?? readString(parsed.type);
const transport = isMcpTransport(transportInput) ? transportInput : undefined;
if (!transport) {
throw new Error('Missing required field: type');
}
assertSupportedTransport(provider, transport, options);
if (transport === 'stdio' && !readString(parsed.command)) {
throw new Error('stdio type requires a command field');
}
if ((transport === 'http' || transport === 'sse') && !readString(parsed.url)) {
throw new Error(`${transport} type requires a url field`);
}
return {
name: formData.name.trim(),
scope: formData.scope,
workspacePath: formData.scope === 'user' ? undefined : formData.workspacePath,
transport,
command: readString(parsed.command),
args: readStringArray(parsed.args) ?? [],
env: readStringRecord(parsed.env) ?? {},
cwd: (options?.supportsWorkingDirectory ?? MCP_SUPPORTS_WORKING_DIRECTORY[provider])
? readString(parsed.cwd)
: undefined,
url: readString(parsed.url),
headers: readStringRecord(parsed.headers ?? parsed.http_headers) ?? {},
envVars: (options?.includeProviderSpecificFields ?? provider === 'codex')
? readStringArray(parsed.envVars ?? parsed.env_vars) ?? []
: undefined,
bearerTokenEnvVar: (options?.includeProviderSpecificFields ?? provider === 'codex')
? readString(parsed.bearerTokenEnvVar ?? parsed.bearer_token_env_var)
: undefined,
envHttpHeaders: (options?.includeProviderSpecificFields ?? provider === 'codex')
? readStringRecord(parsed.envHttpHeaders ?? parsed.env_http_headers) ?? {}
: undefined,
};
};
export const createMcpPayloadFromForm = (
provider: McpProvider,
formData: McpFormState,
options?: CreateMcpPayloadOptions,
): UpsertProviderMcpServerPayload => {
if (formData.importMode === 'json') {
return parseJsonMcpPayload(provider, formData, options);
}
assertSupportedTransport(provider, formData.transport, options);
const supportsWorkingDirectory = options?.supportsWorkingDirectory ?? MCP_SUPPORTS_WORKING_DIRECTORY[provider];
const includeProviderSpecificFields = options?.includeProviderSpecificFields ?? provider === 'codex';
return {
name: formData.name.trim(),
scope: formData.scope,
workspacePath: formData.scope === 'user' ? undefined : formData.workspacePath,
transport: formData.transport,
command: formData.transport === 'stdio' ? formData.command.trim() : undefined,
args: formData.transport === 'stdio' ? formData.args : undefined,
env: formData.env,
cwd: supportsWorkingDirectory ? formData.cwd.trim() || undefined : undefined,
url: formData.transport !== 'stdio' ? formData.url.trim() : undefined,
headers: formData.transport !== 'stdio' ? formData.headers : undefined,
envVars: includeProviderSpecificFields ? formData.envVars : undefined,
bearerTokenEnvVar: includeProviderSpecificFields ? formData.bearerTokenEnvVar.trim() || undefined : undefined,
envHttpHeaders: includeProviderSpecificFields ? formData.envHttpHeaders : undefined,
};
};

View File

@@ -0,0 +1,281 @@
import { Edit3, ExternalLink, Globe, Lock, Plus, Server, Terminal, Trash2, Users, Zap } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import type { McpProject, McpProvider, McpScope, ProviderMcpServer } from '../types';
import { IS_PLATFORM } from '../../../constants/config';
import { Badge, Button } from '../../../shared/view/ui';
import {
MCP_GLOBAL_SUPPORTED_SCOPES,
MCP_GLOBAL_SUPPORTED_TRANSPORTS,
MCP_PROVIDER_BUTTON_CLASSES,
MCP_PROVIDER_NAMES,
} from '../constants';
import { useMcpServers } from '../hooks/useMcpServers';
import { maskSecret } from '../utils/mcpFormatting';
import McpServerFormModal from './modals/McpServerFormModal';
type McpServersProps = {
selectedProvider: McpProvider;
currentProjects: McpProject[];
};
const getTransportIcon = (transport: string | undefined) => {
if (transport === 'stdio') {
return <Terminal className="h-4 w-4" />;
}
if (transport === 'sse') {
return <Zap className="h-4 w-4" />;
}
if (transport === 'http') {
return <Globe className="h-4 w-4" />;
}
return <Server className="h-4 w-4" />;
};
const getScopeLabel = (scope: McpScope): string => {
if (scope === 'user') {
return 'user';
}
if (scope === 'local') {
return 'local';
}
return 'project';
};
const getServerKey = (server: ProviderMcpServer): string => (
`${server.provider}:${server.scope}:${server.workspacePath || 'global'}:${server.name}`
);
function ConfigLine({ label, children }: { label: string; children: string }) {
if (!children) {
return null;
}
return (
<div>
{label}:{' '}
<code className="rounded bg-muted px-1 text-xs">{children}</code>
</div>
);
}
function TeamMcpFeatureCard() {
return (
<div className="rounded-xl border border-dashed border-border/60 bg-muted/20 p-5">
<div className="flex items-start gap-3">
<div className="flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-lg bg-muted/60 text-muted-foreground">
<Users className="h-5 w-5" />
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<h4 className="text-sm font-medium text-foreground">Team MCP Configs</h4>
<Lock className="h-3 w-3 text-muted-foreground/60" />
</div>
<p className="mt-1 text-xs leading-relaxed text-muted-foreground">
Share MCP server configurations across your team. Everyone stays in sync automatically.
</p>
<a
href="https://cloudcli.ai"
target="_blank"
rel="noopener noreferrer"
className="mt-3 inline-flex items-center gap-1 text-xs font-medium text-primary transition-colors hover:underline"
>
Available with CloudCLI Pro
<ExternalLink className="h-3 w-3" />
</a>
</div>
</div>
</div>
);
}
export default function McpServers({ selectedProvider, currentProjects }: McpServersProps) {
const { t } = useTranslation('settings');
const {
servers,
isLoading,
isLoadingProjectScopes,
loadError,
deleteError,
saveStatus,
isFormOpen,
isGlobalFormOpen,
editingServer,
openForm,
openGlobalForm,
closeForm,
closeGlobalForm,
submitForm,
submitGlobalForm,
deleteServer,
} = useMcpServers({ selectedProvider, currentProjects });
const providerName = MCP_PROVIDER_NAMES[selectedProvider];
const description = t(`mcpServers.description.${selectedProvider}`, {
defaultValue: `Model Context Protocol servers provide additional tools and data sources to ${providerName}`,
});
const globalButtonLabel = 'Add Global MCP Server';
const providerButtonLabel = `Add ${providerName} MCP Server`;
const globalAddDescription = 'Add Global MCP Server writes one common stdio or HTTP server to Claude, Cursor, Codex, and Gemini.';
const providerAddDescription = `${providerButtonLabel} only changes ${providerName}.`;
const globalModalDescription = 'Adds this MCP server to every provider: Claude, Cursor, Codex, and Gemini. '
+ 'Only stdio and HTTP transports are supported because the same config must work across all providers.';
return (
<div className="space-y-4">
<div className="flex items-center gap-3">
<Server className="h-5 w-5 text-purple-500" />
<h3 className="text-lg font-medium text-foreground">{t('mcpServers.title')}</h3>
</div>
<p className="text-sm text-muted-foreground">{description}</p>
<div className="space-y-2">
<div className="flex flex-wrap items-center gap-2">
<Button
onClick={openGlobalForm}
className={MCP_PROVIDER_BUTTON_CLASSES[selectedProvider]}
size="sm"
title={globalAddDescription}
>
<Plus className="mr-2 h-4 w-4" />
{globalButtonLabel}
</Button>
<Button
onClick={() => openForm()}
variant="outline"
size="sm"
title={providerAddDescription}
>
<Plus className="mr-2 h-4 w-4" />
{providerButtonLabel}
</Button>
</div>
<div className="min-h-4">
{saveStatus === 'success' && (
<span className="animate-in fade-in text-xs text-muted-foreground">{t('saveStatus.success')}</span>
)}
{isLoadingProjectScopes && (
<span className="animate-in fade-in text-xs text-muted-foreground">Refreshing project scopes...</span>
)}
</div>
</div>
{(loadError || deleteError) && (
<div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700 dark:border-red-800/60 dark:bg-red-900/20 dark:text-red-200">
{deleteError || loadError}
</div>
)}
<div className="space-y-2">
{isLoading && servers.length === 0 && (
<div className="py-8 text-center text-muted-foreground">Loading MCP servers...</div>
)}
{servers.map((server) => (
<div key={getServerKey(server)} className="rounded-lg border border-border bg-card/50 p-4">
<div className="flex items-start justify-between">
<div className="min-w-0 flex-1">
<div className="mb-2 flex flex-wrap items-center gap-2">
{getTransportIcon(server.transport)}
<span className="font-medium text-foreground">{server.name}</span>
<Badge variant="outline" className="text-xs">
{server.transport || 'stdio'}
</Badge>
<Badge variant="outline" className="text-xs">
{getScopeLabel(server.scope)}
</Badge>
{server.projectDisplayName && (
<Badge variant="outline" className="max-w-full truncate text-xs">
{server.projectDisplayName}
</Badge>
)}
</div>
<div className="space-y-1 text-sm text-muted-foreground">
<ConfigLine label={t('mcpServers.config.command')}>{server.command || ''}</ConfigLine>
<ConfigLine label={t('mcpServers.config.url')}>{server.url || ''}</ConfigLine>
<ConfigLine label={t('mcpServers.config.args')}>{(server.args || []).join(' ')}</ConfigLine>
<ConfigLine label="Cwd">{server.cwd || ''}</ConfigLine>
{server.env && Object.keys(server.env).length > 0 && (
<ConfigLine label={t('mcpServers.config.environment')}>
{Object.entries(server.env).map(([key, value]) => `${key}=${maskSecret(value)}`).join(', ')}
</ConfigLine>
)}
{server.envVars && server.envVars.length > 0 && (
<ConfigLine label="Env Vars">{server.envVars.join(', ')}</ConfigLine>
)}
</div>
</div>
<div className="ml-4 flex items-center gap-2">
<Button
onClick={() => openForm(server)}
variant="ghost"
size="sm"
className="text-muted-foreground hover:text-foreground"
title={t('mcpServers.actions.edit')}
>
<Edit3 className="h-4 w-4" />
</Button>
<Button
onClick={() => deleteServer(server)}
variant="ghost"
size="sm"
className="text-red-600 hover:text-red-700"
title={t('mcpServers.actions.delete')}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
</div>
))}
{!isLoading && !isLoadingProjectScopes && servers.length === 0 && (
<div className="py-8 text-center text-muted-foreground">{t('mcpServers.empty')}</div>
)}
</div>
{selectedProvider === 'codex' && (
<div className="rounded-lg border border-border bg-muted/50 p-4">
<h4 className="mb-2 font-medium text-foreground">{t('mcpServers.help.title')}</h4>
<p className="text-sm text-muted-foreground">{t('mcpServers.help.description')}</p>
</div>
)}
{selectedProvider === 'claude' && !IS_PLATFORM && <TeamMcpFeatureCard />}
<McpServerFormModal
provider={selectedProvider}
isOpen={isFormOpen}
editingServer={editingServer}
currentProjects={currentProjects}
title={editingServer ? undefined : providerButtonLabel}
submitLabel={providerButtonLabel}
onClose={closeForm}
onSubmit={submitForm}
/>
<McpServerFormModal
provider={selectedProvider}
mode="global"
isOpen={isGlobalFormOpen}
editingServer={null}
currentProjects={currentProjects}
title={globalButtonLabel}
description={globalModalDescription}
submitLabel={globalButtonLabel}
supportedScopes={MCP_GLOBAL_SUPPORTED_SCOPES}
supportedTransports={MCP_GLOBAL_SUPPORTED_TRANSPORTS}
onClose={closeGlobalForm}
onSubmit={(formData) => submitGlobalForm(formData)}
/>
</div>
);
}

View File

@@ -0,0 +1,434 @@
import { FolderOpen, Globe, X } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Button, Input } from '../../../../shared/view/ui';
import {
MCP_PROVIDER_NAMES,
MCP_SUPPORTED_SCOPES,
MCP_SUPPORTED_TRANSPORTS,
MCP_SUPPORTS_WORKING_DIRECTORY,
} from '../../constants';
import { useMcpServerForm } from '../../hooks/useMcpServerForm';
import type {
McpFormMode,
McpFormState,
McpProject,
McpProvider,
McpScope,
McpTransport,
ProviderMcpServer,
} from '../../types';
type McpServerFormModalProps = {
provider: McpProvider;
mode?: McpFormMode;
isOpen: boolean;
editingServer: ProviderMcpServer | null;
currentProjects: McpProject[];
title?: string;
description?: string;
submitLabel?: string;
supportedScopes?: McpScope[];
supportedTransports?: McpTransport[];
onClose: () => void;
onSubmit: (formData: McpFormState, editingServer: ProviderMcpServer | null) => Promise<void>;
};
const getScopeLabel = (scope: McpScope, mode: McpFormMode): string => {
if (scope === 'user') {
return mode === 'global' ? 'User (All Providers)' : 'User (Global)';
}
if (scope === 'local') {
return 'Claude Local';
}
return mode === 'global' ? 'Project (All Providers)' : 'Project';
};
const getScopeDescription = (scope: McpScope, mode: McpFormMode): string => {
if (scope === 'user') {
return mode === 'global'
? 'Writes to each provider user config and is available across projects on this machine'
: 'Available across all projects on your machine';
}
if (scope === 'local') {
return 'Stored in Claude user settings for the selected project';
}
return mode === 'global'
? 'Writes to the selected project workspace for every provider'
: 'Stored in the selected project workspace';
};
export default function McpServerFormModal({
provider,
mode = 'provider',
isOpen,
editingServer,
currentProjects,
title,
description,
submitLabel,
supportedScopes,
supportedTransports,
onClose,
onSubmit,
}: McpServerFormModalProps) {
const { t } = useTranslation('settings');
const isGlobalMode = mode === 'global';
const availableScopes = supportedScopes ?? MCP_SUPPORTED_SCOPES[provider];
const availableTransports = supportedTransports ?? MCP_SUPPORTED_TRANSPORTS[provider];
const {
formData,
multilineText,
projectOptions,
isEditing,
isSubmitting,
jsonValidationError,
canSubmit,
updateForm,
updateScope,
updateTransport,
updateJsonInput,
updateMultilineText,
handleSubmit,
} = useMcpServerForm({
provider,
isOpen,
editingServer,
currentProjects,
supportedScopes: availableScopes,
supportedTransports: availableTransports,
unsupportedTransportMessage: isGlobalMode
? (transport) => `Add MCP Server supports only stdio and http across all providers, not ${transport}.`
: undefined,
onSubmit,
});
if (!isOpen) {
return null;
}
const providerName = MCP_PROVIDER_NAMES[provider];
const modalTitle = title ?? (isEditing ? t('mcpForm.title.edit') : t('mcpForm.title.add'));
const addButtonLabel = submitLabel ?? `${t('mcpForm.actions.addServer')} to ${providerName}`;
const showProjectSelector = formData.scope !== 'user';
const supportsHttpHeaders = formData.transport === 'http' || formData.transport === 'sse';
const supportsWorkingDirectory = !isGlobalMode && MCP_SUPPORTS_WORKING_DIRECTORY[provider];
const showCodexOnlyFields = provider === 'codex' && !isGlobalMode;
return (
<div className="fixed inset-0 z-[110] flex items-center justify-center bg-black/50 p-4">
<div className="max-h-[90vh] w-full max-w-2xl overflow-y-auto rounded-lg border border-border bg-background">
<div className="flex items-center justify-between border-b border-border p-4">
<h3 className="text-lg font-medium text-foreground">{modalTitle}</h3>
<Button variant="ghost" size="sm" onClick={onClose}>
<X className="h-4 w-4" />
</Button>
</div>
<form onSubmit={handleSubmit} className="space-y-4 p-4">
{description && (
<div className="rounded-lg border border-border bg-muted/40 px-3 py-2 text-sm text-muted-foreground">
{description}
</div>
)}
{!isEditing && (
<div className="mb-4 flex gap-2">
<button
type="button"
onClick={() => updateForm('importMode', 'form')}
className={`rounded-lg px-4 py-2 font-medium transition-colors ${
formData.importMode === 'form'
? 'bg-blue-600 text-white'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700'
}`}
>
{t('mcpForm.importMode.form')}
</button>
<button
type="button"
onClick={() => updateForm('importMode', 'json')}
className={`rounded-lg px-4 py-2 font-medium transition-colors ${
formData.importMode === 'json'
? 'bg-blue-600 text-white'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700'
}`}
>
{t('mcpForm.importMode.json')}
</button>
</div>
)}
{isEditing && (
<div className="rounded-lg border border-gray-200 bg-gray-50 p-3 dark:border-gray-700 dark:bg-gray-900/50">
<label className="mb-2 block text-sm font-medium text-foreground">
{t('mcpForm.scope.label')}
</label>
<div className="flex items-center gap-2">
{formData.scope === 'user' ? <Globe className="h-4 w-4" /> : <FolderOpen className="h-4 w-4" />}
<span className="text-sm">{getScopeLabel(formData.scope, mode)}</span>
{formData.workspacePath && (
<span className="truncate text-xs text-muted-foreground">- {formData.workspacePath}</span>
)}
</div>
<p className="mt-2 text-xs text-muted-foreground">{t('mcpForm.scope.cannotChange')}</p>
</div>
)}
{!isEditing && (
<div className="space-y-4">
<div>
<label className="mb-2 block text-sm font-medium text-foreground">
{t('mcpForm.scope.label')} *
</label>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
{availableScopes.map((scope) => (
<button
key={scope}
type="button"
onClick={() => updateScope(scope)}
className={`rounded-lg px-4 py-2 font-medium transition-colors ${
formData.scope === scope
? 'bg-blue-600 text-white'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700'
}`}
>
<div className="flex items-center justify-center gap-2">
{scope === 'user' ? <Globe className="h-4 w-4" /> : <FolderOpen className="h-4 w-4" />}
<span>{getScopeLabel(scope, mode)}</span>
</div>
</button>
))}
</div>
<p className="mt-2 text-xs text-muted-foreground">{getScopeDescription(formData.scope, mode)}</p>
</div>
{showProjectSelector && (
<div>
<label className="mb-2 block text-sm font-medium text-foreground">
{t('mcpForm.fields.selectProject')} *
</label>
<select
value={formData.workspacePath}
onChange={(event) => updateForm('workspacePath', event.target.value)}
className="w-full rounded-lg border border-gray-300 bg-gray-50 px-3 py-2 text-gray-900 focus:border-blue-500 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100"
required
>
<option value="">{t('mcpForm.fields.selectProject')}</option>
{projectOptions.map((project) => (
<option key={project.value} value={project.value}>
{project.label}
</option>
))}
</select>
{formData.workspacePath && (
<p className="mt-1 truncate text-xs text-muted-foreground">
{t('mcpForm.projectPath', { path: formData.workspacePath })}
</p>
)}
</div>
)}
</div>
)}
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div className={formData.importMode === 'json' ? 'md:col-span-2' : ''}>
<label className="mb-2 block text-sm font-medium text-foreground">
{t('mcpForm.fields.serverName')} *
</label>
<Input
value={formData.name}
onChange={(event) => updateForm('name', event.target.value)}
placeholder={t('mcpForm.placeholders.serverName')}
required
/>
</div>
{formData.importMode === 'form' && (
<div>
<label className="mb-2 block text-sm font-medium text-foreground">
{t('mcpForm.fields.transportType')} *
</label>
<select
value={formData.transport}
onChange={(event) => updateTransport(event.target.value as McpFormState['transport'])}
className="w-full rounded-lg border border-gray-300 bg-gray-50 px-3 py-2 text-gray-900 focus:border-blue-500 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100"
>
{availableTransports.map((transport) => (
<option key={transport} value={transport}>
{transport === 'sse' ? 'SSE' : transport.toUpperCase()}
</option>
))}
</select>
</div>
)}
</div>
{formData.importMode === 'json' && (
<div>
<label className="mb-2 block text-sm font-medium text-foreground">
{t('mcpForm.fields.jsonConfig')} *
</label>
<textarea
value={formData.jsonInput}
onChange={(event) => updateJsonInput(event.target.value)}
className={`w-full border px-3 py-2 ${
jsonValidationError ? 'border-red-500' : 'border-gray-300 dark:border-gray-600'
} rounded-lg bg-gray-50 font-mono text-sm text-gray-900 focus:border-blue-500 focus:ring-blue-500 dark:bg-gray-800 dark:text-gray-100`}
rows={8}
placeholder={'{\n "type": "stdio",\n "command": "npx",\n "args": ["@upstash/context7-mcp"]\n}'}
required
/>
{jsonValidationError && (
<p className="mt-1 text-xs text-red-500">{jsonValidationError}</p>
)}
<p className="mt-2 text-xs text-muted-foreground">
{t('mcpForm.validation.jsonHelp')}
<br />
- stdio: {`{"type":"stdio","command":"npx","args":["@upstash/context7-mcp"]}`}
<br />
- http/sse: {`{"type":"http","url":"https://api.example.com/mcp"}`}
</p>
</div>
)}
{formData.importMode === 'form' && formData.transport === 'stdio' && (
<div className="space-y-4">
<div>
<label className="mb-2 block text-sm font-medium text-foreground">
{t('mcpForm.fields.command')} *
</label>
<Input
value={formData.command}
onChange={(event) => updateForm('command', event.target.value)}
placeholder="npx @my-org/mcp-server"
required
/>
</div>
<div>
<label className="mb-2 block text-sm font-medium text-foreground">
{t('mcpForm.fields.arguments')}
</label>
<textarea
value={multilineText.args}
onChange={(event) => updateMultilineText('args', event.target.value)}
className="w-full rounded-lg border border-gray-300 bg-gray-50 px-3 py-2 text-gray-900 focus:border-blue-500 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100"
rows={3}
placeholder="--port&#10;3000"
/>
</div>
{supportsWorkingDirectory && (
<div>
<label className="mb-2 block text-sm font-medium text-foreground">
Working Directory
</label>
<Input
value={formData.cwd}
onChange={(event) => updateForm('cwd', event.target.value)}
placeholder="."
/>
</div>
)}
</div>
)}
{formData.importMode === 'form' && formData.transport !== 'stdio' && (
<div>
<label className="mb-2 block text-sm font-medium text-foreground">
{t('mcpForm.fields.url')} *
</label>
<Input
value={formData.url}
onChange={(event) => updateForm('url', event.target.value)}
placeholder="https://api.example.com/mcp"
type="url"
required
/>
</div>
)}
{formData.importMode === 'form' && (
<div>
<label className="mb-2 block text-sm font-medium text-foreground">
{t('mcpForm.fields.envVars')}
</label>
<textarea
value={multilineText.env}
onChange={(event) => updateMultilineText('env', event.target.value)}
className="w-full rounded-lg border border-gray-300 bg-gray-50 px-3 py-2 text-gray-900 focus:border-blue-500 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100"
rows={3}
placeholder="API_KEY=your-key&#10;DEBUG=true"
/>
</div>
)}
{formData.importMode === 'form' && supportsHttpHeaders && (
<div>
<label className="mb-2 block text-sm font-medium text-foreground">
{t('mcpForm.fields.headers')}
</label>
<textarea
value={multilineText.headers}
onChange={(event) => updateMultilineText('headers', event.target.value)}
className="w-full rounded-lg border border-gray-300 bg-gray-50 px-3 py-2 text-gray-900 focus:border-blue-500 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100"
rows={3}
placeholder="Authorization=Bearer token&#10;X-API-Key=your-key"
/>
</div>
)}
{showCodexOnlyFields && formData.importMode === 'form' && formData.transport === 'stdio' && (
<div>
<label className="mb-2 block text-sm font-medium text-foreground">
Environment Variable Names
</label>
<textarea
value={multilineText.envVars}
onChange={(event) => updateMultilineText('envVars', event.target.value)}
className="w-full rounded-lg border border-gray-300 bg-gray-50 px-3 py-2 text-gray-900 focus:border-blue-500 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100"
rows={3}
placeholder="GITHUB_TOKEN&#10;API_KEY"
/>
</div>
)}
{showCodexOnlyFields && formData.importMode === 'form' && formData.transport === 'http' && (
<div>
<label className="mb-2 block text-sm font-medium text-foreground">
Bearer Token Environment Variable
</label>
<Input
value={formData.bearerTokenEnvVar}
onChange={(event) => updateForm('bearerTokenEnvVar', event.target.value)}
placeholder="MCP_TOKEN"
/>
</div>
)}
<div className="flex justify-end gap-2 pt-4">
<Button type="button" variant="outline" onClick={onClose}>
{t('mcpForm.actions.cancel')}
</Button>
<Button
type="submit"
disabled={isSubmitting || !canSubmit}
className="bg-purple-600 text-white hover:bg-purple-700 disabled:opacity-50"
>
{isSubmitting
? t('mcpForm.actions.saving')
: isEditing
? t('mcpForm.actions.updateServer')
: addButtonLabel}
</Button>
</div>
</form>
</div>
</div>
);
}

View File

@@ -2,8 +2,8 @@ import { useCallback, useState } from 'react';
import { authenticatedFetch } from '../../../utils/api';
import type { LLMProvider } from '../../../types/app';
import {
CLI_AUTH_STATUS_ENDPOINTS,
CLI_PROVIDERS,
PROVIDER_AUTH_STATUS_ENDPOINTS,
createInitialProviderAuthStatusMap,
} from '../types';
import type {
@@ -69,7 +69,7 @@ export function useProviderAuthStatus(
setProviderLoading(provider);
try {
const response = await authenticatedFetch(CLI_AUTH_STATUS_ENDPOINTS[provider]);
const response = await authenticatedFetch(PROVIDER_AUTH_STATUS_ENDPOINTS[provider]);
if (!response.ok) {
setProviderStatus(provider, {

View File

@@ -12,11 +12,11 @@ export type ProviderAuthStatusMap = Record<LLMProvider, ProviderAuthStatus>;
export const CLI_PROVIDERS: LLMProvider[] = ['claude', 'cursor', 'codex', 'gemini'];
export const CLI_AUTH_STATUS_ENDPOINTS: Record<LLMProvider, string> = {
claude: '/api/cli/claude/status',
cursor: '/api/cli/cursor/status',
codex: '/api/cli/codex/status',
gemini: '/api/cli/gemini/status',
export const PROVIDER_AUTH_STATUS_ENDPOINTS: Record<LLMProvider, string> = {
claude: '/api/providers/claude/auth/status',
cursor: '/api/providers/cursor/auth/status',
codex: '/api/providers/codex/auth/status',
gemini: '/api/providers/gemini/auth/status',
};
export const createInitialProviderAuthStatusMap = (loading = true): ProviderAuthStatusMap => ({

View File

@@ -1,12 +1,8 @@
import type {
AgentCategory,
AgentProvider,
ClaudeMcpFormState,
CodexMcpFormState,
CodeEditorSettingsState,
CursorPermissionsState,
McpToolsResult,
McpTestResult,
ProjectSortOrder,
SettingsMainTab,
} from '../types/types';
@@ -20,7 +16,7 @@ export const SETTINGS_MAIN_TABS: SettingsMainTab[] = [
'notifications',
];
export const AGENT_PROVIDERS: AgentProvider[] = ['claude', 'cursor', 'codex'];
export const AGENT_PROVIDERS: AgentProvider[] = ['claude', 'cursor', 'codex', 'gemini'];
export const AGENT_CATEGORIES: AgentCategory[] = ['account', 'permissions', 'mcp'];
export const DEFAULT_PROJECT_SORT_ORDER: ProjectSortOrder = 'name';
@@ -33,47 +29,6 @@ export const DEFAULT_CODE_EDITOR_SETTINGS: CodeEditorSettingsState = {
fontSize: '14',
};
export const DEFAULT_MCP_TEST_RESULT: McpTestResult = {
success: false,
message: '',
details: [],
loading: false,
};
export const DEFAULT_MCP_TOOLS_RESULT: McpToolsResult = {
success: false,
tools: [],
resources: [],
prompts: [],
};
export const DEFAULT_CLAUDE_MCP_FORM: ClaudeMcpFormState = {
name: '',
type: 'stdio',
scope: 'user',
projectPath: '',
config: {
command: '',
args: [],
env: {},
url: '',
headers: {},
timeout: 30000,
},
importMode: 'form',
jsonInput: '',
};
export const DEFAULT_CODEX_MCP_FORM: CodexMcpFormState = {
name: '',
type: 'stdio',
config: {
command: '',
args: [],
env: {},
},
};
export const DEFAULT_CURSOR_PERMISSIONS: CursorPermissionsState = {
allowedCommands: [],
disallowedCommands: [],

View File

@@ -8,16 +8,11 @@ import {
} from '../constants/constants';
import type {
AgentProvider,
ClaudeMcpFormState,
ClaudePermissionsState,
CodeEditorSettingsState,
CodexMcpFormState,
CodexPermissionMode,
CursorPermissionsState,
GeminiPermissionMode,
McpServer,
McpToolsResult,
McpTestResult,
NotificationPreferencesState,
ProjectSortOrder,
SettingsMainTab,
@@ -33,41 +28,6 @@ type UseSettingsControllerArgs = {
initialTab: string;
};
type JsonResult = {
success?: boolean;
error?: string;
};
type McpReadResponse = {
success?: boolean;
servers?: McpServer[];
};
type McpCliServer = {
name: string;
type?: string;
command?: string;
args?: string[];
env?: Record<string, string>;
url?: string;
headers?: Record<string, string>;
};
type McpCliReadResponse = {
success?: boolean;
servers?: McpCliServer[];
};
type McpTestResponse = {
testResult?: McpTestResult;
error?: string;
};
type McpToolsResponse = {
toolsResult?: McpToolsResult;
error?: string;
};
type ClaudeSettingsStorage = {
allowedTools?: string[];
disallowedTools?: string[];
@@ -103,10 +63,6 @@ const normalizeMainTab = (tab: string): SettingsMainTab => {
return KNOWN_MAIN_TABS.includes(tab as SettingsMainTab) ? (tab as SettingsMainTab) : 'agents';
};
const getErrorMessage = (error: unknown): string => (
error instanceof Error ? error.message : 'Unknown error'
);
const parseJson = <T>(value: string | null, fallback: T): T => {
if (!value) {
return fallback;
@@ -135,25 +91,6 @@ const readCodeEditorSettings = (): CodeEditorSettingsState => ({
fontSize: localStorage.getItem('codeEditorFontSize') ?? DEFAULT_CODE_EDITOR_SETTINGS.fontSize,
});
const mapCliServersToMcpServers = (servers: McpCliServer[] = []): McpServer[] => (
servers.map((server) => ({
id: server.name,
name: server.name,
type: server.type || 'stdio',
scope: 'user',
config: {
command: server.command || '',
args: server.args || [],
env: server.env || {},
url: server.url || '',
headers: server.headers || {},
timeout: 30000,
},
created: new Date().toISOString(),
updated: new Date().toISOString(),
}))
);
const toResponseJson = async <T>(response: Response): Promise<T> => response.json() as Promise<T>;
const createEmptyClaudePermissions = (): ClaudePermissionsState => ({
@@ -184,7 +121,6 @@ export function useSettingsController({ isOpen, initialTab }: UseSettingsControl
const [activeTab, setActiveTab] = useState<SettingsMainTab>(() => normalizeMainTab(initialTab));
const [saveStatus, setSaveStatus] = useState<'success' | 'error' | null>(null);
const [deleteError, setDeleteError] = useState<string | null>(null);
const [projectSortOrder, setProjectSortOrder] = useState<ProjectSortOrder>('name');
const [codeEditorSettings, setCodeEditorSettings] = useState<CodeEditorSettingsState>(() => (
readCodeEditorSettings()
@@ -202,18 +138,6 @@ export function useSettingsController({ isOpen, initialTab }: UseSettingsControl
const [codexPermissionMode, setCodexPermissionMode] = useState<CodexPermissionMode>('default');
const [geminiPermissionMode, setGeminiPermissionMode] = useState<GeminiPermissionMode>('default');
const [mcpServers, setMcpServers] = useState<McpServer[]>([]);
const [cursorMcpServers, setCursorMcpServers] = useState<McpServer[]>([]);
const [codexMcpServers, setCodexMcpServers] = useState<McpServer[]>([]);
const [mcpTestResults, setMcpTestResults] = useState<Record<string, McpTestResult>>({});
const [mcpServerTools, setMcpServerTools] = useState<Record<string, McpToolsResult>>({});
const [mcpToolsLoading, setMcpToolsLoading] = useState<Record<string, boolean>>({});
const [showMcpForm, setShowMcpForm] = useState(false);
const [editingMcpServer, setEditingMcpServer] = useState<McpServer | null>(null);
const [showCodexMcpForm, setShowCodexMcpForm] = useState(false);
const [editingCodexMcpServer, setEditingCodexMcpServer] = useState<McpServer | null>(null);
const [showLoginModal, setShowLoginModal] = useState(false);
const [loginProvider, setLoginProvider] = useState<ActiveLoginProvider>('');
const {
@@ -222,361 +146,6 @@ export function useSettingsController({ isOpen, initialTab }: UseSettingsControl
refreshProviderAuthStatuses,
} = useProviderAuthStatus();
const fetchCursorMcpServers = useCallback(async () => {
try {
const response = await authenticatedFetch('/api/cursor/mcp');
if (!response.ok) {
console.error('Failed to fetch Cursor MCP servers');
return;
}
const data = await toResponseJson<{ servers?: McpServer[] }>(response);
setCursorMcpServers(data.servers || []);
} catch (error) {
console.error('Error fetching Cursor MCP servers:', error);
}
}, []);
const fetchCodexMcpServers = useCallback(async () => {
try {
const configResponse = await authenticatedFetch('/api/codex/mcp/config/read');
if (configResponse.ok) {
const configData = await toResponseJson<McpReadResponse>(configResponse);
if (configData.success && configData.servers) {
setCodexMcpServers(configData.servers);
return;
}
}
const cliResponse = await authenticatedFetch('/api/codex/mcp/cli/list');
if (!cliResponse.ok) {
return;
}
const cliData = await toResponseJson<McpCliReadResponse>(cliResponse);
if (!cliData.success || !cliData.servers) {
return;
}
setCodexMcpServers(mapCliServersToMcpServers(cliData.servers));
} catch (error) {
console.error('Error fetching Codex MCP servers:', error);
}
}, []);
const fetchMcpServers = useCallback(async () => {
try {
const configResponse = await authenticatedFetch('/api/mcp/config/read');
if (configResponse.ok) {
const configData = await toResponseJson<McpReadResponse>(configResponse);
if (configData.success && configData.servers) {
setMcpServers(configData.servers);
return;
}
}
const cliResponse = await authenticatedFetch('/api/mcp/cli/list');
if (cliResponse.ok) {
const cliData = await toResponseJson<McpCliReadResponse>(cliResponse);
if (cliData.success && cliData.servers) {
setMcpServers(mapCliServersToMcpServers(cliData.servers));
return;
}
}
const fallbackResponse = await authenticatedFetch('/api/mcp/servers?scope=user');
if (!fallbackResponse.ok) {
console.error('Failed to fetch MCP servers');
return;
}
const fallbackData = await toResponseJson<{ servers?: McpServer[] }>(fallbackResponse);
setMcpServers(fallbackData.servers || []);
} catch (error) {
console.error('Error fetching MCP servers:', error);
}
}, []);
const deleteMcpServer = useCallback(async (serverId: string, scope = 'user') => {
const response = await authenticatedFetch(`/api/mcp/cli/remove/${serverId}?scope=${scope}`, {
method: 'DELETE',
});
if (!response.ok) {
const error = await toResponseJson<JsonResult>(response);
throw new Error(error.error || 'Failed to delete server');
}
const result = await toResponseJson<JsonResult>(response);
if (!result.success) {
throw new Error(result.error || 'Failed to delete server via Claude CLI');
}
}, []);
const saveMcpServer = useCallback(
async (serverData: ClaudeMcpFormState, editingServer: McpServer | null) => {
const newServerScope = serverData.scope || 'user';
const response = await authenticatedFetch('/api/mcp/cli/add', {
method: 'POST',
body: JSON.stringify({
name: serverData.name,
type: serverData.type,
scope: newServerScope,
projectPath: serverData.projectPath,
command: serverData.config.command,
args: serverData.config.args || [],
url: serverData.config.url,
headers: serverData.config.headers || {},
env: serverData.config.env || {},
}),
});
if (!response.ok) {
const error = await toResponseJson<JsonResult>(response);
throw new Error(error.error || 'Failed to save server');
}
const result = await toResponseJson<JsonResult>(response);
if (!result.success) {
throw new Error(result.error || 'Failed to save server via Claude CLI');
}
if (!editingServer?.id) {
return;
}
const previousServerScope = editingServer.scope || 'user';
const didServerIdentityChange =
editingServer.id !== serverData.name || previousServerScope !== newServerScope;
if (!didServerIdentityChange) {
return;
}
try {
await deleteMcpServer(editingServer.id, previousServerScope);
} catch (error) {
console.warn('Saved MCP server update but failed to remove the previous server entry.', {
previousServerId: editingServer.id,
previousServerScope,
error: getErrorMessage(error),
});
}
},
[deleteMcpServer],
);
const submitMcpForm = useCallback(
async (formData: ClaudeMcpFormState, editingServer: McpServer | null) => {
if (formData.importMode === 'json') {
const response = await authenticatedFetch('/api/mcp/cli/add-json', {
method: 'POST',
body: JSON.stringify({
name: formData.name,
jsonConfig: formData.jsonInput,
scope: formData.scope,
projectPath: formData.projectPath,
}),
});
if (!response.ok) {
const error = await toResponseJson<JsonResult>(response);
throw new Error(error.error || 'Failed to add server');
}
const result = await toResponseJson<JsonResult>(response);
if (!result.success) {
throw new Error(result.error || 'Failed to add server via JSON');
}
} else {
await saveMcpServer(formData, editingServer);
}
await fetchMcpServers();
setSaveStatus('success');
setShowMcpForm(false);
setEditingMcpServer(null);
},
[fetchMcpServers, saveMcpServer],
);
const handleMcpDelete = useCallback(
async (serverId: string, scope = 'user') => {
if (!window.confirm('Are you sure you want to delete this MCP server?')) {
return;
}
setDeleteError(null);
try {
await deleteMcpServer(serverId, scope);
await fetchMcpServers();
setDeleteError(null);
setSaveStatus('success');
} catch (error) {
setDeleteError(getErrorMessage(error));
setSaveStatus('error');
}
},
[deleteMcpServer, fetchMcpServers],
);
const testMcpServer = useCallback(async (serverId: string, scope = 'user') => {
const response = await authenticatedFetch(`/api/mcp/servers/${serverId}/test?scope=${scope}`, {
method: 'POST',
});
if (!response.ok) {
const error = await toResponseJson<McpTestResponse>(response);
throw new Error(error.error || 'Failed to test server');
}
const data = await toResponseJson<McpTestResponse>(response);
return data.testResult || { success: false, message: 'No test result returned' };
}, []);
const discoverMcpTools = useCallback(async (serverId: string, scope = 'user') => {
const response = await authenticatedFetch(`/api/mcp/servers/${serverId}/tools?scope=${scope}`, {
method: 'POST',
});
if (!response.ok) {
const error = await toResponseJson<McpToolsResponse>(response);
throw new Error(error.error || 'Failed to discover tools');
}
const data = await toResponseJson<McpToolsResponse>(response);
return data.toolsResult || { success: false, tools: [], resources: [], prompts: [] };
}, []);
const handleMcpTest = useCallback(
async (serverId: string, scope = 'user') => {
try {
setMcpTestResults((prev) => ({
...prev,
[serverId]: { success: false, message: 'Testing server...', details: [], loading: true },
}));
const result = await testMcpServer(serverId, scope);
setMcpTestResults((prev) => ({ ...prev, [serverId]: result }));
} catch (error) {
setMcpTestResults((prev) => ({
...prev,
[serverId]: {
success: false,
message: getErrorMessage(error),
details: [],
},
}));
}
},
[testMcpServer],
);
const handleMcpToolsDiscovery = useCallback(
async (serverId: string, scope = 'user') => {
try {
setMcpToolsLoading((prev) => ({ ...prev, [serverId]: true }));
const result = await discoverMcpTools(serverId, scope);
setMcpServerTools((prev) => ({ ...prev, [serverId]: result }));
} catch {
setMcpServerTools((prev) => ({
...prev,
[serverId]: { success: false, tools: [], resources: [], prompts: [] },
}));
} finally {
setMcpToolsLoading((prev) => ({ ...prev, [serverId]: false }));
}
},
[discoverMcpTools],
);
const deleteCodexMcpServer = useCallback(async (serverId: string) => {
const response = await authenticatedFetch(`/api/codex/mcp/cli/remove/${serverId}`, {
method: 'DELETE',
});
if (!response.ok) {
const error = await toResponseJson<JsonResult>(response);
throw new Error(error.error || 'Failed to delete server');
}
const result = await toResponseJson<JsonResult>(response);
if (!result.success) {
throw new Error(result.error || 'Failed to delete Codex MCP server');
}
}, []);
const saveCodexMcpServer = useCallback(
async (serverData: CodexMcpFormState, editingServer: McpServer | null) => {
const response = await authenticatedFetch('/api/codex/mcp/cli/add', {
method: 'POST',
body: JSON.stringify({
name: serverData.name,
command: serverData.config.command,
args: serverData.config.args || [],
env: serverData.config.env || {},
}),
});
if (!response.ok) {
const error = await toResponseJson<JsonResult>(response);
throw new Error(error.error || 'Failed to save server');
}
const result = await toResponseJson<JsonResult>(response);
if (!result.success) {
throw new Error(result.error || 'Failed to save Codex MCP server');
}
if (!editingServer?.name || editingServer.name === serverData.name) {
return;
}
try {
await deleteCodexMcpServer(editingServer.name);
} catch (error) {
console.warn('Saved Codex MCP server update but failed to remove the previous server entry.', {
previousServerName: editingServer.name,
error: getErrorMessage(error),
});
}
},
[deleteCodexMcpServer],
);
const submitCodexMcpForm = useCallback(
async (formData: CodexMcpFormState, editingServer: McpServer | null) => {
await saveCodexMcpServer(formData, editingServer);
await fetchCodexMcpServers();
setSaveStatus('success');
setShowCodexMcpForm(false);
setEditingCodexMcpServer(null);
},
[fetchCodexMcpServers, saveCodexMcpServer],
);
const handleCodexMcpDelete = useCallback(
async (serverName: string) => {
if (!window.confirm('Are you sure you want to delete this MCP server?')) {
return;
}
setDeleteError(null);
try {
await deleteCodexMcpServer(serverName);
await fetchCodexMcpServers();
setDeleteError(null);
setSaveStatus('success');
} catch (error) {
setDeleteError(getErrorMessage(error));
setSaveStatus('error');
}
},
[deleteCodexMcpServer, fetchCodexMcpServers],
);
const loadSettings = useCallback(async () => {
try {
const savedClaudeSettings = parseJson<ClaudeSettingsStorage>(
@@ -628,11 +197,6 @@ export function useSettingsController({ isOpen, initialTab }: UseSettingsControl
setNotificationPreferences(createDefaultNotificationPreferences());
}
await Promise.all([
fetchMcpServers(),
fetchCursorMcpServers(),
fetchCodexMcpServers(),
]);
} catch (error) {
console.error('Error loading settings:', error);
setClaudePermissions(createEmptyClaudePermissions());
@@ -641,7 +205,7 @@ export function useSettingsController({ isOpen, initialTab }: UseSettingsControl
setCodexPermissionMode('default');
setProjectSortOrder('name');
}
}, [fetchCodexMcpServers, fetchCursorMcpServers, fetchMcpServers]);
}, []);
const openLoginForProvider = useCallback((provider: AgentProvider) => {
setLoginProvider(provider);
@@ -720,26 +284,6 @@ export function useSettingsController({ isOpen, initialTab }: UseSettingsControl
[],
);
const openMcpForm = useCallback((server?: McpServer) => {
setEditingMcpServer(server || null);
setShowMcpForm(true);
}, []);
const closeMcpForm = useCallback(() => {
setShowMcpForm(false);
setEditingMcpServer(null);
}, []);
const openCodexMcpForm = useCallback((server?: McpServer) => {
setEditingCodexMcpServer(server || null);
setShowCodexMcpForm(true);
}, []);
const closeCodexMcpForm = useCallback(() => {
setShowCodexMcpForm(false);
setEditingCodexMcpServer(null);
}, []);
useEffect(() => {
if (!isOpen) {
return;
@@ -819,7 +363,6 @@ export function useSettingsController({ isOpen, initialTab }: UseSettingsControl
isDarkMode,
toggleDarkMode,
saveStatus,
deleteError,
projectSortOrder,
setProjectSortOrder,
codeEditorSettings,
@@ -832,26 +375,6 @@ export function useSettingsController({ isOpen, initialTab }: UseSettingsControl
setNotificationPreferences,
codexPermissionMode,
setCodexPermissionMode,
mcpServers,
cursorMcpServers,
codexMcpServers,
mcpTestResults,
mcpServerTools,
mcpToolsLoading,
showMcpForm,
editingMcpServer,
openMcpForm,
closeMcpForm,
submitMcpForm,
handleMcpDelete,
handleMcpTest,
handleMcpToolsDiscovery,
showCodexMcpForm,
editingCodexMcpServer,
openCodexMcpForm,
closeCodexMcpForm,
submitCodexMcpForm,
handleCodexMcpDelete,
providerAuthStatus,
geminiPermissionMode,
setGeminiPermissionMode,

View File

@@ -9,9 +9,6 @@ export type ProjectSortOrder = 'name' | 'date';
export type SaveStatus = 'success' | 'error' | null;
export type CodexPermissionMode = 'default' | 'acceptEdits' | 'bypassPermissions';
export type GeminiPermissionMode = 'default' | 'auto_edit' | 'yolo';
export type McpImportMode = 'form' | 'json';
export type McpScope = 'user' | 'local';
export type McpTransportType = 'stdio' | 'sse' | 'http';
export type SettingsProject = {
name: string;
@@ -22,80 +19,6 @@ export type SettingsProject = {
export type AuthStatus = ProviderAuthStatus;
export type KeyValueMap = Record<string, string>;
export type McpServerConfig = {
command?: string;
args?: string[];
env?: KeyValueMap;
url?: string;
headers?: KeyValueMap;
timeout?: number;
};
export type McpServer = {
id?: string;
name: string;
type?: string;
scope?: string;
projectPath?: string;
config?: McpServerConfig;
raw?: unknown;
created?: string;
updated?: string;
};
export type ClaudeMcpFormConfig = {
command: string;
args: string[];
env: KeyValueMap;
url: string;
headers: KeyValueMap;
timeout: number;
};
export type ClaudeMcpFormState = {
name: string;
type: McpTransportType;
scope: McpScope;
projectPath: string;
config: ClaudeMcpFormConfig;
importMode: McpImportMode;
jsonInput: string;
raw?: unknown;
};
export type CodexMcpFormConfig = {
command: string;
args: string[];
env: KeyValueMap;
};
export type CodexMcpFormState = {
name: string;
type: 'stdio';
config: CodexMcpFormConfig;
};
export type McpTestResult = {
success: boolean;
message: string;
details?: string[];
loading?: boolean;
};
export type McpTool = {
name: string;
[key: string]: unknown;
};
export type McpToolsResult = {
success?: boolean;
tools?: McpTool[];
resources?: unknown[];
prompts?: unknown[];
};
export type ClaudePermissionsState = {
allowedTools: string[];
disallowedTools: string[];

View File

@@ -2,8 +2,6 @@ import { X } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import ProviderLoginModal from '../../provider-auth/view/ProviderLoginModal';
import { Button } from '../../../shared/view/ui';
import ClaudeMcpFormModal from '../view/modals/ClaudeMcpFormModal';
import CodexMcpFormModal from '../view/modals/CodexMcpFormModal';
import SettingsSidebar from '../view/SettingsSidebar';
import AgentsSettingsTab from '../view/tabs/agents-settings/AgentsSettingsTab';
import AppearanceSettingsTab from '../view/tabs/AppearanceSettingsTab';
@@ -23,7 +21,6 @@ function Settings({ isOpen, onClose, projects = [], initialTab = 'agents' }: Set
activeTab,
setActiveTab,
saveStatus,
deleteError,
projectSortOrder,
setProjectSortOrder,
codeEditorSettings,
@@ -36,26 +33,6 @@ function Settings({ isOpen, onClose, projects = [], initialTab = 'agents' }: Set
setCursorPermissions,
codexPermissionMode,
setCodexPermissionMode,
mcpServers,
cursorMcpServers,
codexMcpServers,
mcpTestResults,
mcpServerTools,
mcpToolsLoading,
showMcpForm,
editingMcpServer,
openMcpForm,
closeMcpForm,
submitMcpForm,
handleMcpDelete,
handleMcpTest,
handleMcpToolsDiscovery,
showCodexMcpForm,
editingCodexMcpServer,
openCodexMcpForm,
closeCodexMcpForm,
submitCodexMcpForm,
handleCodexMcpDelete,
providerAuthStatus,
geminiPermissionMode,
setGeminiPermissionMode,
@@ -156,19 +133,7 @@ function Settings({ isOpen, onClose, projects = [], initialTab = 'agents' }: Set
onCodexPermissionModeChange={setCodexPermissionMode}
geminiPermissionMode={geminiPermissionMode}
onGeminiPermissionModeChange={setGeminiPermissionMode}
mcpServers={mcpServers}
cursorMcpServers={cursorMcpServers}
codexMcpServers={codexMcpServers}
mcpTestResults={mcpTestResults}
mcpServerTools={mcpServerTools}
mcpToolsLoading={mcpToolsLoading}
onOpenMcpForm={openMcpForm}
onDeleteMcpServer={handleMcpDelete}
onTestMcpServer={handleMcpTest}
onDiscoverMcpTools={handleMcpToolsDiscovery}
onOpenCodexMcpForm={openCodexMcpForm}
onDeleteCodexMcpServer={handleCodexMcpDelete}
deleteError={deleteError}
projects={projects}
/>
)}
@@ -205,20 +170,6 @@ function Settings({ isOpen, onClose, projects = [], initialTab = 'agents' }: Set
isAuthenticated={isAuthenticated}
/>
<ClaudeMcpFormModal
isOpen={showMcpForm}
editingServer={editingMcpServer}
projects={projects}
onClose={closeMcpForm}
onSubmit={submitMcpForm}
/>
<CodexMcpFormModal
isOpen={showCodexMcpForm}
editingServer={editingCodexMcpServer}
onClose={closeCodexMcpForm}
onSubmit={submitCodexMcpForm}
/>
</div>
);
}

View File

@@ -1,478 +0,0 @@
import { FolderOpen, Globe, X } from 'lucide-react';
import { useEffect, useMemo, useState } from 'react';
import type { FormEvent } from 'react';
import { useTranslation } from 'react-i18next';
import { Button, Input } from '../../../../shared/view/ui';
import { DEFAULT_CLAUDE_MCP_FORM } from '../../constants/constants';
import type { ClaudeMcpFormState, McpServer, McpScope, McpTransportType, SettingsProject } from '../../types/types';
type ClaudeMcpFormModalProps = {
isOpen: boolean;
editingServer: McpServer | null;
projects: SettingsProject[];
onClose: () => void;
onSubmit: (formData: ClaudeMcpFormState, editingServer: McpServer | null) => Promise<void>;
};
const getSafeTransportType = (value: unknown): McpTransportType => {
if (value === 'sse' || value === 'http') {
return value;
}
return 'stdio';
};
const getSafeScope = (value: unknown): McpScope => (value === 'local' ? 'local' : 'user');
const getErrorMessage = (error: unknown): string => (
error instanceof Error ? error.message : 'Unknown error'
);
const createFormStateFromServer = (server: McpServer): ClaudeMcpFormState => ({
name: server.name || '',
type: getSafeTransportType(server.type),
scope: getSafeScope(server.scope),
projectPath: server.projectPath || '',
config: {
command: server.config?.command || '',
args: server.config?.args || [],
env: server.config?.env || {},
url: server.config?.url || '',
headers: server.config?.headers || {},
timeout: server.config?.timeout || 30000,
},
importMode: 'form',
jsonInput: '',
raw: server.raw,
});
export default function ClaudeMcpFormModal({
isOpen,
editingServer,
projects,
onClose,
onSubmit,
}: ClaudeMcpFormModalProps) {
const { t } = useTranslation('settings');
const [formData, setFormData] = useState<ClaudeMcpFormState>(DEFAULT_CLAUDE_MCP_FORM);
const [jsonValidationError, setJsonValidationError] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
const isEditing = Boolean(editingServer);
useEffect(() => {
if (!isOpen) {
return;
}
setJsonValidationError('');
if (editingServer) {
setFormData(createFormStateFromServer(editingServer));
return;
}
setFormData(DEFAULT_CLAUDE_MCP_FORM);
}, [editingServer, isOpen]);
const canSubmit = useMemo(() => {
if (!formData.name.trim()) {
return false;
}
if (formData.importMode === 'json') {
return Boolean(formData.jsonInput.trim()) && !jsonValidationError;
}
if (formData.scope === 'local' && !formData.projectPath.trim()) {
return false;
}
if (formData.type === 'stdio') {
return Boolean(formData.config.command.trim());
}
return Boolean(formData.config.url.trim());
}, [formData, jsonValidationError]);
if (!isOpen) {
return null;
}
const updateConfig = <K extends keyof ClaudeMcpFormState['config']>(
key: K,
value: ClaudeMcpFormState['config'][K],
) => {
setFormData((prev) => ({
...prev,
config: {
...prev.config,
[key]: value,
},
}));
};
const handleJsonValidation = (value: string) => {
if (!value.trim()) {
setJsonValidationError('');
return;
}
try {
const parsed = JSON.parse(value) as { type?: string; command?: string; url?: string };
if (!parsed.type) {
setJsonValidationError(t('mcpForm.validation.missingType'));
} else if (parsed.type === 'stdio' && !parsed.command) {
setJsonValidationError(t('mcpForm.validation.stdioRequiresCommand'));
} else if ((parsed.type === 'http' || parsed.type === 'sse') && !parsed.url) {
setJsonValidationError(t('mcpForm.validation.httpRequiresUrl', { type: parsed.type }));
} else {
setJsonValidationError('');
}
} catch {
setJsonValidationError(t('mcpForm.validation.invalidJson'));
}
};
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
setIsSubmitting(true);
try {
await onSubmit(formData, editingServer);
} catch (error) {
alert(`Error: ${getErrorMessage(error)}`);
} finally {
setIsSubmitting(false);
}
};
return (
<div className="fixed inset-0 z-[110] flex items-center justify-center bg-black/50 p-4">
<div className="max-h-[90vh] w-full max-w-2xl overflow-y-auto rounded-lg border border-border bg-background">
<div className="flex items-center justify-between border-b border-border p-4">
<h3 className="text-lg font-medium text-foreground">
{isEditing ? t('mcpForm.title.edit') : t('mcpForm.title.add')}
</h3>
<Button variant="ghost" size="sm" onClick={onClose}>
<X className="h-4 w-4" />
</Button>
</div>
<form onSubmit={handleSubmit} className="space-y-4 p-4">
{!isEditing && (
<div className="mb-4 flex gap-2">
<button
type="button"
onClick={() => setFormData((prev) => ({ ...prev, importMode: 'form' }))}
className={`rounded-lg px-4 py-2 font-medium transition-colors ${
formData.importMode === 'form'
? 'bg-blue-600 text-white'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700'
}`}
>
{t('mcpForm.importMode.form')}
</button>
<button
type="button"
onClick={() => setFormData((prev) => ({ ...prev, importMode: 'json' }))}
className={`rounded-lg px-4 py-2 font-medium transition-colors ${
formData.importMode === 'json'
? 'bg-blue-600 text-white'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700'
}`}
>
{t('mcpForm.importMode.json')}
</button>
</div>
)}
{formData.importMode === 'form' && isEditing && (
<div className="rounded-lg border border-gray-200 bg-gray-50 p-3 dark:border-gray-700 dark:bg-gray-900/50">
<label className="mb-2 block text-sm font-medium text-foreground">
{t('mcpForm.scope.label')}
</label>
<div className="flex items-center gap-2">
{formData.scope === 'user' ? <Globe className="h-4 w-4" /> : <FolderOpen className="h-4 w-4" />}
<span className="text-sm">
{formData.scope === 'user' ? t('mcpForm.scope.userGlobal') : t('mcpForm.scope.projectLocal')}
</span>
{formData.scope === 'local' && formData.projectPath && (
<span className="text-xs text-muted-foreground">- {formData.projectPath}</span>
)}
</div>
<p className="mt-2 text-xs text-muted-foreground">{t('mcpForm.scope.cannotChange')}</p>
</div>
)}
{formData.importMode === 'form' && !isEditing && (
<div className="space-y-4">
<div>
<label className="mb-2 block text-sm font-medium text-foreground">
{t('mcpForm.scope.label')} *
</label>
<div className="flex gap-2">
<button
type="button"
onClick={() => setFormData((prev) => ({ ...prev, scope: 'user', projectPath: '' }))}
className={`flex-1 rounded-lg px-4 py-2 font-medium transition-colors ${
formData.scope === 'user'
? 'bg-blue-600 text-white'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700'
}`}
>
<div className="flex items-center justify-center gap-2">
<Globe className="h-4 w-4" />
<span>{t('mcpForm.scope.userGlobal')}</span>
</div>
</button>
<button
type="button"
onClick={() => setFormData((prev) => ({ ...prev, scope: 'local' }))}
className={`flex-1 rounded-lg px-4 py-2 font-medium transition-colors ${
formData.scope === 'local'
? 'bg-blue-600 text-white'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700'
}`}
>
<div className="flex items-center justify-center gap-2">
<FolderOpen className="h-4 w-4" />
<span>{t('mcpForm.scope.projectLocal')}</span>
</div>
</button>
</div>
<p className="mt-2 text-xs text-muted-foreground">
{formData.scope === 'user'
? t('mcpForm.scope.userDescription')
: t('mcpForm.scope.projectDescription')}
</p>
</div>
{formData.scope === 'local' && (
<div>
<label className="mb-2 block text-sm font-medium text-foreground">
{t('mcpForm.fields.selectProject')} *
</label>
<select
value={formData.projectPath}
onChange={(event) => {
setFormData((prev) => ({ ...prev, projectPath: event.target.value }));
}}
className="w-full rounded-lg border border-gray-300 bg-gray-50 px-3 py-2 text-gray-900 focus:border-blue-500 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100"
required
>
<option value="">{t('mcpForm.fields.selectProject')}...</option>
{projects.map((project) => (
<option key={project.name} value={project.path || project.fullPath}>
{project.displayName || project.name}
</option>
))}
</select>
{formData.projectPath && (
<p className="mt-1 text-xs text-muted-foreground">
{t('mcpForm.projectPath', { path: formData.projectPath })}
</p>
)}
</div>
)}
</div>
)}
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div className={formData.importMode === 'json' ? 'md:col-span-2' : ''}>
<label className="mb-2 block text-sm font-medium text-foreground">
{t('mcpForm.fields.serverName')} *
</label>
<Input
value={formData.name}
onChange={(event) => setFormData((prev) => ({ ...prev, name: event.target.value }))}
placeholder={t('mcpForm.placeholders.serverName')}
required
/>
</div>
{formData.importMode === 'form' && (
<div>
<label className="mb-2 block text-sm font-medium text-foreground">
{t('mcpForm.fields.transportType')} *
</label>
<select
value={formData.type}
onChange={(event) => {
setFormData((prev) => ({
...prev,
type: getSafeTransportType(event.target.value),
}));
}}
className="w-full rounded-lg border border-gray-300 bg-gray-50 px-3 py-2 text-gray-900 focus:border-blue-500 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100"
>
<option value="stdio">stdio</option>
<option value="sse">SSE</option>
<option value="http">HTTP</option>
</select>
</div>
)}
</div>
{isEditing && Boolean(formData.raw) && formData.importMode === 'form' && (
<div className="rounded-lg border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-900/50">
<h4 className="mb-2 text-sm font-medium text-foreground">
{t('mcpForm.configDetails', {
configFile: editingServer?.scope === 'global' ? '~/.claude.json' : 'project config',
})}
</h4>
<pre className="overflow-x-auto rounded bg-gray-100 p-3 text-xs dark:bg-gray-800">
{JSON.stringify(formData.raw, null, 2)}
</pre>
</div>
)}
{formData.importMode === 'json' && (
<div className="space-y-4">
<div>
<label className="mb-2 block text-sm font-medium text-foreground">
{t('mcpForm.fields.jsonConfig')} *
</label>
<textarea
value={formData.jsonInput}
onChange={(event) => {
const value = event.target.value;
setFormData((prev) => ({ ...prev, jsonInput: value }));
handleJsonValidation(value);
}}
className={`w-full border px-3 py-2 ${
jsonValidationError ? 'border-red-500' : 'border-gray-300 dark:border-gray-600'
} rounded-lg bg-gray-50 font-mono text-sm text-gray-900 focus:border-blue-500 focus:ring-blue-500 dark:bg-gray-800 dark:text-gray-100`}
rows={8}
placeholder={'{\n "type": "stdio",\n "command": "/path/to/server",\n "args": ["--api-key", "abc123"],\n "env": {\n "CACHE_DIR": "/tmp"\n }\n}'}
required
/>
{jsonValidationError && (
<p className="mt-1 text-xs text-red-500">{jsonValidationError}</p>
)}
<p className="mt-2 text-xs text-muted-foreground">
{t('mcpForm.validation.jsonHelp')}
<br />
- stdio: {`{"type":"stdio","command":"npx","args":["@upstash/context7-mcp"]}`}
<br />
- http/sse: {`{"type":"http","url":"https://api.example.com/mcp"}`}
</p>
</div>
</div>
)}
{formData.importMode === 'form' && formData.type === 'stdio' && (
<div className="space-y-4">
<div>
<label className="mb-2 block text-sm font-medium text-foreground">
{t('mcpForm.fields.command')} *
</label>
<Input
value={formData.config.command}
onChange={(event) => updateConfig('command', event.target.value)}
placeholder="/path/to/mcp-server"
required
/>
</div>
<div>
<label className="mb-2 block text-sm font-medium text-foreground">
{t('mcpForm.fields.arguments')}
</label>
<textarea
value={formData.config.args.join('\n')}
onChange={(event) => {
const args = event.target.value.split('\n').filter((arg) => arg.trim());
updateConfig('args', args);
}}
className="w-full rounded-lg border border-gray-300 bg-gray-50 px-3 py-2 text-gray-900 focus:border-blue-500 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100"
rows={3}
placeholder="--api-key&#10;abc123"
/>
</div>
</div>
)}
{formData.importMode === 'form' && (formData.type === 'sse' || formData.type === 'http') && (
<div>
<label className="mb-2 block text-sm font-medium text-foreground">
{t('mcpForm.fields.url')} *
</label>
<Input
value={formData.config.url}
onChange={(event) => updateConfig('url', event.target.value)}
placeholder="https://api.example.com/mcp"
type="url"
required
/>
</div>
)}
{formData.importMode === 'form' && (
<div>
<label className="mb-2 block text-sm font-medium text-foreground">
{t('mcpForm.fields.envVars')}
</label>
<textarea
value={Object.entries(formData.config.env).map(([key, value]) => `${key}=${value}`).join('\n')}
onChange={(event) => {
const env: Record<string, string> = {};
event.target.value.split('\n').forEach((line) => {
const [key, ...valueParts] = line.split('=');
if (key && key.trim()) {
env[key.trim()] = valueParts.join('=').trim();
}
});
updateConfig('env', env);
}}
className="w-full rounded-lg border border-gray-300 bg-gray-50 px-3 py-2 text-gray-900 focus:border-blue-500 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100"
rows={3}
placeholder="API_KEY=your-key&#10;DEBUG=true"
/>
</div>
)}
{formData.importMode === 'form' && (formData.type === 'sse' || formData.type === 'http') && (
<div>
<label className="mb-2 block text-sm font-medium text-foreground">
{t('mcpForm.fields.headers')}
</label>
<textarea
value={Object.entries(formData.config.headers).map(([key, value]) => `${key}=${value}`).join('\n')}
onChange={(event) => {
const headers: Record<string, string> = {};
event.target.value.split('\n').forEach((line) => {
const [key, ...valueParts] = line.split('=');
if (key && key.trim()) {
headers[key.trim()] = valueParts.join('=').trim();
}
});
updateConfig('headers', headers);
}}
className="w-full rounded-lg border border-gray-300 bg-gray-50 px-3 py-2 text-gray-900 focus:border-blue-500 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100"
rows={3}
placeholder="Authorization=Bearer token&#10;X-API-Key=your-key"
/>
</div>
)}
<div className="flex justify-end gap-2 pt-4">
<Button type="button" variant="outline" onClick={onClose}>
{t('mcpForm.actions.cancel')}
</Button>
<Button
type="submit"
disabled={isSubmitting || !canSubmit}
className="bg-purple-600 hover:bg-purple-700 disabled:opacity-50"
>
{isSubmitting
? t('mcpForm.actions.saving')
: isEditing
? t('mcpForm.actions.updateServer')
: t('mcpForm.actions.addServer')}
</Button>
</div>
</form>
</div>
</div>
);
}

View File

@@ -1,177 +0,0 @@
import { useEffect, useState } from 'react';
import type { FormEvent } from 'react';
import { X } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Button, Input } from '../../../../shared/view/ui';
import { DEFAULT_CODEX_MCP_FORM } from '../../constants/constants';
import type { CodexMcpFormState, McpServer } from '../../types/types';
type CodexMcpFormModalProps = {
isOpen: boolean;
editingServer: McpServer | null;
onClose: () => void;
onSubmit: (formData: CodexMcpFormState, editingServer: McpServer | null) => Promise<void>;
};
const getErrorMessage = (error: unknown): string => (
error instanceof Error ? error.message : 'Unknown error'
);
const createFormStateFromServer = (server: McpServer): CodexMcpFormState => ({
name: server.name || '',
type: 'stdio',
config: {
command: server.config?.command || '',
args: server.config?.args || [],
env: server.config?.env || {},
},
});
export default function CodexMcpFormModal({
isOpen,
editingServer,
onClose,
onSubmit,
}: CodexMcpFormModalProps) {
const { t } = useTranslation('settings');
const [formData, setFormData] = useState<CodexMcpFormState>(DEFAULT_CODEX_MCP_FORM);
const [isSubmitting, setIsSubmitting] = useState(false);
useEffect(() => {
if (!isOpen) {
return;
}
if (editingServer) {
setFormData(createFormStateFromServer(editingServer));
return;
}
setFormData(DEFAULT_CODEX_MCP_FORM);
}, [editingServer, isOpen]);
if (!isOpen) {
return null;
}
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
setIsSubmitting(true);
try {
await onSubmit(formData, editingServer);
} catch (error) {
alert(`Error: ${getErrorMessage(error)}`);
} finally {
setIsSubmitting(false);
}
};
return (
<div className="fixed inset-0 z-[110] flex items-center justify-center bg-black/50 p-4">
<div className="max-h-[90vh] w-full max-w-lg overflow-y-auto rounded-lg border border-border bg-background">
<div className="flex items-center justify-between border-b border-border p-4">
<h3 className="text-lg font-medium text-foreground">
{editingServer ? t('mcpForm.title.edit') : t('mcpForm.title.add')}
</h3>
<Button variant="ghost" size="sm" onClick={onClose}>
<X className="h-4 w-4" />
</Button>
</div>
<form onSubmit={handleSubmit} className="space-y-4 p-4">
<div>
<label className="mb-2 block text-sm font-medium text-foreground">
{t('mcpForm.fields.serverName')} *
</label>
<Input
value={formData.name}
onChange={(event) => setFormData((prev) => ({ ...prev, name: event.target.value }))}
placeholder={t('mcpForm.placeholders.serverName')}
required
/>
</div>
<div>
<label className="mb-2 block text-sm font-medium text-foreground">
{t('mcpForm.fields.command')} *
</label>
<Input
value={formData.config.command}
onChange={(event) => {
const command = event.target.value;
setFormData((prev) => ({
...prev,
config: { ...prev.config, command },
}));
}}
placeholder="npx @my-org/mcp-server"
required
/>
</div>
<div>
<label className="mb-2 block text-sm font-medium text-foreground">
{t('mcpForm.fields.arguments')}
</label>
<textarea
value={formData.config.args.join('\n')}
onChange={(event) => {
const args = event.target.value.split('\n').filter((arg) => arg.trim());
setFormData((prev) => ({
...prev,
config: { ...prev.config, args },
}));
}}
placeholder="--port&#10;3000"
rows={3}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
/>
</div>
<div>
<label className="mb-2 block text-sm font-medium text-foreground">
{t('mcpForm.fields.envVars')}
</label>
<textarea
value={Object.entries(formData.config.env).map(([key, value]) => `${key}=${value}`).join('\n')}
onChange={(event) => {
const env: Record<string, string> = {};
event.target.value.split('\n').forEach((line) => {
const [key, ...valueParts] = line.split('=');
if (key && valueParts.length > 0) {
env[key.trim()] = valueParts.join('=').trim();
}
});
setFormData((prev) => ({
...prev,
config: { ...prev.config, env },
}));
}}
placeholder="API_KEY=xxx&#10;DEBUG=true"
rows={3}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
/>
</div>
<div className="flex justify-end gap-2 border-t border-border pt-4">
<Button type="button" variant="outline" onClick={onClose}>
{t('mcpForm.actions.cancel')}
</Button>
<Button
type="submit"
disabled={isSubmitting || !formData.name.trim() || !formData.config.command.trim()}
className="bg-green-600 text-white hover:bg-green-700"
>
{isSubmitting
? t('mcpForm.actions.saving')
: editingServer
? t('mcpForm.actions.updateServer')
: t('mcpForm.actions.addServer')}
</Button>
</div>
</form>
</div>
</div>
);
}

View File

@@ -1,9 +1,12 @@
import { useMemo, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { useServerPlatform } from '../../../../../hooks/useServerPlatform';
import type { AgentCategory, AgentProvider } from '../../../types/types';
import type { AgentContext, AgentsSettingsTabProps } from './types';
import AgentCategoryContentSection from './sections/AgentCategoryContentSection';
import AgentCategoryTabsSection from './sections/AgentCategoryTabsSection';
import AgentSelectorSection from './sections/AgentSelectorSection';
import type { AgentContext, AgentsSettingsTabProps } from './types';
export default function AgentsSettingsTab({
providerAuthStatus,
@@ -16,22 +19,26 @@ export default function AgentsSettingsTab({
onCodexPermissionModeChange,
geminiPermissionMode,
onGeminiPermissionModeChange,
mcpServers,
cursorMcpServers,
codexMcpServers,
mcpTestResults,
mcpServerTools,
mcpToolsLoading,
deleteError,
onOpenMcpForm,
onDeleteMcpServer,
onTestMcpServer,
onDiscoverMcpTools,
onOpenCodexMcpForm,
onDeleteCodexMcpServer,
projects,
}: AgentsSettingsTabProps) {
const [selectedAgent, setSelectedAgent] = useState<AgentProvider>('claude');
const [selectedCategory, setSelectedCategory] = useState<AgentCategory>('account');
const { isWindowsServer } = useServerPlatform();
const visibleAgents = useMemo<AgentProvider[]>(() => {
const all: AgentProvider[] = ['claude', 'cursor', 'codex', 'gemini'];
if (isWindowsServer) {
return all.filter((id) => id !== 'cursor');
}
return all;
}, [isWindowsServer]);
useEffect(() => {
if (isWindowsServer && selectedAgent === 'cursor') {
setSelectedAgent('claude');
}
}, [isWindowsServer, selectedAgent]);
const agentContextById = useMemo<Record<AgentProvider, AgentContext>>(() => ({
claude: {
@@ -61,6 +68,7 @@ export default function AgentsSettingsTab({
return (
<div className="-mx-4 -mb-4 -mt-2 flex min-h-[300px] flex-col overflow-hidden md:-mx-6 md:-mb-6 md:-mt-2 md:min-h-[500px]">
<AgentSelectorSection
agents={visibleAgents}
selectedAgent={selectedAgent}
onSelectAgent={setSelectedAgent}
agentContextById={agentContextById}
@@ -84,19 +92,7 @@ export default function AgentsSettingsTab({
onCodexPermissionModeChange={onCodexPermissionModeChange}
geminiPermissionMode={geminiPermissionMode}
onGeminiPermissionModeChange={onGeminiPermissionModeChange}
mcpServers={mcpServers}
cursorMcpServers={cursorMcpServers}
codexMcpServers={codexMcpServers}
mcpTestResults={mcpTestResults}
mcpServerTools={mcpServerTools}
mcpToolsLoading={mcpToolsLoading}
deleteError={deleteError}
onOpenMcpForm={onOpenMcpForm}
onDeleteMcpServer={onDeleteMcpServer}
onTestMcpServer={onTestMcpServer}
onDiscoverMcpTools={onDiscoverMcpTools}
onOpenCodexMcpForm={onOpenCodexMcpForm}
onDeleteCodexMcpServer={onDeleteCodexMcpServer}
projects={projects}
/>
</div>
</div>

View File

@@ -1,6 +1,7 @@
import type { AgentCategoryContentSectionProps } from '../types';
import { McpServers } from '../../../../../mcp';
import AccountContent from './content/AccountContent';
import McpServersContent from './content/McpServersContent';
import PermissionsContent from './content/PermissionsContent';
export default function AgentCategoryContentSection({
@@ -13,23 +14,8 @@ export default function AgentCategoryContentSection({
onCursorPermissionsChange,
codexPermissionMode,
onCodexPermissionModeChange,
mcpServers,
cursorMcpServers,
codexMcpServers,
mcpTestResults,
mcpServerTools,
mcpToolsLoading,
deleteError,
onOpenMcpForm,
onDeleteMcpServer,
onTestMcpServer,
onDiscoverMcpTools,
onOpenCodexMcpForm,
onDeleteCodexMcpServer,
projects,
}: AgentCategoryContentSectionProps) {
// Cursor MCP add/edit/delete was previously a placeholder and is intentionally preserved.
const noopCursorMcpAction = () => {};
return (
<div className="flex-1 overflow-y-auto p-3 md:p-4">
{selectedCategory === 'account' && (
@@ -84,40 +70,10 @@ export default function AgentCategoryContentSection({
/>
)}
{selectedCategory === 'mcp' && selectedAgent === 'claude' && (
<McpServersContent
agent="claude"
servers={mcpServers}
onAdd={() => onOpenMcpForm()}
onEdit={(server) => onOpenMcpForm(server)}
onDelete={onDeleteMcpServer}
onTest={onTestMcpServer}
onDiscoverTools={onDiscoverMcpTools}
testResults={mcpTestResults}
serverTools={mcpServerTools}
toolsLoading={mcpToolsLoading}
deleteError={deleteError}
/>
)}
{selectedCategory === 'mcp' && selectedAgent === 'cursor' && (
<McpServersContent
agent="cursor"
servers={cursorMcpServers}
onAdd={noopCursorMcpAction}
onEdit={noopCursorMcpAction}
onDelete={noopCursorMcpAction}
/>
)}
{selectedCategory === 'mcp' && selectedAgent === 'codex' && (
<McpServersContent
agent="codex"
servers={codexMcpServers}
onAdd={() => onOpenCodexMcpForm()}
onEdit={(server) => onOpenCodexMcpForm(server)}
onDelete={(serverId) => onDeleteCodexMcpServer(serverId)}
deleteError={deleteError}
{selectedCategory === 'mcp' && (
<McpServers
selectedProvider={selectedAgent}
currentProjects={projects}
/>
)}
</div>

View File

@@ -3,8 +3,6 @@ import SessionProviderLogo from '../../../../../llm-logo-provider/SessionProvide
import type { AgentProvider } from '../../../../types/types';
import type { AgentSelectorSectionProps } from '../types';
const AGENT_PROVIDERS: AgentProvider[] = ['claude', 'cursor', 'codex', 'gemini'];
const AGENT_NAMES: Record<AgentProvider, string> = {
claude: 'Claude',
cursor: 'Cursor',
@@ -13,6 +11,7 @@ const AGENT_NAMES: Record<AgentProvider, string> = {
};
export default function AgentSelectorSection({
agents,
selectedAgent,
onSelectAgent,
agentContextById,
@@ -20,7 +19,7 @@ export default function AgentSelectorSection({
return (
<div className="flex-shrink-0 border-b border-border px-3 py-2 md:px-4 md:py-3">
<PillBar className="w-full md:w-auto">
{AGENT_PROVIDERS.map((agent) => {
{agents.map((agent) => {
const dotColor =
agent === 'claude' ? 'bg-blue-500' :
agent === 'cursor' ? 'bg-purple-500' :

View File

@@ -1,391 +0,0 @@
import { Edit3, Globe, Plus, Server, Terminal, Trash2, Users, Zap } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Badge, Button } from '../../../../../../../shared/view/ui';
import { IS_PLATFORM } from '../../../../../../../constants/config';
import PremiumFeatureCard from '../../../../PremiumFeatureCard';
import type { McpServer, McpToolsResult, McpTestResult } from '../../../../../types/types';
const getTransportIcon = (type: string | undefined) => {
if (type === 'stdio') {
return <Terminal className="h-4 w-4" />;
}
if (type === 'sse') {
return <Zap className="h-4 w-4" />;
}
if (type === 'http') {
return <Globe className="h-4 w-4" />;
}
return <Server className="h-4 w-4" />;
};
const maskSecret = (value: unknown): string => {
const normalizedValue = String(value ?? '');
if (normalizedValue.length <= 4) {
return '****';
}
return `${normalizedValue.slice(0, 2)}****${normalizedValue.slice(-2)}`;
};
type ClaudeMcpServersProps = {
agent: 'claude';
servers: McpServer[];
onAdd: () => void;
onEdit: (server: McpServer) => void;
onDelete: (serverId: string, scope?: string) => void;
onTest: (serverId: string, scope?: string) => void;
onDiscoverTools: (serverId: string, scope?: string) => void;
testResults: Record<string, McpTestResult>;
serverTools: Record<string, McpToolsResult>;
toolsLoading: Record<string, boolean>;
deleteError?: string | null;
};
function ClaudeMcpServers({
servers,
onAdd,
onEdit,
onDelete,
testResults,
serverTools,
deleteError,
}: Omit<ClaudeMcpServersProps, 'agent' | 'onTest' | 'onDiscoverTools' | 'toolsLoading'>) {
const { t } = useTranslation('settings');
return (
<div className="space-y-4">
<div className="flex items-center gap-3">
<Server className="h-5 w-5 text-purple-500" />
<h3 className="text-lg font-medium text-foreground">{t('mcpServers.title')}</h3>
</div>
<p className="text-sm text-muted-foreground">{t('mcpServers.description.claude')}</p>
<div className="flex items-center justify-between">
<Button onClick={onAdd} className="bg-purple-600 text-white hover:bg-purple-700" size="sm">
<Plus className="mr-2 h-4 w-4" />
{t('mcpServers.addButton')}
</Button>
</div>
{deleteError && (
<div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700 dark:border-red-800/60 dark:bg-red-900/20 dark:text-red-200">
{deleteError}
</div>
)}
<div className="space-y-2">
{servers.map((server) => {
const serverId = server.id || server.name;
const testResult = testResults[serverId];
const toolsResult = serverTools[serverId];
return (
<div key={serverId} className="rounded-lg border border-border bg-card/50 p-4">
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="mb-2 flex items-center gap-2">
{getTransportIcon(server.type)}
<span className="font-medium text-foreground">{server.name}</span>
<Badge variant="outline" className="text-xs">
{server.type || 'stdio'}
</Badge>
<Badge variant="outline" className="text-xs">
{server.scope === 'local'
? t('mcpServers.scope.local')
: server.scope === 'user'
? t('mcpServers.scope.user')
: server.scope}
</Badge>
</div>
<div className="space-y-1 text-sm text-muted-foreground">
{server.type === 'stdio' && server.config?.command && (
<div>
{t('mcpServers.config.command')}:{' '}
<code className="rounded bg-muted px-1 text-xs">{server.config.command}</code>
</div>
)}
{(server.type === 'sse' || server.type === 'http') && server.config?.url && (
<div>
{t('mcpServers.config.url')}:{' '}
<code className="rounded bg-muted px-1 text-xs">{server.config.url}</code>
</div>
)}
{server.config?.args && server.config.args.length > 0 && (
<div>
{t('mcpServers.config.args')}:{' '}
<code className="rounded bg-muted px-1 text-xs">{server.config.args.join(' ')}</code>
</div>
)}
</div>
{testResult && (
<div className={`mt-2 rounded p-2 text-xs ${
testResult.success
? 'bg-green-50 text-green-800 dark:bg-green-900/20 dark:text-green-200'
: 'bg-red-50 text-red-800 dark:bg-red-900/20 dark:text-red-200'
}`}
>
<div className="font-medium">{testResult.message}</div>
</div>
)}
{toolsResult && toolsResult.tools && toolsResult.tools.length > 0 && (
<div className="mt-2 rounded bg-blue-50 p-2 text-xs text-blue-800 dark:bg-blue-900/20 dark:text-blue-200">
<div className="font-medium">
{t('mcpServers.tools.title')} {t('mcpServers.tools.count', { count: toolsResult.tools.length })}
</div>
<div className="mt-1 flex flex-wrap gap-1">
{toolsResult.tools.slice(0, 5).map((tool, index) => (
<code key={`${tool.name}-${index}`} className="rounded bg-blue-100 px-1 dark:bg-blue-800">
{tool.name}
</code>
))}
{toolsResult.tools.length > 5 && (
<span className="text-xs opacity-75">
{t('mcpServers.tools.more', { count: toolsResult.tools.length - 5 })}
</span>
)}
</div>
</div>
)}
</div>
<div className="ml-4 flex items-center gap-2">
<Button
onClick={() => onEdit(server)}
variant="ghost"
size="sm"
className="text-muted-foreground hover:text-foreground"
title={t('mcpServers.actions.edit')}
>
<Edit3 className="h-4 w-4" />
</Button>
<Button
onClick={() => onDelete(serverId, server.scope)}
variant="ghost"
size="sm"
className="text-red-600 hover:text-red-700"
title={t('mcpServers.actions.delete')}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
</div>
);
})}
{servers.length === 0 && (
<div className="py-8 text-center text-muted-foreground">{t('mcpServers.empty')}</div>
)}
</div>
{!IS_PLATFORM && (
<PremiumFeatureCard
icon={<Users className="h-5 w-5" />}
title="Team MCP Configs"
description="Share MCP server configurations across your team. Everyone stays in sync automatically."
/>
)}
</div>
);
}
type CursorMcpServersProps = {
agent: 'cursor';
servers: McpServer[];
onAdd: () => void;
onEdit: (server: McpServer) => void;
onDelete: (serverId: string) => void;
};
function CursorMcpServers({ servers, onAdd, onEdit, onDelete }: Omit<CursorMcpServersProps, 'agent'>) {
const { t } = useTranslation('settings');
return (
<div className="space-y-4">
<div className="flex items-center gap-3">
<Server className="h-5 w-5 text-purple-500" />
<h3 className="text-lg font-medium text-foreground">{t('mcpServers.title')}</h3>
</div>
<p className="text-sm text-muted-foreground">{t('mcpServers.description.cursor')}</p>
<div className="flex items-center justify-between">
<Button onClick={onAdd} className="bg-purple-600 text-white hover:bg-purple-700" size="sm">
<Plus className="mr-2 h-4 w-4" />
{t('mcpServers.addButton')}
</Button>
</div>
<div className="space-y-2">
{servers.map((server) => {
const serverId = server.id || server.name;
return (
<div key={serverId} className="rounded-lg border border-border bg-card/50 p-4">
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="mb-2 flex items-center gap-2">
<Terminal className="h-4 w-4" />
<span className="font-medium text-foreground">{server.name}</span>
<Badge variant="outline" className="text-xs">stdio</Badge>
</div>
<div className="text-sm text-muted-foreground">
{server.config?.command && (
<div>
{t('mcpServers.config.command')}:{' '}
<code className="rounded bg-muted px-1 text-xs">{server.config.command}</code>
</div>
)}
</div>
</div>
<div className="ml-4 flex items-center gap-2">
<Button
onClick={() => onEdit(server)}
variant="ghost"
size="sm"
className="text-muted-foreground hover:text-foreground"
title={t('mcpServers.actions.edit')}
>
<Edit3 className="h-4 w-4" />
</Button>
<Button
onClick={() => onDelete(serverId)}
variant="ghost"
size="sm"
className="text-red-600 hover:text-red-700"
title={t('mcpServers.actions.delete')}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
</div>
);
})}
{servers.length === 0 && (
<div className="py-8 text-center text-muted-foreground">{t('mcpServers.empty')}</div>
)}
</div>
</div>
);
}
type CodexMcpServersProps = {
agent: 'codex';
servers: McpServer[];
onAdd: () => void;
onEdit: (server: McpServer) => void;
onDelete: (serverId: string) => void;
deleteError?: string | null;
};
function CodexMcpServers({ servers, onAdd, onEdit, onDelete, deleteError }: Omit<CodexMcpServersProps, 'agent'>) {
const { t } = useTranslation('settings');
return (
<div className="space-y-4">
<div className="flex items-center gap-3">
<Server className="h-5 w-5 text-muted-foreground" />
<h3 className="text-lg font-medium text-foreground">{t('mcpServers.title')}</h3>
</div>
<p className="text-sm text-muted-foreground">{t('mcpServers.description.codex')}</p>
<div className="flex items-center justify-between">
<Button onClick={onAdd} className="bg-gray-800 text-white hover:bg-gray-900 dark:bg-gray-700 dark:hover:bg-gray-600" size="sm">
<Plus className="mr-2 h-4 w-4" />
{t('mcpServers.addButton')}
</Button>
</div>
{deleteError && (
<div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700 dark:border-red-800/60 dark:bg-red-900/20 dark:text-red-200">
{deleteError}
</div>
)}
<div className="space-y-2">
{servers.map((server) => (
<div key={server.name} className="rounded-lg border border-border bg-card/50 p-4">
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="mb-2 flex items-center gap-2">
<Terminal className="h-4 w-4" />
<span className="font-medium text-foreground">{server.name}</span>
<Badge variant="outline" className="text-xs">stdio</Badge>
</div>
<div className="space-y-1 text-sm text-muted-foreground">
{server.config?.command && (
<div>
{t('mcpServers.config.command')}:{' '}
<code className="rounded bg-muted px-1 text-xs">{server.config.command}</code>
</div>
)}
{server.config?.args && server.config.args.length > 0 && (
<div>
{t('mcpServers.config.args')}:{' '}
<code className="rounded bg-muted px-1 text-xs">{server.config.args.join(' ')}</code>
</div>
)}
{server.config?.env && Object.keys(server.config.env).length > 0 && (
<div>
{t('mcpServers.config.environment')}:{' '}
<code className="rounded bg-muted px-1 text-xs">
{Object.entries(server.config.env).map(([key, value]) => `${key}=${maskSecret(value)}`).join(', ')}
</code>
</div>
)}
</div>
</div>
<div className="ml-4 flex items-center gap-2">
<Button
onClick={() => onEdit(server)}
variant="ghost"
size="sm"
className="text-muted-foreground hover:text-foreground"
title={t('mcpServers.actions.edit')}
>
<Edit3 className="h-4 w-4" />
</Button>
<Button
onClick={() => onDelete(server.name)}
variant="ghost"
size="sm"
className="text-red-600 hover:text-red-700"
title={t('mcpServers.actions.delete')}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
</div>
))}
{servers.length === 0 && (
<div className="py-8 text-center text-muted-foreground">{t('mcpServers.empty')}</div>
)}
</div>
<div className="rounded-lg border border-border bg-muted/50 p-4">
<h4 className="mb-2 font-medium text-foreground">{t('mcpServers.help.title')}</h4>
<p className="text-sm text-muted-foreground">{t('mcpServers.help.description')}</p>
</div>
</div>
);
}
type McpServersContentProps = ClaudeMcpServersProps | CursorMcpServersProps | CodexMcpServersProps;
export default function McpServersContent(props: McpServersContentProps) {
if (props.agent === 'claude') {
return <ClaudeMcpServers {...props} />;
}
if (props.agent === 'cursor') {
return <CursorMcpServers {...props} />;
}
return <CodexMcpServers {...props} />;
}

View File

@@ -6,9 +6,7 @@ import type {
CursorPermissionsState,
CodexPermissionMode,
GeminiPermissionMode,
McpServer,
McpToolsResult,
McpTestResult,
SettingsProject,
} from '../../../types/types';
export type AgentContext = {
@@ -30,19 +28,7 @@ export type AgentsSettingsTabProps = {
onCodexPermissionModeChange: (value: CodexPermissionMode) => void;
geminiPermissionMode: GeminiPermissionMode;
onGeminiPermissionModeChange: (value: GeminiPermissionMode) => void;
mcpServers: McpServer[];
cursorMcpServers: McpServer[];
codexMcpServers: McpServer[];
mcpTestResults: Record<string, McpTestResult>;
mcpServerTools: Record<string, McpToolsResult>;
mcpToolsLoading: Record<string, boolean>;
deleteError: string | null;
onOpenMcpForm: (server?: McpServer) => void;
onDeleteMcpServer: (serverId: string, scope?: string) => void;
onTestMcpServer: (serverId: string, scope?: string) => void;
onDiscoverMcpTools: (serverId: string, scope?: string) => void;
onOpenCodexMcpForm: (server?: McpServer) => void;
onDeleteCodexMcpServer: (serverId: string) => void;
projects: SettingsProject[];
};
export type AgentCategoryTabsSectionProps = {
@@ -51,6 +37,7 @@ export type AgentCategoryTabsSectionProps = {
};
export type AgentSelectorSectionProps = {
agents: AgentProvider[];
selectedAgent: AgentProvider;
onSelectAgent: (agent: AgentProvider) => void;
agentContextById: AgentContextByProvider;
@@ -68,17 +55,5 @@ export type AgentCategoryContentSectionProps = {
onCodexPermissionModeChange: (value: CodexPermissionMode) => void;
geminiPermissionMode: GeminiPermissionMode;
onGeminiPermissionModeChange: (value: GeminiPermissionMode) => void;
mcpServers: McpServer[];
cursorMcpServers: McpServer[];
codexMcpServers: McpServer[];
mcpTestResults: Record<string, McpTestResult>;
mcpServerTools: Record<string, McpToolsResult>;
mcpToolsLoading: Record<string, boolean>;
deleteError: string | null;
onOpenMcpForm: (server?: McpServer) => void;
onDeleteMcpServer: (serverId: string, scope?: string) => void;
onTestMcpServer: (serverId: string, scope?: string) => void;
onDiscoverMcpTools: (serverId: string, scope?: string) => void;
onOpenCodexMcpForm: (server?: McpServer) => void;
onDeleteCodexMcpServer: (serverId: string) => void;
projects: SettingsProject[];
};

View File

@@ -452,7 +452,7 @@ export function useSidebarController({
[getProjectSessions],
);
const confirmDeleteProject = useCallback(async (deleteData = false) => {
const confirmDeleteProject = useCallback(async () => {
if (!deleteConfirmation) {
return;
}
@@ -464,7 +464,7 @@ export function useSidebarController({
setDeletingProjects((prev) => new Set([...prev, project.name]));
try {
const response = await api.deleteProject(project.name, !isEmpty, deleteData);
const response = await api.deleteProject(project.name, !isEmpty);
if (response.ok) {
onProjectDelete?.(project.name);

View File

@@ -1,6 +1,6 @@
import { useMemo } from 'react';
import ReactDOM from 'react-dom';
import { AlertTriangle, EyeOff, Trash2 } from 'lucide-react';
import { AlertTriangle, Trash2 } from 'lucide-react';
import type { TFunction } from 'i18next';
import { Button } from '../../../../shared/view/ui';
import Settings from '../../../settings/view/Settings';
@@ -22,7 +22,7 @@ type SidebarModalsProps = {
onProjectCreated: () => void;
deleteConfirmation: DeleteProjectConfirmation | null;
onCancelDeleteProject: () => void;
onConfirmDeleteProject: (deleteData?: boolean) => void;
onConfirmDeleteProject: () => void;
sessionDeleteConfirmation: SessionDeleteConfirmation | null;
onCancelDeleteSession: () => void;
onConfirmDeleteSession: () => void;
@@ -104,8 +104,8 @@ export default function SidebarModals({
<div className="w-full max-w-md overflow-hidden rounded-xl border border-border bg-card shadow-2xl">
<div className="p-6">
<div className="flex items-start gap-4">
<div className="flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-full bg-orange-100 dark:bg-orange-900/30">
<AlertTriangle className="h-6 w-6 text-orange-600 dark:text-orange-400" />
<div className="flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-full bg-red-100 dark:bg-red-900/30">
<AlertTriangle className="h-6 w-6 text-red-600 dark:text-red-400" />
</div>
<div className="min-w-0 flex-1">
<h3 className="mb-2 text-lg font-semibold text-foreground">
@@ -119,32 +119,32 @@ export default function SidebarModals({
?
</p>
{deleteConfirmation.sessionCount > 0 && (
<p className="mt-2 text-sm text-muted-foreground">
{t('deleteConfirmation.sessionCount', { count: deleteConfirmation.sessionCount })}
</p>
<div className="mt-3 rounded-lg border border-red-200 bg-red-50 p-3 dark:border-red-800 dark:bg-red-900/20">
<p className="text-sm font-medium text-red-700 dark:text-red-300">
{t('deleteConfirmation.sessionCount', { count: deleteConfirmation.sessionCount })}
</p>
<p className="mt-1 text-xs text-red-600 dark:text-red-400">
{t('deleteConfirmation.allConversationsDeleted')}
</p>
</div>
)}
<p className="mt-3 text-xs text-muted-foreground">
{t('deleteConfirmation.cannotUndo')}
</p>
</div>
</div>
</div>
<div className="flex flex-col gap-2 border-t border-border bg-muted/30 p-4">
<Button
variant="outline"
className="w-full justify-start"
onClick={() => onConfirmDeleteProject(false)}
>
<EyeOff className="mr-2 h-4 w-4" />
{t('deleteConfirmation.removeFromSidebar')}
<div className="flex gap-3 border-t border-border bg-muted/30 p-4">
<Button variant="outline" className="flex-1" onClick={onCancelDeleteProject}>
{t('actions.cancel')}
</Button>
<Button
variant="destructive"
className="w-full justify-start bg-red-600 text-white hover:bg-red-700"
onClick={() => onConfirmDeleteProject(true)}
className="flex-1 bg-red-600 text-white hover:bg-red-700"
onClick={onConfirmDeleteProject}
>
<Trash2 className="mr-2 h-4 w-4" />
{t('deleteConfirmation.deleteAllData')}
</Button>
<Button variant="ghost" className="w-full" onClick={onCancelDeleteProject}>
{t('actions.cancel')}
{t('actions.delete')}
</Button>
</div>
</div>

View File

@@ -0,0 +1,40 @@
import { useEffect, useState } from 'react';
import { authenticatedFetch } from '../utils/api';
/**
* Node `process.platform` from the API host (e.g. win32, darwin, linux).
* Null until loaded or if the request fails.
*/
export function useServerPlatform(): {
serverPlatform: string | null;
isWindowsServer: boolean;
} {
const [serverPlatform, setServerPlatform] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
(async () => {
try {
const response = await authenticatedFetch('/api/settings/server-env');
if (!response.ok) {
return;
}
const body = (await response.json()) as { platform?: string };
if (!cancelled && typeof body.platform === 'string') {
setServerPlatform(body.platform);
}
} catch {
// Keep null: treat as unknown host.
}
})();
return () => {
cancelled = true;
};
}, []);
return {
serverPlatform,
isWindowsServer: serverPlatform === 'win32',
};
}

View File

@@ -2,7 +2,7 @@
"projects": {
"title": "Projekte",
"newProject": "Neues Projekt",
"deleteProject": "Projekt entfernen",
"deleteProject": "Projekt löschen",
"renameProject": "Projekt umbenennen",
"noProjects": "Keine Projekte gefunden",
"loadingProjects": "Projekte werden geladen...",
@@ -40,7 +40,7 @@
"createProject": "Neues Projekt erstellen",
"refresh": "Projekte und Sitzungen aktualisieren (Strg+R)",
"renameProject": "Projekt umbenennen (F2)",
"deleteProject": "Projekt aus Seitenleiste entfernen (Entf)",
"deleteProject": "Leeres Projekt löschen (Entf)",
"addToFavorites": "Zu Favoriten hinzufügen",
"removeFromFavorites": "Aus Favoriten entfernen",
"editSessionName": "Sitzungsname manuell bearbeiten",
@@ -95,14 +95,14 @@
"deleteSuccess": "Erfolgreich gelöscht",
"errorOccurred": "Ein Fehler ist aufgetreten",
"deleteSessionConfirm": "Möchtest du diese Sitzung wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden.",
"deleteProjectConfirm": "Projekt aus der Seitenleiste entfernen? Deine Projektdateien, Erinnerungen und Sitzungsdaten werden nicht gelöscht.",
"deleteProjectConfirm": "Möchtest du dieses leere Projekt wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden.",
"enterProjectPath": "Bitte gib einen Projektpfad ein",
"deleteSessionFailed": "Sitzung konnte nicht gelöscht werden. Bitte erneut versuchen.",
"deleteSessionError": "Fehler beim Löschen der Sitzung. Bitte erneut versuchen.",
"renameSessionFailed": "Sitzung konnte nicht umbenannt werden. Bitte erneut versuchen.",
"renameSessionError": "Fehler beim Umbenennen der Sitzung. Bitte erneut versuchen.",
"deleteProjectFailed": "Projekt konnte nicht entfernt werden. Bitte erneut versuchen.",
"deleteProjectError": "Fehler beim Entfernen des Projekts. Bitte erneut versuchen.",
"deleteProjectFailed": "Projekt konnte nicht gelöscht werden. Bitte erneut versuchen.",
"deleteProjectError": "Fehler beim Löschen des Projekts. Bitte erneut versuchen.",
"createProjectFailed": "Projekt konnte nicht erstellt werden. Bitte erneut versuchen.",
"createProjectError": "Fehler beim Erstellen des Projekts. Bitte erneut versuchen."
},
@@ -122,14 +122,12 @@
"projectsScanned_other": "{{count}} Projekte durchsucht"
},
"deleteConfirmation": {
"deleteProject": "Projekt entfernen",
"deleteProject": "Projekt löschen",
"deleteSession": "Sitzung löschen",
"confirmDelete": "Was möchtest du mit",
"confirmDelete": "Möchtest du wirklich löschen",
"sessionCount_one": "Dieses Projekt enthält {{count}} Unterhaltung.",
"sessionCount_other": "Dieses Projekt enthält {{count}} Unterhaltungen.",
"removeFromSidebar": "Nur aus der Seitenleiste entfernen",
"deleteAllData": "Alle Daten dauerhaft löschen",
"allConversationsDeleted": "Das Projekt wird aus der Seitenleiste entfernt. Deine Dateien, Erinnerungen und Sitzungsdaten bleiben erhalten.",
"cannotUndo": "Du kannst das Projekt später erneut hinzufügen."
"allConversationsDeleted": "Alle Unterhaltungen werden dauerhaft gelöscht.",
"cannotUndo": "Diese Aktion kann nicht rückgängig gemacht werden."
}
}

View File

@@ -2,7 +2,7 @@
"projects": {
"title": "Projects",
"newProject": "New Project",
"deleteProject": "Remove Project",
"deleteProject": "Delete Project",
"renameProject": "Rename Project",
"noProjects": "No projects found",
"loadingProjects": "Loading projects...",
@@ -40,7 +40,7 @@
"createProject": "Create new project",
"refresh": "Refresh projects and sessions (Ctrl+R)",
"renameProject": "Rename project (F2)",
"deleteProject": "Remove project from sidebar (Delete)",
"deleteProject": "Delete empty project (Delete)",
"addToFavorites": "Add to favorites",
"removeFromFavorites": "Remove from favorites",
"editSessionName": "Manually edit session name",
@@ -95,14 +95,14 @@
"deleteSuccess": "Deleted successfully",
"errorOccurred": "An error occurred",
"deleteSessionConfirm": "Are you sure you want to delete this session? This action cannot be undone.",
"deleteProjectConfirm": "Remove this project from the sidebar? Your project files, memories, and session data will not be deleted.",
"deleteProjectConfirm": "Are you sure you want to delete this empty project? This action cannot be undone.",
"enterProjectPath": "Please enter a project path",
"deleteSessionFailed": "Failed to delete session. Please try again.",
"deleteSessionError": "Error deleting session. Please try again.",
"renameSessionFailed": "Failed to rename session. Please try again.",
"renameSessionError": "Error renaming session. Please try again.",
"deleteProjectFailed": "Failed to remove project. Please try again.",
"deleteProjectError": "Error removing project. Please try again.",
"deleteProjectFailed": "Failed to delete project. Please try again.",
"deleteProjectError": "Error deleting project. Please try again.",
"createProjectFailed": "Failed to create project. Please try again.",
"createProjectError": "Error creating project. Please try again."
},
@@ -122,14 +122,12 @@
"projectsScanned_other": "{{count}} projects scanned"
},
"deleteConfirmation": {
"deleteProject": "Remove Project",
"deleteProject": "Delete Project",
"deleteSession": "Delete Session",
"confirmDelete": "What would you like to do with",
"confirmDelete": "Are you sure you want to delete",
"sessionCount_one": "This project contains {{count}} conversation.",
"sessionCount_other": "This project contains {{count}} conversations.",
"removeFromSidebar": "Remove from sidebar only",
"deleteAllData": "Delete all data permanently",
"allConversationsDeleted": "The project will be removed from the sidebar. Your files, memories, and session data will be preserved.",
"cannotUndo": "You can re-add the project later."
"allConversationsDeleted": "All conversations will be permanently deleted.",
"cannotUndo": "This action cannot be undone."
}
}

View File

@@ -2,7 +2,7 @@
"projects": {
"title": "プロジェクト",
"newProject": "新規プロジェクト",
"deleteProject": "プロジェクトを除",
"deleteProject": "プロジェクトを除",
"renameProject": "プロジェクト名を変更",
"noProjects": "プロジェクトが見つかりません",
"loadingProjects": "プロジェクトを読み込んでいます...",
@@ -40,7 +40,7 @@
"createProject": "新しいプロジェクトを作成",
"refresh": "プロジェクトとセッションを更新 (Ctrl+R)",
"renameProject": "プロジェクト名を変更 (F2)",
"deleteProject": "サイドバーからプロジェクトを除 (Delete)",
"deleteProject": "空のプロジェクトを除 (Delete)",
"addToFavorites": "お気に入りに追加",
"removeFromFavorites": "お気に入りから削除",
"editSessionName": "セッション名を手動で編集",
@@ -94,14 +94,14 @@
"deleteSuccess": "削除しました",
"errorOccurred": "エラーが発生しました",
"deleteSessionConfirm": "このセッションを削除してもよろしいですか?この操作は取り消せません。",
"deleteProjectConfirm": "サイドバーからこのプロジェクトを除去しますか?プロジェクトファイル、メモリ、セッションデータは削除されません。",
"deleteProjectConfirm": "この空のプロジェクトを削除してもよろしいですか?この操作は取り消せません。",
"enterProjectPath": "プロジェクトのパスを入力してください",
"deleteSessionFailed": "セッションの削除に失敗しました。もう一度お試しください。",
"deleteSessionError": "セッションの削除でエラーが発生しました。もう一度お試しください。",
"renameSessionFailed": "セッション名の変更に失敗しました。もう一度お試しください。",
"renameSessionError": "セッション名の変更でエラーが発生しました。もう一度お試しください。",
"deleteProjectFailed": "プロジェクトの除に失敗しました。もう一度お試しください。",
"deleteProjectError": "プロジェクトの除でエラーが発生しました。もう一度お試しください。",
"deleteProjectFailed": "プロジェクトの除に失敗しました。もう一度お試しください。",
"deleteProjectError": "プロジェクトの除でエラーが発生しました。もう一度お試しください。",
"createProjectFailed": "プロジェクトの作成に失敗しました。もう一度お試しください。",
"createProjectError": "プロジェクトの作成でエラーが発生しました。もう一度お試しください。"
},
@@ -109,13 +109,11 @@
"updateAvailable": "アップデートあり"
},
"deleteConfirmation": {
"deleteProject": "プロジェクトを除",
"deleteProject": "プロジェクトを除",
"deleteSession": "セッションを削除",
"confirmDelete": "このプロジェクトをどうしますか",
"confirmDelete": "本当に削除しますか",
"sessionCount": "このプロジェクトには{{count}}件の会話があります。",
"removeFromSidebar": "サイドバーからのみ除去",
"deleteAllData": "すべてのデータを完全に削除",
"allConversationsDeleted": "プロジェクトはサイドバーから除去されます。ファイル、メモリ、セッションデータは保持されます。",
"cannotUndo": "後からプロジェクトを再追加できます。"
"allConversationsDeleted": "すべての会話が完全に削除されます。",
"cannotUndo": "この操作は取り消せません。"
}
}

View File

@@ -2,7 +2,7 @@
"projects": {
"title": "프로젝트",
"newProject": "새 프로젝트",
"deleteProject": "프로젝트 제",
"deleteProject": "프로젝트 제",
"renameProject": "프로젝트 이름 변경",
"noProjects": "프로젝트가 없습니다",
"loadingProjects": "프로젝트 로딩 중...",
@@ -40,7 +40,7 @@
"createProject": "새 프로젝트 생성",
"refresh": "프로젝트 및 세션 새로고침 (Ctrl+R)",
"renameProject": "프로젝트 이름 변경 (F2)",
"deleteProject": "사이드바에서 프로젝트 제 (Delete)",
"deleteProject": " 프로젝트 제 (Delete)",
"addToFavorites": "즐겨찾기에 추가",
"removeFromFavorites": "즐겨찾기에서 제거",
"editSessionName": "세션 이름 직접 편집",
@@ -94,14 +94,14 @@
"deleteSuccess": "삭제되었습니다",
"errorOccurred": "오류가 발생했습니다",
"deleteSessionConfirm": "이 세션을 삭제하시겠습니까? 이 작업은 취소할 수 없습니다.",
"deleteProjectConfirm": "사이드바에서 이 프로젝트를 제하시겠습니까? 프로젝트 파일, 메모리 및 세션 데이터는 삭제되지 않습니다.",
"deleteProjectConfirm": "이 프로젝트를 제하시겠습니까? 이 작업은 취소할 수 없습니다.",
"enterProjectPath": "프로젝트 경로를 입력해주세요",
"deleteSessionFailed": "세션 삭제 실패. 다시 시도해주세요.",
"deleteSessionError": "세션 삭제 오류. 다시 시도해주세요.",
"renameSessionFailed": "세션 이름 변경 실패. 다시 시도해주세요.",
"renameSessionError": "세션 이름 변경 오류. 다시 시도해주세요.",
"deleteProjectFailed": "프로젝트 제 실패. 다시 시도해주세요.",
"deleteProjectError": "프로젝트 제 오류. 다시 시도해주세요.",
"deleteProjectFailed": "프로젝트 제 실패. 다시 시도해주세요.",
"deleteProjectError": "프로젝트 제 오류. 다시 시도해주세요.",
"createProjectFailed": "프로젝트 생성 실패. 다시 시도해주세요.",
"createProjectError": "프로젝트 생성 오류. 다시 시도해주세요."
},
@@ -109,14 +109,12 @@
"updateAvailable": "업데이트 가능"
},
"deleteConfirmation": {
"deleteProject": "프로젝트 제",
"deleteProject": "프로젝트 제",
"deleteSession": "세션 삭제",
"confirmDelete": "이 프로젝트를 어떻게 하시겠습니까:",
"confirmDelete": "정말 삭제하시겠습니까",
"sessionCount_one": "이 프로젝트에는 {{count}}개의 대화가 있습니다.",
"sessionCount_other": "이 프로젝트에는 {{count}}개의 대화가 있습니다.",
"removeFromSidebar": "사이드바에서만 제거",
"deleteAllData": "모든 데이터 영구 삭제",
"allConversationsDeleted": "프로젝트가 사이드바에서 제거됩니다. 파일, 메모리 및 세션 데이터는 보존됩니다.",
"cannotUndo": "나중에 프로젝트를 다시 추가할 수 있습니다."
"allConversationsDeleted": "모든 대화가 영구적으로 삭제됩니다.",
"cannotUndo": "이 작업은 취소할 수 없습니다."
}
}

View File

@@ -2,7 +2,7 @@
"projects": {
"title": "Проекты",
"newProject": "Новый проект",
"deleteProject": "Убрать проект",
"deleteProject": "Удалить проект",
"renameProject": "Переименовать проект",
"noProjects": "Проекты не найдены",
"loadingProjects": "Загрузка проектов...",
@@ -40,7 +40,7 @@
"createProject": "Создать новый проект",
"refresh": "Обновить проекты и сеансы (Ctrl+R)",
"renameProject": "Переименовать проект (F2)",
"deleteProject": "Убрать проект из боковой панели (Delete)",
"deleteProject": "Удалить пустой проект (Delete)",
"addToFavorites": "Добавить в избранное",
"removeFromFavorites": "Удалить из избранного",
"editSessionName": "Вручную редактировать имя сеанса",
@@ -95,14 +95,14 @@
"deleteSuccess": "Успешно удалено",
"errorOccurred": "Произошла ошибка",
"deleteSessionConfirm": "Вы уверены, что хотите удалить этот сеанс? Это действие нельзя отменить.",
"deleteProjectConfirm": "Убрать этот проект из боковой панели? Файлы проекта, воспоминания и данные сеансов не будут удалены.",
"deleteProjectConfirm": "Вы уверены, что хотите удалить этот пустой проект? Это действие нельзя отменить.",
"enterProjectPath": "Пожалуйста, введите путь к проекту",
"deleteSessionFailed": "Не удалось удалить сеанс. Попробуйте снова.",
"deleteSessionError": "Ошибка при удалении сеанса. Попробуйте снова.",
"renameSessionFailed": "Не удалось переименовать сеанс. Попробуйте снова.",
"renameSessionError": "Ошибка при переименовании сеанса. Попробуйте снова.",
"deleteProjectFailed": "Не удалось убрать проект. Попробуйте снова.",
"deleteProjectError": "Ошибка при удалении проекта из списка. Попробуйте снова.",
"deleteProjectFailed": "Не удалось удалить проект. Попробуйте снова.",
"deleteProjectError": "Ошибка при удалении проекта. Попробуйте снова.",
"createProjectFailed": "Не удалось создать проект. Попробуйте снова.",
"createProjectError": "Ошибка при создании проекта. Попробуйте снова."
},
@@ -126,16 +126,14 @@
"projectsScanned_other": "{{count}} проектов просканировано"
},
"deleteConfirmation": {
"deleteProject": "Убрать проект",
"deleteProject": "Удалить проект",
"deleteSession": "Удалить сеанс",
"confirmDelete": "Что вы хотите сделать с",
"confirmDelete": "Вы уверены, что хотите удалить",
"sessionCount_one": "Этот проект содержит {{count}} разговор.",
"sessionCount_few": "Этот проект содержит {{count}} разговора.",
"sessionCount_many": "Этот проект содержит {{count}} разговоров.",
"sessionCount_other": "Этот проект содержит {{count}} разговоров.",
"removeFromSidebar": "Убрать только из боковой панели",
"deleteAllData": "Удалить все данные навсегда",
"allConversationsDeleted": "Проект будет убран из боковой панели. Ваши файлы, воспоминания и данные сеансов сохранятся.",
"cannotUndo": "Вы сможете добавить проект позже."
"allConversationsDeleted": "Все разговоры будут удалены навсегда.",
"cannotUndo": "Это действие нельзя отменить."
}
}

View File

@@ -2,7 +2,7 @@
"projects": {
"title": "项目",
"newProject": "新建项目",
"deleteProject": "除项目",
"deleteProject": "除项目",
"renameProject": "重命名项目",
"noProjects": "未找到项目",
"loadingProjects": "加载项目中...",
@@ -40,7 +40,7 @@
"createProject": "创建新项目",
"refresh": "刷新项目和会话 (Ctrl+R)",
"renameProject": "重命名项目 (F2)",
"deleteProject": "从侧边栏移除项目 (Delete)",
"deleteProject": "删除空项目 (Delete)",
"addToFavorites": "添加到收藏",
"removeFromFavorites": "从收藏移除",
"editSessionName": "手动编辑会话名称",
@@ -95,14 +95,14 @@
"deleteSuccess": "删除成功",
"errorOccurred": "发生错误",
"deleteSessionConfirm": "确定要删除此会话吗?此操作无法撤销。",
"deleteProjectConfirm": "从侧边栏移除此项目?您的项目文件、记忆和会话数据不会被删除。",
"deleteProjectConfirm": "确定要删除此项目吗?此操作无法撤销。",
"enterProjectPath": "请输入项目路径",
"deleteSessionFailed": "删除会话失败,请重试。",
"deleteSessionError": "删除会话时出错,请重试。",
"renameSessionFailed": "重命名会话失败,请重试。",
"renameSessionError": "重命名会话时出错,请重试。",
"deleteProjectFailed": "除项目失败,请重试。",
"deleteProjectError": "除项目时出错,请重试。",
"deleteProjectFailed": "除项目失败,请重试。",
"deleteProjectError": "除项目时出错,请重试。",
"createProjectFailed": "创建项目失败,请重试。",
"createProjectError": "创建项目时出错,请重试。"
},
@@ -122,14 +122,12 @@
"projectsScanned_other": "{{count}} 个项目已扫描"
},
"deleteConfirmation": {
"deleteProject": "除项目",
"deleteProject": "除项目",
"deleteSession": "删除会话",
"confirmDelete": "您想如何处理",
"confirmDelete": "您确定要删除",
"sessionCount_one": "此项目包含 {{count}} 个对话。",
"sessionCount_other": "此项目包含 {{count}} 个对话。",
"removeFromSidebar": "仅从侧边栏移除",
"deleteAllData": "永久删除所有数据",
"allConversationsDeleted": "项目将从侧边栏中移除。您的文件、记忆和会话数据将会保留。",
"cannotUndo": "您可以稍后重新添加此项目。"
"allConversationsDeleted": "所有对话将被永久删除。",
"cannotUndo": "此操作无法撤销。"
}
}

View File

@@ -89,26 +89,16 @@ export const api = {
authenticatedFetch(`/api/gemini/sessions/${sessionId}`, {
method: 'DELETE',
}),
deleteProject: (projectName, force = false, deleteData = false) => {
const params = new URLSearchParams();
if (force) params.set('force', 'true');
if (deleteData) params.set('deleteData', 'true');
const qs = params.toString();
return authenticatedFetch(`/api/projects/${projectName}${qs ? `?${qs}` : ''}`, {
deleteProject: (projectName, force = false) =>
authenticatedFetch(`/api/projects/${projectName}${force ? '?force=true' : ''}`, {
method: 'DELETE',
});
},
}),
searchConversationsUrl: (query, limit = 50) => {
const token = localStorage.getItem('auth-token');
const params = new URLSearchParams({ q: query, limit: String(limit) });
if (token) params.set('token', token);
return `/api/search/conversations?${params.toString()}`;
},
createProject: (path) =>
authenticatedFetch('/api/projects/create', {
method: 'POST',
body: JSON.stringify({ path }),
}),
createWorkspace: (workspaceData) =>
authenticatedFetch('/api/projects/create-workspace', {
method: 'POST',