Repository: siteboon/claudecodeui Branch: main Commit: 08a6653b3813 Files: 448 Total size: 2.4 MB Directory structure: gitextract_zr639i5w/ ├── .github/ │ ├── ISSUE_TEMPLATE/ │ │ ├── bug_report.md │ │ └── feature_request.md │ └── workflows/ │ └── discord-release.yml ├── .gitignore ├── .gitmodules ├── .husky/ │ ├── commit-msg │ └── pre-commit ├── .npmignore ├── .nvmrc ├── .release-it.json ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE ├── README.de.md ├── README.ja.md ├── README.ko.md ├── README.md ├── README.ru.md ├── README.zh-CN.md ├── commitlint.config.js ├── eslint.config.js ├── index.html ├── package.json ├── postcss.config.js ├── public/ │ ├── api-docs.html │ ├── clear-cache.html │ ├── convert-icons.md │ ├── generate-icons.js │ ├── manifest.json │ └── sw.js ├── release.sh ├── scripts/ │ └── fix-node-pty.js ├── server/ │ ├── claude-sdk.js │ ├── cli.js │ ├── constants/ │ │ └── config.js │ ├── cursor-cli.js │ ├── database/ │ │ ├── db.js │ │ └── init.sql │ ├── gemini-cli.js │ ├── gemini-response-handler.js │ ├── index.js │ ├── load-env.js │ ├── middleware/ │ │ └── auth.js │ ├── openai-codex.js │ ├── projects.js │ ├── providers/ │ │ ├── claude/ │ │ │ └── adapter.js │ │ ├── codex/ │ │ │ └── adapter.js │ │ ├── cursor/ │ │ │ └── adapter.js │ │ ├── gemini/ │ │ │ └── adapter.js │ │ ├── registry.js │ │ ├── types.js │ │ └── utils.js │ ├── routes/ │ │ ├── agent.js │ │ ├── auth.js │ │ ├── cli-auth.js │ │ ├── codex.js │ │ ├── commands.js │ │ ├── cursor.js │ │ ├── gemini.js │ │ ├── git.js │ │ ├── mcp-utils.js │ │ ├── mcp.js │ │ ├── messages.js │ │ ├── plugins.js │ │ ├── projects.js │ │ ├── settings.js │ │ ├── taskmaster.js │ │ └── user.js │ ├── services/ │ │ ├── notification-orchestrator.js │ │ └── vapid-keys.js │ ├── sessionManager.js │ └── utils/ │ ├── commandParser.js │ ├── frontmatter.js │ ├── gitConfig.js │ ├── mcp-detector.js │ ├── plugin-loader.js │ ├── plugin-process-manager.js │ └── taskmaster-websocket.js ├── shared/ │ ├── modelConstants.js │ └── networkHosts.js ├── src/ │ ├── App.tsx │ ├── components/ │ │ ├── app/ │ │ │ ├── AppContent.tsx │ │ │ └── MobileNav.tsx │ │ ├── auth/ │ │ │ ├── constants.ts │ │ │ ├── context/ │ │ │ │ └── AuthContext.tsx │ │ │ ├── index.ts │ │ │ ├── types.ts │ │ │ ├── utils.ts │ │ │ └── view/ │ │ │ ├── AuthErrorAlert.tsx │ │ │ ├── AuthInputField.tsx │ │ │ ├── AuthLoadingScreen.tsx │ │ │ ├── AuthScreenLayout.tsx │ │ │ ├── LoginForm.tsx │ │ │ ├── ProtectedRoute.tsx │ │ │ └── SetupForm.tsx │ │ ├── chat/ │ │ │ ├── constants/ │ │ │ │ └── thinkingModes.ts │ │ │ ├── hooks/ │ │ │ │ ├── useChatComposerState.ts │ │ │ │ ├── useChatMessages.ts │ │ │ │ ├── useChatProviderState.ts │ │ │ │ ├── useChatRealtimeHandlers.ts │ │ │ │ ├── useChatSessionState.ts │ │ │ │ ├── useFileMentions.tsx │ │ │ │ └── useSlashCommands.ts │ │ │ ├── tools/ │ │ │ │ ├── README.md │ │ │ │ ├── ToolRenderer.tsx │ │ │ │ ├── components/ │ │ │ │ │ ├── CollapsibleDisplay.tsx │ │ │ │ │ ├── CollapsibleSection.tsx │ │ │ │ │ ├── ContentRenderers/ │ │ │ │ │ │ ├── FileListContent.tsx │ │ │ │ │ │ ├── MarkdownContent.tsx │ │ │ │ │ │ ├── QuestionAnswerContent.tsx │ │ │ │ │ │ ├── TaskListContent.tsx │ │ │ │ │ │ ├── TextContent.tsx │ │ │ │ │ │ ├── TodoList.tsx │ │ │ │ │ │ ├── TodoListContent.tsx │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── InteractiveRenderers/ │ │ │ │ │ │ ├── AskUserQuestionPanel.tsx │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── OneLineDisplay.tsx │ │ │ │ │ ├── SubagentContainer.tsx │ │ │ │ │ ├── ToolDiffViewer.tsx │ │ │ │ │ └── index.ts │ │ │ │ ├── configs/ │ │ │ │ │ ├── permissionPanelRegistry.ts │ │ │ │ │ └── toolConfigs.ts │ │ │ │ └── index.ts │ │ │ ├── types/ │ │ │ │ └── types.ts │ │ │ ├── utils/ │ │ │ │ ├── chatFormatting.ts │ │ │ │ ├── chatPermissions.ts │ │ │ │ ├── chatStorage.ts │ │ │ │ ├── messageKeys.ts │ │ │ │ └── messageTransforms.ts │ │ │ └── view/ │ │ │ ├── ChatInterface.tsx │ │ │ └── subcomponents/ │ │ │ ├── AssistantThinkingIndicator.tsx │ │ │ ├── ChatComposer.tsx │ │ │ ├── ChatInputControls.tsx │ │ │ ├── ChatMessagesPane.tsx │ │ │ ├── ClaudeStatus.tsx │ │ │ ├── CommandMenu.tsx │ │ │ ├── ImageAttachment.tsx │ │ │ ├── Markdown.tsx │ │ │ ├── MessageComponent.tsx │ │ │ ├── MessageCopyControl.tsx │ │ │ ├── PermissionRequestsBanner.tsx │ │ │ ├── ProviderSelectionEmptyState.tsx │ │ │ ├── ThinkingModeSelector.tsx │ │ │ └── TokenUsagePie.tsx │ │ ├── code-editor/ │ │ │ ├── constants/ │ │ │ │ └── settings.ts │ │ │ ├── hooks/ │ │ │ │ ├── useCodeEditorDocument.ts │ │ │ │ ├── useCodeEditorSettings.ts │ │ │ │ ├── useEditorKeyboardShortcuts.ts │ │ │ │ └── useEditorSidebar.ts │ │ │ ├── types/ │ │ │ │ └── types.ts │ │ │ ├── utils/ │ │ │ │ ├── binaryFile.ts │ │ │ │ ├── editorExtensions.ts │ │ │ │ ├── editorStyles.ts │ │ │ │ └── editorToolbarPanel.ts │ │ │ └── view/ │ │ │ ├── CodeEditor.tsx │ │ │ ├── EditorSidebar.tsx │ │ │ └── subcomponents/ │ │ │ ├── CodeEditorBinaryFile.tsx │ │ │ ├── CodeEditorFooter.tsx │ │ │ ├── CodeEditorHeader.tsx │ │ │ ├── CodeEditorLoadingState.tsx │ │ │ ├── CodeEditorSurface.tsx │ │ │ └── markdown/ │ │ │ ├── MarkdownCodeBlock.tsx │ │ │ └── MarkdownPreview.tsx │ │ ├── file-tree/ │ │ │ ├── constants/ │ │ │ │ ├── constants.ts │ │ │ │ └── fileIcons.ts │ │ │ ├── hooks/ │ │ │ │ ├── useExpandedDirectories.ts │ │ │ │ ├── useFileTreeData.ts │ │ │ │ ├── useFileTreeOperations.ts │ │ │ │ ├── useFileTreeSearch.ts │ │ │ │ ├── useFileTreeUpload.ts │ │ │ │ └── useFileTreeViewMode.ts │ │ │ ├── types/ │ │ │ │ └── types.ts │ │ │ ├── utils/ │ │ │ │ └── fileTreeUtils.ts │ │ │ └── view/ │ │ │ ├── FileContextMenu.tsx │ │ │ ├── FileTree.tsx │ │ │ ├── FileTreeBody.tsx │ │ │ ├── FileTreeDetailedColumns.tsx │ │ │ ├── FileTreeEmptyState.tsx │ │ │ ├── FileTreeHeader.tsx │ │ │ ├── FileTreeList.tsx │ │ │ ├── FileTreeLoadingState.tsx │ │ │ ├── FileTreeNode.tsx │ │ │ └── ImageViewer.tsx │ │ ├── git-panel/ │ │ │ ├── constants/ │ │ │ │ └── constants.ts │ │ │ ├── hooks/ │ │ │ │ ├── useGitPanelController.ts │ │ │ │ ├── useRevertLocalCommit.ts │ │ │ │ └── useSelectedProvider.ts │ │ │ ├── types/ │ │ │ │ └── types.ts │ │ │ ├── utils/ │ │ │ │ └── gitPanelUtils.ts │ │ │ └── view/ │ │ │ ├── GitPanel.tsx │ │ │ ├── GitPanelHeader.tsx │ │ │ ├── GitRepositoryErrorState.tsx │ │ │ ├── GitViewTabs.tsx │ │ │ ├── branches/ │ │ │ │ └── BranchesView.tsx │ │ │ ├── changes/ │ │ │ │ ├── ChangesView.tsx │ │ │ │ ├── CommitComposer.tsx │ │ │ │ ├── FileChangeItem.tsx │ │ │ │ ├── FileChangeList.tsx │ │ │ │ ├── FileSelectionControls.tsx │ │ │ │ └── FileStatusLegend.tsx │ │ │ ├── history/ │ │ │ │ ├── CommitHistoryItem.tsx │ │ │ │ └── HistoryView.tsx │ │ │ ├── modals/ │ │ │ │ ├── ConfirmActionModal.tsx │ │ │ │ └── NewBranchModal.tsx │ │ │ └── shared/ │ │ │ └── GitDiffViewer.tsx │ │ ├── llm-logo-provider/ │ │ │ ├── ClaudeLogo.tsx │ │ │ ├── CodexLogo.tsx │ │ │ ├── CursorLogo.tsx │ │ │ ├── GeminiLogo.tsx │ │ │ └── SessionProviderLogo.tsx │ │ ├── main-content/ │ │ │ ├── hooks/ │ │ │ │ └── useMobileMenuHandlers.ts │ │ │ ├── types/ │ │ │ │ └── types.ts │ │ │ └── view/ │ │ │ ├── ErrorBoundary.tsx │ │ │ ├── MainContent.tsx │ │ │ └── subcomponents/ │ │ │ ├── MainContentHeader.tsx │ │ │ ├── MainContentStateView.tsx │ │ │ ├── MainContentTabSwitcher.tsx │ │ │ ├── MainContentTitle.tsx │ │ │ └── MobileMenuButton.tsx │ │ ├── mic-button/ │ │ │ ├── constants/ │ │ │ │ └── constants.ts │ │ │ ├── data/ │ │ │ │ └── whisper.ts │ │ │ ├── hooks/ │ │ │ │ └── useMicButtonController.ts │ │ │ ├── types/ │ │ │ │ └── types.ts │ │ │ └── view/ │ │ │ ├── MicButton.tsx │ │ │ └── MicButtonView.tsx │ │ ├── onboarding/ │ │ │ └── view/ │ │ │ ├── Onboarding.tsx │ │ │ ├── subcomponents/ │ │ │ │ ├── AgentConnectionCard.tsx │ │ │ │ ├── AgentConnectionsStep.tsx │ │ │ │ ├── GitConfigurationStep.tsx │ │ │ │ └── OnboardingStepProgress.tsx │ │ │ ├── types.ts │ │ │ └── utils.ts │ │ ├── plugins/ │ │ │ └── view/ │ │ │ ├── PluginIcon.tsx │ │ │ ├── PluginSettingsTab.tsx │ │ │ └── PluginTabContent.tsx │ │ ├── prd-editor/ │ │ │ ├── PRDEditor.tsx │ │ │ ├── constants.ts │ │ │ ├── hooks/ │ │ │ │ ├── usePrdDocument.ts │ │ │ │ ├── usePrdKeyboardShortcuts.ts │ │ │ │ ├── usePrdRegistry.ts │ │ │ │ └── usePrdSave.ts │ │ │ ├── index.ts │ │ │ ├── types.ts │ │ │ ├── utils/ │ │ │ │ └── fileName.ts │ │ │ └── view/ │ │ │ ├── GenerateTasksModal.tsx │ │ │ ├── OverwriteConfirmModal.tsx │ │ │ ├── PrdEditorBody.tsx │ │ │ ├── PrdEditorFooter.tsx │ │ │ ├── PrdEditorHeader.tsx │ │ │ ├── PrdEditorLoadingState.tsx │ │ │ └── PrdEditorWorkspace.tsx │ │ ├── project-creation-wizard/ │ │ │ ├── ProjectCreationWizard.tsx │ │ │ ├── components/ │ │ │ │ ├── ErrorBanner.tsx │ │ │ │ ├── FolderBrowserModal.tsx │ │ │ │ ├── GithubAuthenticationCard.tsx │ │ │ │ ├── StepConfiguration.tsx │ │ │ │ ├── StepReview.tsx │ │ │ │ ├── StepTypeSelection.tsx │ │ │ │ ├── WizardFooter.tsx │ │ │ │ ├── WizardProgress.tsx │ │ │ │ └── WorkspacePathField.tsx │ │ │ ├── data/ │ │ │ │ └── workspaceApi.ts │ │ │ ├── hooks/ │ │ │ │ └── useGithubTokens.ts │ │ │ ├── index.ts │ │ │ ├── types.ts │ │ │ └── utils/ │ │ │ └── pathUtils.ts │ │ ├── provider-auth/ │ │ │ ├── types.ts │ │ │ └── view/ │ │ │ └── ProviderLoginModal.tsx │ │ ├── quick-settings-panel/ │ │ │ ├── constants.ts │ │ │ ├── hooks/ │ │ │ │ ├── useQuickSettingsDrag.ts │ │ │ │ └── useWhisperMode.ts │ │ │ ├── index.ts │ │ │ ├── types.ts │ │ │ └── view/ │ │ │ ├── QuickSettingsContent.tsx │ │ │ ├── QuickSettingsHandle.tsx │ │ │ ├── QuickSettingsPanelHeader.tsx │ │ │ ├── QuickSettingsPanelView.tsx │ │ │ ├── QuickSettingsSection.tsx │ │ │ ├── QuickSettingsToggleRow.tsx │ │ │ └── QuickSettingsWhisperSection.tsx │ │ ├── settings/ │ │ │ ├── constants/ │ │ │ │ └── constants.ts │ │ │ ├── hooks/ │ │ │ │ ├── useCredentialsSettings.ts │ │ │ │ ├── useGitSettings.ts │ │ │ │ └── useSettingsController.ts │ │ │ ├── types/ │ │ │ │ └── types.ts │ │ │ └── view/ │ │ │ ├── Settings.tsx │ │ │ ├── SettingsCard.tsx │ │ │ ├── SettingsMainTabs.tsx │ │ │ ├── SettingsRow.tsx │ │ │ ├── SettingsSection.tsx │ │ │ ├── SettingsSidebar.tsx │ │ │ ├── SettingsToggle.tsx │ │ │ ├── modals/ │ │ │ │ ├── ClaudeMcpFormModal.tsx │ │ │ │ └── CodexMcpFormModal.tsx │ │ │ └── tabs/ │ │ │ ├── AppearanceSettingsTab.tsx │ │ │ ├── NotificationsSettingsTab.tsx │ │ │ ├── agents-settings/ │ │ │ │ ├── AgentListItem.tsx │ │ │ │ ├── AgentsSettingsTab.tsx │ │ │ │ ├── sections/ │ │ │ │ │ ├── AgentCategoryContentSection.tsx │ │ │ │ │ ├── AgentCategoryTabsSection.tsx │ │ │ │ │ ├── AgentSelectorSection.tsx │ │ │ │ │ └── content/ │ │ │ │ │ ├── AccountContent.tsx │ │ │ │ │ ├── McpServersContent.tsx │ │ │ │ │ └── PermissionsContent.tsx │ │ │ │ └── types.ts │ │ │ ├── api-settings/ │ │ │ │ ├── CredentialsSettingsTab.tsx │ │ │ │ ├── sections/ │ │ │ │ │ ├── ApiKeysSection.tsx │ │ │ │ │ ├── GithubCredentialsSection.tsx │ │ │ │ │ ├── NewApiKeyAlert.tsx │ │ │ │ │ └── VersionInfoSection.tsx │ │ │ │ └── types.ts │ │ │ ├── git-settings/ │ │ │ │ └── GitSettingsTab.tsx │ │ │ └── tasks-settings/ │ │ │ └── TasksSettingsTab.tsx │ │ ├── shell/ │ │ │ ├── constants/ │ │ │ │ └── constants.ts │ │ │ ├── hooks/ │ │ │ │ ├── useShellConnection.ts │ │ │ │ ├── useShellRuntime.ts │ │ │ │ └── useShellTerminal.ts │ │ │ ├── types/ │ │ │ │ └── types.ts │ │ │ ├── utils/ │ │ │ │ ├── auth.ts │ │ │ │ ├── socket.ts │ │ │ │ └── terminalStyles.ts │ │ │ └── view/ │ │ │ ├── Shell.tsx │ │ │ └── subcomponents/ │ │ │ ├── ShellConnectionOverlay.tsx │ │ │ ├── ShellEmptyState.tsx │ │ │ ├── ShellHeader.tsx │ │ │ ├── ShellMinimalView.tsx │ │ │ └── TerminalShortcutsPanel.tsx │ │ ├── sidebar/ │ │ │ ├── hooks/ │ │ │ │ └── useSidebarController.ts │ │ │ ├── types/ │ │ │ │ └── types.ts │ │ │ ├── utils/ │ │ │ │ └── utils.ts │ │ │ └── view/ │ │ │ ├── Sidebar.tsx │ │ │ └── subcomponents/ │ │ │ ├── SidebarCollapsed.tsx │ │ │ ├── SidebarContent.tsx │ │ │ ├── SidebarFooter.tsx │ │ │ ├── SidebarHeader.tsx │ │ │ ├── SidebarModals.tsx │ │ │ ├── SidebarProjectItem.tsx │ │ │ ├── SidebarProjectList.tsx │ │ │ ├── SidebarProjectSessions.tsx │ │ │ ├── SidebarProjectsState.tsx │ │ │ ├── SidebarSessionItem.tsx │ │ │ └── TaskIndicator.tsx │ │ ├── standalone-shell/ │ │ │ └── view/ │ │ │ ├── StandaloneShell.tsx │ │ │ └── subcomponents/ │ │ │ ├── StandaloneShellEmptyState.tsx │ │ │ └── StandaloneShellHeader.tsx │ │ ├── task-master/ │ │ │ ├── context/ │ │ │ │ └── TaskMasterContext.tsx │ │ │ ├── hooks/ │ │ │ │ ├── useProjectPrdFiles.ts │ │ │ │ └── useTaskBoardState.ts │ │ │ ├── index.ts │ │ │ ├── types.ts │ │ │ ├── utils/ │ │ │ │ ├── taskKanban.ts │ │ │ │ └── taskSorting.ts │ │ │ └── view/ │ │ │ ├── NextTaskBanner.tsx │ │ │ ├── TaskBoard.tsx │ │ │ ├── TaskBoardContent.tsx │ │ │ ├── TaskBoardToolbar.tsx │ │ │ ├── TaskCard.tsx │ │ │ ├── TaskDetailModal.tsx │ │ │ ├── TaskEmptyState.tsx │ │ │ ├── TaskMasterPanel.tsx │ │ │ ├── modals/ │ │ │ │ ├── CreateTaskModal.tsx │ │ │ │ ├── TaskHelpModal.tsx │ │ │ │ └── TaskMasterSetupModal.tsx │ │ │ └── shared/ │ │ │ ├── TaskFiltersPanel.tsx │ │ │ └── TaskQuickSortBar.tsx │ │ └── version-upgrade/ │ │ └── view/ │ │ ├── VersionUpgradeModal.tsx │ │ └── index.ts │ ├── constants/ │ │ └── config.ts │ ├── contexts/ │ │ ├── AuthContext.jsx │ │ ├── PluginsContext.tsx │ │ ├── TaskMasterContext.ts │ │ ├── TasksSettingsContext.jsx │ │ ├── ThemeContext.jsx │ │ └── WebSocketContext.tsx │ ├── hooks/ │ │ ├── useDeviceSettings.ts │ │ ├── useLocalStorage.jsx │ │ ├── useProjectsState.ts │ │ ├── useSessionProtection.ts │ │ ├── useUiPreferences.ts │ │ ├── useVersionCheck.ts │ │ └── useWebPush.ts │ ├── i18n/ │ │ ├── config.js │ │ ├── languages.js │ │ └── locales/ │ │ ├── de/ │ │ │ ├── auth.json │ │ │ ├── chat.json │ │ │ ├── codeEditor.json │ │ │ ├── common.json │ │ │ ├── settings.json │ │ │ ├── sidebar.json │ │ │ └── tasks.json │ │ ├── en/ │ │ │ ├── auth.json │ │ │ ├── chat.json │ │ │ ├── codeEditor.json │ │ │ ├── common.json │ │ │ ├── settings.json │ │ │ ├── sidebar.json │ │ │ └── tasks.json │ │ ├── ja/ │ │ │ ├── auth.json │ │ │ ├── chat.json │ │ │ ├── codeEditor.json │ │ │ ├── common.json │ │ │ ├── settings.json │ │ │ ├── sidebar.json │ │ │ └── tasks.json │ │ ├── ko/ │ │ │ ├── auth.json │ │ │ ├── chat.json │ │ │ ├── codeEditor.json │ │ │ ├── common.json │ │ │ ├── settings.json │ │ │ └── sidebar.json │ │ ├── ru/ │ │ │ ├── auth.json │ │ │ ├── chat.json │ │ │ ├── codeEditor.json │ │ │ ├── common.json │ │ │ ├── settings.json │ │ │ ├── sidebar.json │ │ │ └── tasks.json │ │ └── zh-CN/ │ │ ├── auth.json │ │ ├── chat.json │ │ ├── codeEditor.json │ │ ├── common.json │ │ ├── settings.json │ │ └── sidebar.json │ ├── index.css │ ├── lib/ │ │ └── utils.js │ ├── main.jsx │ ├── shared/ │ │ └── view/ │ │ └── ui/ │ │ ├── Badge.tsx │ │ ├── Button.tsx │ │ ├── DarkModeToggle.tsx │ │ ├── Input.tsx │ │ ├── LanguageSelector.tsx │ │ ├── PillBar.tsx │ │ ├── ScrollArea.tsx │ │ ├── Tooltip.tsx │ │ └── index.ts │ ├── stores/ │ │ └── useSessionStore.ts │ ├── types/ │ │ ├── app.ts │ │ ├── global.d.ts │ │ ├── react-syntax-highlighter.d.ts │ │ └── sharedTypes.ts │ ├── utils/ │ │ ├── api.js │ │ ├── clipboard.ts │ │ └── dateUtils.ts │ └── vite-env.d.ts ├── tailwind.config.js ├── tsconfig.json └── vite.config.js ================================================ FILE CONTENTS ================================================ ================================================ FILE: .github/ISSUE_TEMPLATE/bug_report.md ================================================ --- name: Bug report about: Create a report to help us improve title: '' labels: '' assignees: '' type: Bug --- **Describe the bug** A clear and concise description of what the bug is. **To Reproduce** Steps to reproduce the behavior: 1. Go to '...' 2. Click on '....' 3. Scroll down to '....' 4. See error **Expected behavior** A clear and concise description of what you expected to happen. **Screenshots** If applicable, add screenshots to help explain your problem. **Error message** If applicable, add the error message you see to help explain your problem. **Desktop (please complete the following information):** - OS: [e.g. iOS] - Browser [e.g. chrome, safari] - Version [e.g. 22] **Smartphone (please complete the following information):** - Device: [e.g. iPhone6] - OS: [e.g. iOS8.1] - Browser [e.g. stock browser, safari] **Additional context** Add any other context about the problem here. ================================================ FILE: .github/ISSUE_TEMPLATE/feature_request.md ================================================ --- name: Feature request about: Suggest an idea for this project title: "[Feature]" labels: '' assignees: '' type: Feature --- **Is your feature request related to a problem? Please describe.** A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] **Describe the solution you'd like** A clear and concise description of what you want to happen. **Additional context** Add any other context or screenshots about the feature request here. ================================================ FILE: .github/workflows/discord-release.yml ================================================ name: Discord Release Notification on: release: types: [published] jobs: github-releases-to-discord: runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v3 - name: Github Releases To Discord uses: SethCohen/github-releases-to-discord@v1.19.0 with: webhook_url: ${{ secrets.DISCORD_WEBHOOK_URL }} color: "2105893" username: "Release Changelog" avatar_url: "https://cdn.discordapp.com/avatars/487431320314576937/bd64361e4ba6313d561d54e78c9e7171.png" content: "||@everyone||" footer_title: "Changelog" reduce_headings: true ================================================ FILE: .gitignore ================================================ # Dependencies node_modules/ npm-debug.log* yarn-debug.log* yarn-error.log* pnpm-debug.log* lerna-debug.log* # Build outputs dist/ dist-ssr/ build/ out/ # Environment variables .env .env.local .env.development.local .env.test.local .env.production.local # IDE and editor files .vscode/ .idea/ *.swp *.swo *~ # OS generated files .DS_Store .DS_Store? ._* .Spotlight-V100 .Trashes ehthumbs.db Thumbs.db # Logs *.log logs/ # Runtime data pids *.pid *.seed *.pid.lock # Coverage directory used by tools like istanbul coverage/ *.lcov # nyc test coverage .nyc_output # Dependency directories jspm_packages/ # Optional npm cache directory .npm # Optional eslint cache .eslintcache # Optional REPL history .node_repl_history # Output of 'npm pack' *.tgz # Yarn Integrity file .yarn-integrity # dotenv environment variables file .env.test # parcel-bundler cache (https://parceljs.org/) .cache .parcel-cache # Next.js build output .next # Nuxt.js build / generate output .nuxt # Storybook build outputs .out .storybook-out # Temporary folders tmp/ temp/ .tmp/ # Vite .vite/ # Local Netlify folder .netlify # AI specific .claude/ .cursor/ .roo/ .taskmaster/ .cline/ .windsurf/ .serena/ CLAUDE.md .mcp.json .gemini/ # Database files *.db *.sqlite *.sqlite3 logs dev-debug.log # Editor directories and files .idea .vscode *.suo *.ntvs* *.njsproj *.sln *.sw? # OS specific # Task files tasks.json tasks/ # Translations !src/i18n/locales/en/tasks.json !src/i18n/locales/ja/tasks.json !src/i18n/locales/ru/tasks.json !src/i18n/locales/de/tasks.json # Git worktrees .worktrees/ ================================================ FILE: .gitmodules ================================================ [submodule "plugins/starter"] path = plugins/starter url = https://github.com/cloudcli-ai/cloudcli-plugin-starter.git ================================================ FILE: .husky/commit-msg ================================================ npx commitlint --edit $1 ================================================ FILE: .husky/pre-commit ================================================ npx lint-staged ================================================ FILE: .npmignore ================================================ *.md !README.md .env* .gitignore .nvmrc .release-it.json release.sh postcss.config.js vite.config.js tailwind.config.js # Database files authdb/ *.db *.sqlite *.sqlite3 # IDE and editor files .vscode/ .idea/ *.swp *.swo *~ # OS generated files .DS_Store .DS_Store? ._* .Spotlight-V100 .Trashes ehthumbs.db Thumbs.db # AI specific .claude/ .cursor/ .roo/ .taskmaster/ .cline/ .windsurf/ .serena/ CLAUDE.md .mcp.json # Task files tasks.json tasks/ # Environment variables .env .env.local .env.development.local .env.test.local .env.production.local ================================================ FILE: .nvmrc ================================================ v22 ================================================ FILE: .release-it.json ================================================ { "git": { "commitMessage": "chore(release): v${version}", "tagName": "v${version}", "requireBranch": "main", "requireCleanWorkingDir": true }, "npm": { "publish": true }, "github": { "release": true, "releaseName": "CloudCLI UI v${version}" }, "hooks": { "before:init": ["npm run build"] }, "plugins": { "@release-it/conventional-changelog": { "infile": "CHANGELOG.md", "header": "# Changelog\n\nAll notable changes to CloudCLI UI will be documented in this file.\n", "preset": { "name": "conventionalcommits", "types": [ { "type": "feat", "section": "New Features" }, { "type": "feature", "section": "New Features" }, { "type": "fix", "section": "Bug Fixes" }, { "type": "perf", "section": "Performance" }, { "type": "refactor", "section": "Refactoring" }, { "type": "docs", "section": "Documentation" }, { "type": "style", "section": "Styling" }, { "type": "chore", "section": "Maintenance" }, { "type": "ci", "section": "CI/CD" }, { "type": "test", "section": "Tests" }, { "type": "build", "section": "Build" } ] } } } } ================================================ FILE: CHANGELOG.md ================================================ # Changelog All notable changes to CloudCLI UI will be documented in this file. ## [1.26.0](https://github.com/siteboon/claudecodeui/compare/v1.25.2...v1.26.0) (2026-03-20) ### New Features * add German (Deutsch) language support ([#525](https://github.com/siteboon/claudecodeui/issues/525)) ([a7299c6](https://github.com/siteboon/claudecodeui/commit/a7299c68237908c752d504c2e8eea91570a30203)) * add WebSocket proxy for plugin backends ([#553](https://github.com/siteboon/claudecodeui/issues/553)) ([88c60b7](https://github.com/siteboon/claudecodeui/commit/88c60b70b031798d51ce26c8f080a0f64d824b05)) * Browser autofill support for login form ([#521](https://github.com/siteboon/claudecodeui/issues/521)) ([72ff134](https://github.com/siteboon/claudecodeui/commit/72ff134b315b7a1d602f3cc7dd60d47c1c1c34af)) * git panel redesign ([#535](https://github.com/siteboon/claudecodeui/issues/535)) ([adb3a06](https://github.com/siteboon/claudecodeui/commit/adb3a06d7e66a6d2dbcdfb501615e617178314af)) * introduce notification system and claude notifications ([#450](https://github.com/siteboon/claudecodeui/issues/450)) ([45e71a0](https://github.com/siteboon/claudecodeui/commit/45e71a0e73b368309544165e4dcf8b7fd014e8dd)) * **refactor:** move plugins to typescript ([#557](https://github.com/siteboon/claudecodeui/issues/557)) ([612390d](https://github.com/siteboon/claudecodeui/commit/612390db536417e2f68c501329bfccf5c6795e45)) * unified message architecture with provider adapters and session store ([#558](https://github.com/siteboon/claudecodeui/issues/558)) ([a4632dc](https://github.com/siteboon/claudecodeui/commit/a4632dc4cec228a8febb7c5bae4807c358963678)) ### Bug Fixes * detect Claude auth from settings env ([#527](https://github.com/siteboon/claudecodeui/issues/527)) ([95bcee0](https://github.com/siteboon/claudecodeui/commit/95bcee0ec459f186d52aeffe100ac1a024e92909)) * remove /exit command from claude login flow during onboarding ([#552](https://github.com/siteboon/claudecodeui/issues/552)) ([4de8b78](https://github.com/siteboon/claudecodeui/commit/4de8b78c6db5d8c2c402afce0f0b4cc16d5b6496)) ### Documentation * add German language link to all README files ([#534](https://github.com/siteboon/claudecodeui/issues/534)) ([1d31c3e](https://github.com/siteboon/claudecodeui/commit/1d31c3ec8309b433a041f3099955addc8c136c35)) * **readme:** hotfix and improve for README.jp.md ([#550](https://github.com/siteboon/claudecodeui/issues/550)) ([7413c2c](https://github.com/siteboon/claudecodeui/commit/7413c2c78422c308ac949e6a83c3e9216b24b649)) * **README:** update translations with CloudCLI branding and feature restructuring ([#544](https://github.com/siteboon/claudecodeui/issues/544)) ([14aef73](https://github.com/siteboon/claudecodeui/commit/14aef73cc6085fbb519fe64aea7cac80b7d51285)) ## [1.25.2](https://github.com/siteboon/claudecodeui/compare/v1.25.0...v1.25.2) (2026-03-11) ### New Features * **i18n:** localize plugin settings for all languages ([#515](https://github.com/siteboon/claudecodeui/issues/515)) ([621853c](https://github.com/siteboon/claudecodeui/commit/621853cbfb4233b34cb8cc2e1ed10917ba424352)) ### Bug Fixes * codeql user value provided path validation ([aaa14b9](https://github.com/siteboon/claudecodeui/commit/aaa14b9fc0b9b51c4fb9d1dba40fada7cbbe0356)) * numerous bugs ([#528](https://github.com/siteboon/claudecodeui/issues/528)) ([a77f213](https://github.com/siteboon/claudecodeui/commit/a77f213dd5d0b2538dea091ab8da6e55d2002f2f)) * **security:** disable executable gray-matter frontmatter in commands ([b9c902b](https://github.com/siteboon/claudecodeui/commit/b9c902b016f411a942c8707dd07d32b60bad087c)) * session reconnect catch-up, always-on input, frozen session recovery ([#524](https://github.com/siteboon/claudecodeui/issues/524)) ([4d8fb6e](https://github.com/siteboon/claudecodeui/commit/4d8fb6e30aa03d7cdb92bd62b7709422f9d08e32)) ### Refactoring * new settings page design and new pill component ([8ddeeb0](https://github.com/siteboon/claudecodeui/commit/8ddeeb0ce8d0642560bd3fa149236011dc6e3707)) ## [1.25.0](https://github.com/siteboon/claudecodeui/compare/v1.24.0...v1.25.0) (2026-03-10) ### New Features * add copy as text or markdown feature for assistant messages ([#519](https://github.com/siteboon/claudecodeui/issues/519)) ([1dc2a20](https://github.com/siteboon/claudecodeui/commit/1dc2a205dc2a3cbf960625d7669c7c63a2b6905f)) * add full Russian language support; update Readme.md files, and .gitignore update ([#514](https://github.com/siteboon/claudecodeui/issues/514)) ([c7dcba8](https://github.com/siteboon/claudecodeui/commit/c7dcba8d9117e84db8aac7d8a7bf6a3aa683e115)) * new plugin system ([#489](https://github.com/siteboon/claudecodeui/issues/489)) ([8afb46a](https://github.com/siteboon/claudecodeui/commit/8afb46af2e5514c9284030367281793fbb014e4f)) ### Bug Fixes * resolve duplicate key issue when rendering model options ([#520](https://github.com/siteboon/claudecodeui/issues/520)) ([9bceab9](https://github.com/siteboon/claudecodeui/commit/9bceab9e1a6e063b0b4f934ed2d9f854fcc9c6a4)) ### Maintenance * add plugins section in readme ([e581a0e](https://github.com/siteboon/claudecodeui/commit/e581a0e1ccd59fd7ec7306ca76a13e73d7c674c1)) ## [1.24.0](https://github.com/siteboon/claudecodeui/compare/v1.23.2...v1.24.0) (2026-03-09) ### New Features * add full-text search across conversations ([#482](https://github.com/siteboon/claudecodeui/issues/482)) ([3950c0e](https://github.com/siteboon/claudecodeui/commit/3950c0e47f41e93227af31494690818d45c8bc7a)) ### Bug Fixes * **git:** prevent shell injection in git routes ([86c33c1](https://github.com/siteboon/claudecodeui/commit/86c33c1c0cb34176725a38f46960213714fc3e04)) * replace getDatabase with better-sqlite3 db in getGithubTokenById ([#501](https://github.com/siteboon/claudecodeui/issues/501)) ([cb4fd79](https://github.com/siteboon/claudecodeui/commit/cb4fd795c938b1cc86d47f401973bfccdd68fdee)) ## [1.23.2](https://github.com/siteboon/claudecodeui/compare/v1.22.1...v1.23.2) (2026-03-06) ### New Features * add clickable overlay buttons for CLI prompts in Shell terminal ([#480](https://github.com/siteboon/claudecodeui/issues/480)) ([2444209](https://github.com/siteboon/claudecodeui/commit/2444209723701dda2b881cea2501b239e64e51c1)), closes [#427](https://github.com/siteboon/claudecodeui/issues/427) * add terminal shortcuts panel for mobile ([#411](https://github.com/siteboon/claudecodeui/issues/411)) ([b0a3fdf](https://github.com/siteboon/claudecodeui/commit/b0a3fdf95ffdb961261194d10400267251e42f17)) * implement session rename with SQLite storage ([#413](https://github.com/siteboon/claudecodeui/issues/413)) ([198e3da](https://github.com/siteboon/claudecodeui/commit/198e3da89b353780f53a91888384da9118995e81)), closes [#72](https://github.com/siteboon/claudecodeui/issues/72) [#358](https://github.com/siteboon/claudecodeui/issues/358) ### Bug Fixes * **chat:** finalize terminal lifecycle to prevent stuck processing/thinking UI ([#483](https://github.com/siteboon/claudecodeui/issues/483)) ([0590c5c](https://github.com/siteboon/claudecodeui/commit/0590c5c178f4791e2b039d525ecca4d220c3dcae)) * **codex-history:** prevent AGENTS.md/internal prompt leakage when reloading Codex sessions ([#488](https://github.com/siteboon/claudecodeui/issues/488)) ([64a96b2](https://github.com/siteboon/claudecodeui/commit/64a96b24f853acb802f700810b302f0f5cf00898)) * preserve pending permission requests across WebSocket reconnections ([#462](https://github.com/siteboon/claudecodeui/issues/462)) ([4ee88f0](https://github.com/siteboon/claudecodeui/commit/4ee88f0eb0c648b54b05f006c6796fb7b09b0fae)) * prevent React 18 batching from losing messages during session sync ([#461](https://github.com/siteboon/claudecodeui/issues/461)) ([688d734](https://github.com/siteboon/claudecodeui/commit/688d73477a50773e43c85addc96212aa6290aea5)) * release it script ([dcea8a3](https://github.com/siteboon/claudecodeui/commit/dcea8a329c7d68437e1e72c8c766cf33c74637e9)) ### Styling * improve UI for processing banner ([#477](https://github.com/siteboon/claudecodeui/issues/477)) ([2320e1d](https://github.com/siteboon/claudecodeui/commit/2320e1d74b59c65b5b7fc4fa8b05fd9208f4898c)) ### Maintenance * remove logging of received WebSocket messages in production ([#487](https://github.com/siteboon/claudecodeui/issues/487)) ([9193feb](https://github.com/siteboon/claudecodeui/commit/9193feb6dc83041f3c365204648a88468bdc001b)) ## [1.22.0](https://github.com/siteboon/claudecodeui/compare/v1.21.0...v1.22.0) (2026-03-03) ### New Features * add community button in the app ([84d4634](https://github.com/siteboon/claudecodeui/commit/84d4634735f9ee13ac1c20faa0e7e31f1b77cae8)) * Advanced file editor and file tree improvements ([#444](https://github.com/siteboon/claudecodeui/issues/444)) ([9768958](https://github.com/siteboon/claudecodeui/commit/97689588aa2e8240ba4373da5f42ab444c772e72)) * update document title based on selected project ([#448](https://github.com/siteboon/claudecodeui/issues/448)) ([9e22f42](https://github.com/siteboon/claudecodeui/commit/9e22f42a3d3a781f448ddac9d133292fe103bb8c)) ### Bug Fixes * **claude:** correct project encoded path ([#451](https://github.com/siteboon/claudecodeui/issues/451)) ([9c0e864](https://github.com/siteboon/claudecodeui/commit/9c0e864532dcc5ce7ee890d3b4db722872db2b54)), closes [#447](https://github.com/siteboon/claudecodeui/issues/447) * **claude:** move model usage log to result message only ([#454](https://github.com/siteboon/claudecodeui/issues/454)) ([506d431](https://github.com/siteboon/claudecodeui/commit/506d43144b3ec3155c3e589e7e803862c4a8f83a)) * missing translation label ([855e22f](https://github.com/siteboon/claudecodeui/commit/855e22f9176a71daa51de716370af7f19d55bfb4)) ### Maintenance * add Gemini-CLI support to README ([#453](https://github.com/siteboon/claudecodeui/issues/453)) ([503c384](https://github.com/siteboon/claudecodeui/commit/503c3846850fb843781979b0c0e10a24b07e1a4b)) ## [1.21.0](https://github.com/siteboon/claudecodeui/compare/v1.20.1...v1.21.0) (2026-02-27) ### New Features * add copy icon for user messages ([#449](https://github.com/siteboon/claudecodeui/issues/449)) ([b359c51](https://github.com/siteboon/claudecodeui/commit/b359c515277b4266fde2fb9a29b5356949c07c4f)) * Google's gemini-cli integration ([#422](https://github.com/siteboon/claudecodeui/issues/422)) ([a367edd](https://github.com/siteboon/claudecodeui/commit/a367edd51578608b3281373cb4a95169dbf17f89)) * persist active tab across reloads via localStorage ([#414](https://github.com/siteboon/claudecodeui/issues/414)) ([e3b6892](https://github.com/siteboon/claudecodeui/commit/e3b689214f11d549ffe1b3a347476d58f25c5aca)), closes [#387](https://github.com/siteboon/claudecodeui/issues/387) ### Bug Fixes * add support for Codex in the shell ([#424](https://github.com/siteboon/claudecodeui/issues/424)) ([23801e9](https://github.com/siteboon/claudecodeui/commit/23801e9cc15d2b8d1bfc6e39aee2fae93226d1ad)) ### Maintenance * upgrade @anthropic-ai/claude-agent-sdk to version 0.2.59 and add model usage logging ([#446](https://github.com/siteboon/claudecodeui/issues/446)) ([917c353](https://github.com/siteboon/claudecodeui/commit/917c353115653ee288bf97be01f62fad24123cbc)) * upgrade better-sqlite to latest version to support node 25 ([#445](https://github.com/siteboon/claudecodeui/issues/445)) ([4ab94fc](https://github.com/siteboon/claudecodeui/commit/4ab94fce4257e1e20370fa83fa4c0f6fadbb8a2b)) ## [1.20.1](https://github.com/siteboon/claudecodeui/compare/v1.19.1...v1.20.1) (2026-02-23) ### New Features * implement install mode detection and update commands in version upgrade process ([f986004](https://github.com/siteboon/claudecodeui/commit/f986004319207b068431f9f6adf338a8ce8decfc)) * migrate legacy database to new location and improve last login update handling ([50e097d](https://github.com/siteboon/claudecodeui/commit/50e097d4ac498aa9f1803ef3564843721833dc19)) ## [1.19.1](https://github.com/siteboon/claudecodeui/compare/v1.19.0...v1.19.1) (2026-02-23) ### Bug Fixes * add prepublishOnly script to build before publishing ([82efac4](https://github.com/siteboon/claudecodeui/commit/82efac4704cab11ed8d1a05fe84f41312140b223)) ## [1.19.0](https://github.com/siteboon/claudecodeui/compare/v1.18.2...v1.19.0) (2026-02-23) ### New Features * add HOST environment variable for configurable bind address ([#360](https://github.com/siteboon/claudecodeui/issues/360)) ([cccd915](https://github.com/siteboon/claudecodeui/commit/cccd915c336192216b6e6f68e2b5f3ece0ccf966)) * subagent tool grouping ([#398](https://github.com/siteboon/claudecodeui/issues/398)) ([0207a1f](https://github.com/siteboon/claudecodeui/commit/0207a1f3a3c87f1c6c1aee8213be999b23289386)) ### Bug Fixes * **macos:** fix node-pty posix_spawnp error with postinstall script ([#347](https://github.com/siteboon/claudecodeui/issues/347)) ([38a593c](https://github.com/siteboon/claudecodeui/commit/38a593c97fdb2bb7f051e09e8e99c16035448655)), closes [#284](https://github.com/siteboon/claudecodeui/issues/284) * slash commands with arguments bypass command execution ([#392](https://github.com/siteboon/claudecodeui/issues/392)) ([597e9c5](https://github.com/siteboon/claudecodeui/commit/597e9c54b76e7c6cd1947299c668c78d24019cab)) ### Refactoring * **releases:** Create a contributing guide and proper release notes using a release-it plugin ([fc369d0](https://github.com/siteboon/claudecodeui/commit/fc369d047e13cba9443fe36c0b6bb2ce3beaf61c)) ### Maintenance * update @anthropic-ai/claude-agent-sdk to version 0.1.77 in package-lock.json ([#410](https://github.com/siteboon/claudecodeui/issues/410)) ([7ccbc8d](https://github.com/siteboon/claudecodeui/commit/7ccbc8d92d440e18c157b656c9ea2635044a64f6)) ================================================ FILE: CONTRIBUTING.md ================================================ # Contributing to CloudCLI UI Thanks for your interest in contributing to CloudCLI UI! Before you start, please take a moment to read through this guide. ## Before You Start - **Search first.** Check [existing issues](https://github.com/siteboon/claudecodeui/issues) and [pull requests](https://github.com/siteboon/claudecodeui/pulls) to avoid duplicating work. - **Discuss first** for new features. Open an [issue](https://github.com/siteboon/claudecodeui/issues/new) to discuss your idea before investing time in implementation. We may already have plans or opinions on how it should work. - **Bug fixes are always welcome.** If you spot a bug, feel free to open a PR directly. ## Prerequisites - [Node.js](https://nodejs.org/) 22 or later - [Claude Code CLI](https://docs.anthropic.com/en/docs/claude-code) installed and configured ## Getting Started 1. Fork the repository 2. Clone your fork: ```bash git clone https://github.com//claudecodeui.git cd claudecodeui ``` 3. Install dependencies: ```bash npm install ``` 4. Start the development server: ```bash npm run dev ``` 5. Create a branch for your changes: ```bash git checkout -b feat/your-feature-name ``` ## Project Structure ``` claudecodeui/ ├── src/ # React frontend (Vite + Tailwind) │ ├── components/ # UI components │ ├── contexts/ # React context providers │ ├── hooks/ # Custom React hooks │ ├── i18n/ # Internationalization and translations │ ├── lib/ # Shared frontend libraries │ ├── types/ # TypeScript type definitions │ └── utils/ # Frontend utilities ├── server/ # Express backend │ ├── routes/ # API route handlers │ ├── middleware/ # Express middleware │ ├── database/ # SQLite database layer │ └── tools/ # CLI tool integrations ├── shared/ # Code shared between client and server └── public/ # Static assets, icons, PWA manifest ``` ## Development Workflow - `npm run dev` — Start both the frontend and backend in development mode - `npm run build` — Create a production build - `npm run server` — Start only the backend server - `npm run client` — Start only the Vite dev server ## Making Changes ### Bug Fixes - Reference the issue number in your PR if one exists - Describe how to reproduce the bug in your PR description - Add a screenshot or recording for visual bugs ### New Features - Keep the scope focused — one feature per PR - Include screenshots or recordings for UI changes ### Documentation - Documentation improvements are always welcome - Keep language clear and concise ## Commit Convention We follow [Conventional Commits](https://conventionalcommits.org/) to generate release notes automatically. Every commit message should follow this format: ``` (optional scope): ``` Use imperative, present tense: "add feature" not "added feature" or "adds feature". ### Types | Type | Description | |------|-------------| | `feat` | A new feature | | `fix` | A bug fix | | `perf` | A performance improvement | | `refactor` | Code change that neither fixes a bug nor adds a feature | | `docs` | Documentation only | | `style` | CSS, formatting, visual changes | | `chore` | Maintenance, dependencies, config | | `ci` | CI/CD pipeline changes | | `test` | Adding or updating tests | | `build` | Build system changes | ### Examples ```bash feat: add conversation search feat(i18n): add Japanese language support fix: redirect unauthenticated users to login fix(editor): syntax highlighting for .env files perf: lazy load code editor component refactor(chat): extract message list component docs: update API configuration guide ``` ### Breaking Changes Add `!` after the type or include `BREAKING CHANGE:` in the commit footer: ```bash feat!: redesign settings page layout ``` ## Pull Requests - Give your PR a clear, descriptive title following the commit convention above - Fill in the PR description with what changed and why - Link any related issues - Include screenshots for UI changes - Make sure the build passes (`npm run build`) - Keep PRs focused — avoid unrelated changes ## Releases Releases are managed by maintainers using [release-it](https://github.com/release-it/release-it) with the [conventional changelog plugin](https://github.com/release-it/conventional-changelog). ```bash npm run release # interactive (prompts for version bump) npm run release -- patch # patch release npm run release -- minor # minor release ``` This automatically: - Bumps the version based on commit types (`feat` = minor, `fix` = patch) - Generates categorized release notes - Updates `CHANGELOG.md` - Creates a git tag and GitHub Release - Publishes to npm ## License By contributing, you agree that your contributions will be licensed under the [GPL-3.0 License](LICENSE). ================================================ FILE: LICENSE ================================================ # GNU GENERAL PUBLIC LICENSE Version 3, 29 June 2007 Copyright (C) 2007 Free Software Foundation, Inc. Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. ## Preamble The GNU General Public License is a free, copyleft license for software and other kinds of works. The licenses for most software and other practical works are designed to take away your freedom to share and change the works. By contrast, the GNU General Public License is intended to guarantee your freedom to share and change all versions of a program--to make sure it remains free software for all its users. We, the Free Software Foundation, use the GNU General Public License for most of our software; it applies also to any other work released this way by its authors. You can apply it to your programs, too. When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for them if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs, and that you know you can do these things. To protect your rights, we need to prevent others from denying you these rights or asking you to surrender the rights. Therefore, you have certain responsibilities if you distribute copies of the software, or if you modify it: responsibilities to respect the freedom of others. For example, if you distribute copies of such a program, whether gratis or for a fee, you must pass on to the recipients the same freedoms that you received. You must make sure that they, too, receive or can get the source code. And you must show them these terms so they know their rights. Developers that use the GNU GPL protect your rights with two steps: (1) assert copyright on the software, and (2) offer you this License giving you legal permission to copy, distribute and/or modify it. For the developers' and authors' protection, the GPL clearly explains that there is no warranty for this free software. For both users' and authors' sake, the GPL requires that modified versions be marked as changed, so that their problems will not be attributed erroneously to authors of previous versions. Some devices are designed to deny users access to install or run modified versions of the software inside them, although the manufacturer can do so. This is fundamentally incompatible with the aim of protecting users' freedom to change the software. The systematic pattern of such abuse occurs in the area of products for individuals to use, which is precisely where it is most unacceptable. Therefore, we have designed this version of the GPL to prohibit the practice for those products. If such problems arise substantially in other domains, we stand ready to extend this provision to those domains in future versions of the GPL, as needed to protect the freedom of users. Finally, every program is threatened constantly by software patents. States should not allow patents to restrict development and use of software on general-purpose computers, but in those that do, we wish to avoid the special danger that patents applied to a free program could make it effectively proprietary. To prevent this, the GPL assures that patents cannot be used to render the program non-free. The precise terms and conditions for copying, distribution and modification follow. ## TERMS AND CONDITIONS ### 0. Definitions. "This License" refers to version 3 of the GNU General Public License. "Copyright" also means copyright-like laws that apply to other kinds of works, such as semiconductor masks. "The Program" refers to any copyrightable work licensed under this License. Each licensee is addressed as "you". "Licensees" and "recipients" may be individuals or organizations. To "modify" a work means to copy from or adapt all or part of the work in a fashion requiring copyright permission, other than the making of an exact copy. The resulting work is called a "modified version" of the earlier work or a work "based on" the earlier work. A "covered work" means either the unmodified Program or a work based on the Program. To "propagate" a work means to do anything with it that, without permission, would make you directly or secondarily liable for infringement under applicable copyright law, except executing it on a computer or modifying a private copy. Propagation includes copying, distribution (with or without modification), making available to the public, and in some countries other activities as well. To "convey" a work means any kind of propagation that enables other parties to make or receive copies. Mere interaction with a user through a computer network, with no transfer of a copy, is not conveying. An interactive user interface displays "Appropriate Legal Notices" to the extent that it includes a convenient and prominently visible feature that (1) displays an appropriate copyright notice, and (2) tells the user that there is no warranty for the work (except to the extent that warranties are provided), that licensees may convey the work under this License, and how to view a copy of this License. If the interface presents a list of user commands or options, such as a menu, a prominent item in the list meets this criterion. ### 1. Source Code. The "source code" for a work means the preferred form of the work for making modifications to it. "Object code" means any non-source form of a work. A "Standard Interface" means an interface that either is an official standard defined by a recognized standards body, or, in the case of interfaces specified for a particular programming language, one that is widely used among developers working in that language. The "System Libraries" of an executable work include anything, other than the work as a whole, that (a) is included in the normal form of packaging a Major Component, but which is not part of that Major Component, and (b) serves only to enable use of the work with that Major Component, or to implement a Standard Interface for which an implementation is available to the public in source code form. A "Major Component", in this context, means a major essential component (kernel, window system, and so on) of the specific operating system (if any) on which the executable work runs, or a compiler used to produce the work, or an object code interpreter used to run it. The "Corresponding Source" for a work in object code form means all the source code needed to generate, install, and (for an executable work) run the object code and to modify the work, including scripts to control those activities. However, it does not include the work's System Libraries, or general-purpose tools or generally available free programs which are used unmodified in performing those activities but which are not part of the work. For example, Corresponding Source includes interface definition files associated with source files for the work, and the source code for shared libraries and dynamically linked subprograms that the work is specifically designed to require, such as by intimate data communication or control flow between those subprograms and other parts of the work. The Corresponding Source need not include anything that users can regenerate automatically from other parts of the Corresponding Source. The Corresponding Source for a work in source code form is that same work. ### 2. Basic Permissions. All rights granted under this License are granted for the term of copyright on the Program, and are irrevocable provided the stated conditions are met. This License explicitly affirms your unlimited permission to run the unmodified Program. The output from running a covered work is covered by this License only if the output, given its content, constitutes a covered work. This License acknowledges your rights of fair use or other equivalent, as provided by copyright law. You may make, run and propagate covered works that you do not convey, without conditions so long as your license otherwise remains in force. You may convey covered works to others for the sole purpose of having them make modifications exclusively for you, or provide you with facilities for running those works, provided that you comply with the terms of this License in conveying all material for which you do not control copyright. Those thus making or running the covered works for you must do so exclusively on your behalf, under your direction and control, on terms that prohibit them from making any copies of your copyrighted material outside their relationship with you. Conveying under any other circumstances is permitted solely under the conditions stated below. Sublicensing is not allowed; section 10 makes it unnecessary. ### 3. Protecting Users' Legal Rights From Anti-Circumvention Law. No covered work shall be deemed part of an effective technological measure under any applicable law fulfilling obligations under article 11 of the WIPO copyright treaty adopted on 20 December 1996, or similar laws prohibiting or restricting circumvention of such measures. When you convey a covered work, you waive any legal power to forbid circumvention of technological measures to the extent such circumvention is effected by exercising rights under this License with respect to the covered work, and you disclaim any intention to limit operation or modification of the work as a means of enforcing, against the work's users, your or third parties' legal rights to forbid circumvention of technological measures. ### 4. Conveying Verbatim Copies. You may convey verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice; keep intact all notices stating that this License and any non-permissive terms added in accord with section 7 apply to the code; keep intact all notices of the absence of any warranty; and give all recipients a copy of this License along with the Program. You may charge any price or no price for each copy that you convey, and you may offer support or warranty protection for a fee. ### 5. Conveying Modified Source Versions. You may convey a work based on the Program, or the modifications to produce it from the Program, in the form of source code under the terms of section 4, provided that you also meet all of these conditions: - a) The work must carry prominent notices stating that you modified it, and giving a relevant date. - b) The work must carry prominent notices stating that it is released under this License and any conditions added under section 7. This requirement modifies the requirement in section 4 to "keep intact all notices". - c) You must license the entire work, as a whole, under this License to anyone who comes into possession of a copy. This License will therefore apply, along with any applicable section 7 additional terms, to the whole of the work, and all its parts, regardless of how they are packaged. This License gives no permission to license the work in any other way, but it does not invalidate such permission if you have separately received it. - d) If the work has interactive user interfaces, each must display Appropriate Legal Notices; however, if the Program has interactive interfaces that do not display Appropriate Legal Notices, your work need not make them do so. A compilation of a covered work with other separate and independent works, which are not by their nature extensions of the covered work, and which are not combined with it such as to form a larger program, in or on a volume of a storage or distribution medium, is called an "aggregate" if the compilation and its resulting copyright are not used to limit the access or legal rights of the compilation's users beyond what the individual works permit. Inclusion of a covered work in an aggregate does not cause this License to apply to the other parts of the aggregate. ### 6. Conveying Non-Source Forms. You may convey a covered work in object code form under the terms of sections 4 and 5, provided that you also convey the machine-readable Corresponding Source under the terms of this License, in one of these ways: - a) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by the Corresponding Source fixed on a durable physical medium customarily used for software interchange. - b) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by a written offer, valid for at least three years and valid for as long as you offer spare parts or customer support for that product model, to give anyone who possesses the object code either (1) a copy of the Corresponding Source for all the software in the product that is covered by this License, on a durable physical medium customarily used for software interchange, for a price no more than your reasonable cost of physically performing this conveying of source, or (2) access to copy the Corresponding Source from a network server at no charge. - c) Convey individual copies of the object code with a copy of the written offer to provide the Corresponding Source. This alternative is allowed only occasionally and noncommercially, and only if you received the object code with such an offer, in accord with subsection 6b. - d) Convey the object code by offering access from a designated place (gratis or for a charge), and offer equivalent access to the Corresponding Source in the same way through the same place at no further charge. You need not require recipients to copy the Corresponding Source along with the object code. If the place to copy the object code is a network server, the Corresponding Source may be on a different server (operated by you or a third party) that supports equivalent copying facilities, provided you maintain clear directions next to the object code saying where to find the Corresponding Source. Regardless of what server hosts the Corresponding Source, you remain obligated to ensure that it is available for as long as needed to satisfy these requirements. - e) Convey the object code using peer-to-peer transmission, provided you inform other peers where the object code and Corresponding Source of the work are being offered to the general public at no charge under subsection 6d. A separable portion of the object code, whose source code is excluded from the Corresponding Source as a System Library, need not be included in conveying the object code work. A "User Product" is either (1) a "consumer product", which means any tangible personal property which is normally used for personal, family, or household purposes, or (2) anything designed or sold for incorporation into a dwelling. In determining whether a product is a consumer product, doubtful cases shall be resolved in favor of coverage. For a particular product received by a particular user, "normally used" refers to a typical or common use of that class of product, regardless of the status of the particular user or of the way in which the particular user actually uses, or expects or is expected to use, the product. A product is a consumer product regardless of whether the product has substantial commercial, industrial or non-consumer uses, unless such uses represent the only significant mode of use of the product. "Installation Information" for a User Product means any methods, procedures, authorization keys, or other information required to install and execute modified versions of a covered work in that User Product from a modified version of its Corresponding Source. The information must suffice to ensure that the continued functioning of the modified object code is in no case prevented or interfered with solely because modification has been made. If you convey an object code work under this section in, or with, or specifically for use in, a User Product, and the conveying occurs as part of a transaction in which the right of possession and use of the User Product is transferred to the recipient in perpetuity or for a fixed term (regardless of how the transaction is characterized), the Corresponding Source conveyed under this section must be accompanied by the Installation Information. But this requirement does not apply if neither you nor any third party retains the ability to install modified object code on the User Product (for example, the work has been installed in ROM). The requirement to provide Installation Information does not include a requirement to continue to provide support service, warranty, or updates for a work that has been modified or installed by the recipient, or for the User Product in which it has been modified or installed. Access to a network may be denied when the modification itself materially and adversely affects the operation of the network or violates the rules and protocols for communication across the network. Corresponding Source conveyed, and Installation Information provided, in accord with this section must be in a format that is publicly documented (and with an implementation available to the public in source code form), and must require no special password or key for unpacking, reading or copying. ### 7. Additional Terms. "Additional permissions" are terms that supplement the terms of this License by making exceptions from one or more of its conditions. Additional permissions that are applicable to the entire Program shall be treated as though they were included in this License, to the extent that they are valid under applicable law. If additional permissions apply only to part of the Program, that part may be used separately under those permissions, but the entire Program remains governed by this License without regard to the additional permissions. When you convey a copy of a covered work, you may at your option remove any additional permissions from that copy, or from any part of it. (Additional permissions may be written to require their own removal in certain cases when you modify the work.) You may place additional permissions on material, added by you to a covered work, for which you have or can give appropriate copyright permission. Notwithstanding any other provision of this License, for material you add to a covered work, you may (if authorized by the copyright holders of that material) supplement the terms of this License with terms: - a) Disclaiming warranty or limiting liability differently from the terms of sections 15 and 16 of this License; or - b) Requiring preservation of specified reasonable legal notices or author attributions in that material or in the Appropriate Legal Notices displayed by works containing it; or - c) Prohibiting misrepresentation of the origin of that material, or requiring that modified versions of such material be marked in reasonable ways as different from the original version; or - d) Limiting the use for publicity purposes of names of licensors or authors of the material; or - e) Declining to grant rights under trademark law for use of some trade names, trademarks, or service marks; or - f) Requiring indemnification of licensors and authors of that material by anyone who conveys the material (or modified versions of it) with contractual assumptions of liability to the recipient, for any liability that these contractual assumptions directly impose on those licensors and authors. All other non-permissive additional terms are considered "further restrictions" within the meaning of section 10. If the Program as you received it, or any part of it, contains a notice stating that it is governed by this License along with a term that is a further restriction, you may remove that term. If a license document contains a further restriction but permits relicensing or conveying under this License, you may add to a covered work material governed by the terms of that license document, provided that the further restriction does not survive such relicensing or conveying. If you add terms to a covered work in accord with this section, you must place, in the relevant source files, a statement of the additional terms that apply to those files, or a notice indicating where to find the applicable terms. Additional terms, permissive or non-permissive, may be stated in the form of a separately written license, or stated as exceptions; the above requirements apply either way. ### 8. Termination. You may not propagate or modify a covered work except as expressly provided under this License. Any attempt otherwise to propagate or modify it is void, and will automatically terminate your rights under this License (including any patent licenses granted under the third paragraph of section 11). However, if you cease all violation of this License, then your license from a particular copyright holder is reinstated (a) provisionally, unless and until the copyright holder explicitly and finally terminates your license, and (b) permanently, if the copyright holder fails to notify you of the violation by some reasonable means prior to 60 days after the cessation. Moreover, your license from a particular copyright holder is reinstated permanently if the copyright holder notifies you of the violation by some reasonable means, this is the first time you have received notice of violation of this License (for any work) from that copyright holder, and you cure the violation prior to 30 days after your receipt of the notice. Termination of your rights under this section does not terminate the licenses of parties who have received copies or rights from you under this License. If your rights have been terminated and not permanently reinstated, you do not qualify to receive new licenses for the same material under section 10. ### 9. Acceptance Not Required for Having Copies. You are not required to accept this License in order to receive or run a copy of the Program. Ancillary propagation of a covered work occurring solely as a consequence of using peer-to-peer transmission to receive a copy likewise does not require acceptance. However, nothing other than this License grants you permission to propagate or modify any covered work. These actions infringe copyright if you do not accept this License. Therefore, by modifying or propagating a covered work, you indicate your acceptance of this License to do so. ### 10. Automatic Licensing of Downstream Recipients. Each time you convey a covered work, the recipient automatically receives a license from the original licensors, to run, modify and propagate that work, subject to this License. You are not responsible for enforcing compliance by third parties with this License. An "entity transaction" is a transaction transferring control of an organization, or substantially all assets of one, or subdividing an organization, or merging organizations. If propagation of a covered work results from an entity transaction, each party to that transaction who receives a copy of the work also receives whatever licenses to the work the party's predecessor in interest had or could give under the previous paragraph, plus a right to possession of the Corresponding Source of the work from the predecessor in interest, if the predecessor has it or can get it with reasonable efforts. You may not impose any further restrictions on the exercise of the rights granted or affirmed under this License. For example, you may not impose a license fee, royalty, or other charge for exercise of rights granted under this License, and you may not initiate litigation (including a cross-claim or counterclaim in a lawsuit) alleging that any patent claim is infringed by making, using, selling, offering for sale, or importing the Program or any portion of it. ### 11. Patents. A "contributor" is a copyright holder who authorizes use under this License of the Program or a work on which the Program is based. The work thus licensed is called the contributor's "contributor version". A contributor's "essential patent claims" are all patent claims owned or controlled by the contributor, whether already acquired or hereafter acquired, that would be infringed by some manner, permitted by this License, of making, using, or selling its contributor version, but do not include claims that would be infringed only as a consequence of further modification of the contributor version. For purposes of this definition, "control" includes the right to grant patent sublicenses in a manner consistent with the requirements of this License. Each contributor grants you a non-exclusive, worldwide, royalty-free patent license under the contributor's essential patent claims, to make, use, sell, offer for sale, import and otherwise run, modify and propagate the contents of its contributor version. In the following three paragraphs, a "patent license" is any express agreement or commitment, however denominated, not to enforce a patent (such as an express permission to practice a patent or covenant not to sue for patent infringement). To "grant" such a patent license to a party means to make such an agreement or commitment not to enforce a patent against the party. If you convey a covered work, knowingly relying on a patent license, and the Corresponding Source of the work is not available for anyone to copy, free of charge and under the terms of this License, through a publicly available network server or other readily accessible means, then you must either (1) cause the Corresponding Source to be so available, or (2) arrange to deprive yourself of the benefit of the patent license for this particular work, or (3) arrange, in a manner consistent with the requirements of this License, to extend the patent license to downstream recipients. "Knowingly relying" means you have actual knowledge that, but for the patent license, your conveying the covered work in a country, or your recipient's use of the covered work in a country, would infringe one or more identifiable patents in that country that you have reason to believe are valid. If, pursuant to or in connection with a single transaction or arrangement, you convey, or propagate by procuring conveyance of, a covered work, and grant a patent license to some of the parties receiving the covered work authorizing them to use, propagate, modify or convey a specific copy of the covered work, then the patent license you grant is automatically extended to all recipients of the covered work and works based on it. A patent license is "discriminatory" if it does not include within the scope of its coverage, prohibits the exercise of, or is conditioned on the non-exercise of one or more of the rights that are specifically granted under this License. You may not convey a covered work if you are a party to an arrangement with a third party that is in the business of distributing software, under which you make payment to the third party based on the extent of your activity of conveying the work, and under which the third party grants, to any of the parties who would receive the covered work from you, a discriminatory patent license (a) in connection with copies of the covered work conveyed by you (or copies made from those copies), or (b) primarily for and in connection with specific products or compilations that contain the covered work, unless you entered into that arrangement, or that patent license was granted, prior to 28 March 2007. Nothing in this License shall be construed as excluding or limiting any implied license or other defenses to infringement that may otherwise be available to you under applicable patent law. ### 12. No Surrender of Others' Freedom. If conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot convey a covered work so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not convey it at all. For example, if you agree to terms that obligate you to collect a royalty for further conveying from those to whom you convey the Program, the only way you could satisfy both those terms and this License would be to refrain entirely from conveying the Program. ### 13. Use with the GNU Affero General Public License. Notwithstanding any other provision of this License, you have permission to link or combine any covered work with a work licensed under version 3 of the GNU Affero General Public License into a single combined work, and to convey the resulting work. The terms of this License will continue to apply to the part which is the covered work, but the special requirements of the GNU Affero General Public License, section 13, concerning interaction through a network will apply to the combination as such. ### 14. Revised Versions of this License. The Free Software Foundation may publish revised and/or new versions of the GNU General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. Each version is given a distinguishing version number. If the Program specifies that a certain numbered version of the GNU General Public License "or any later version" applies to it, you have the option of following the terms and conditions either of that numbered version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of the GNU General Public License, you may choose any version ever published by the Free Software Foundation. If the Program specifies that a proxy can decide which future versions of the GNU General Public License can be used, that proxy's public statement of acceptance of a version permanently authorizes you to choose that version for the Program. Later license versions may give you additional or different permissions. However, no additional obligations are imposed on any author or copyright holder as a result of your choosing to follow a later version. ### 15. Disclaimer of Warranty. THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. ### 16. Limitation of Liability. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. ### 17. Interpretation of Sections 15 and 16. If the disclaimer of warranty and limitation of liability provided above cannot be given local legal effect according to their terms, reviewing courts shall apply local law that most closely approximates an absolute waiver of all civil liability in connection with the Program, unless a warranty or assumption of liability accompanies a copy of the Program in return for a fee. END OF TERMS AND CONDITIONS ## How to Apply These Terms to Your New Programs If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms. To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found. Copyright (C) This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see . Also add information on how to contact you by electronic and paper mail. If the program does terminal interaction, make it output a short notice like this when it starts in an interactive mode: Copyright (C) This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. This is free software, and you are welcome to redistribute it under certain conditions; type `show c' for details. The hypothetical commands \`show w' and \`show c' should show the appropriate parts of the General Public License. Of course, your program's commands might be different; for a GUI interface, you would use an "about box". You should also get your employer (if you work as a programmer) or school, if any, to sign a "copyright disclaimer" for the program, if necessary. For more information on this, and how to apply and follow the GNU GPL, see . The GNU General Public License does not permit incorporating your program into proprietary programs. If your program is a subroutine library, you may consider it more useful to permit linking proprietary applications with the library. If this is what you want to do, use the GNU Lesser General Public License instead of this License. But first, please read . ================================================ FILE: README.de.md ================================================
CloudCLI UI

Cloud CLI (auch bekannt als Claude Code UI)

Eine Desktop- und Mobile-Oberfläche für Claude Code, Cursor CLI, Codex und Gemini-CLI.
Lokal oder remote nutzbar – verwalte deine aktiven Projekte und Sitzungen von überall.

CloudCLI Cloud · Dokumentation · Discord · Fehler melden · Mitwirken

CloudCLI Cloud Join Community

siteboon%2Fclaudecodeui | Trendshift

--- ## Screenshots

Desktop-Ansicht

Desktop-Oberfläche
Hauptoberfläche mit Projektübersicht und Chat

Mobile-Erfahrung

Mobile-Oberfläche
Responsives mobiles Design mit Touch-Navigation

CLI-Auswahl

CLI-Auswahl
Wähle zwischen Claude Code, Gemini, Cursor CLI und Codex
## Funktionen - **Responsives Design** – Funktioniert nahtlos auf Desktop, Tablet und Mobilgerät, sodass du Agents auch vom Smartphone aus nutzen kannst - **Interaktives Chat-Interface** – Eingebaute Chat-Oberfläche für die reibungslose Kommunikation mit den Agents - **Integriertes Shell-Terminal** – Direkter Zugriff auf die Agents CLI über die eingebaute Shell-Funktionalität - **Datei-Explorer** – Interaktiver Dateibaum mit Syntaxhervorhebung und Live-Bearbeitung - **Git-Explorer** – Änderungen anzeigen, stagen und committen. Branches wechseln ebenfalls möglich - **Sitzungsverwaltung** – Gespräche fortsetzen, mehrere Sitzungen verwalten und Verlauf nachverfolgen - **Plugin-System** – CloudCLI mit eigenen Plugins erweitern – neue Tabs, Backend-Dienste und Integrationen hinzufügen. [Eigenes Plugin erstellen →](https://github.com/cloudcli-ai/cloudcli-plugin-starter) - **TaskMaster AI Integration** *(Optional)* – Erweitertes Projektmanagement mit KI-gestützter Aufgabenplanung, PRD-Parsing und Workflow-Automatisierung - **Modell-Kompatibilität** – Funktioniert mit Claude, GPT und Gemini (vollständige Liste unterstützter Modelle in [`shared/modelConstants.js`](shared/modelConstants.js)) ## Schnellstart ### CloudCLI Cloud (Empfohlen) Der schnellste Einstieg – keine lokale Einrichtung erforderlich. Erhalte eine vollständig verwaltete, containerisierte Entwicklungsumgebung, die über Web, Mobile App, API oder deine bevorzugte IDE erreichbar ist. **[Mit CloudCLI Cloud starten](https://cloudcli.ai)** ### Self-Hosted (Open Source) CloudCLI UI sofort mit **npx** ausprobieren (erfordert **Node.js** v22+): ```bash npx @siteboon/claude-code-ui ``` Oder **global** installieren für regelmäßige Nutzung: ```bash npm install -g @siteboon/claude-code-ui cloudcli ``` Öffne `http://localhost:3001` – alle vorhandenen Sitzungen werden automatisch erkannt. Die **[Dokumentation →](https://cloudcli.ai/docs)** enthält weitere Konfigurationsoptionen, PM2, Remote-Server-Einrichtung und mehr. --- ## Welche Option passt zu dir? CloudCLI UI ist die Open-Source-UI-Schicht, die CloudCLI Cloud antreibt. Du kannst es auf deinem eigenen Rechner selbst hosten oder CloudCLI Cloud nutzen, das darauf aufbaut und eine vollständig verwaltete Cloud-Umgebung, Team-Funktionen und tiefere Integrationen bietet. | | CloudCLI UI (Self-hosted) | CloudCLI Cloud | |---|---|---| | **Am besten für** | Entwickler:innen, die eine vollständige UI für lokale Agent-Sitzungen auf ihrem eigenen Rechner möchten | Teams und Entwickler:innen, die Agents in der Cloud betreiben möchten, überall erreichbar | | **Zugriff** | Browser via `[deineIP]:port` | Browser, jede IDE, REST API, n8n | | **Einrichtung** | `npx @siteboon/claude-code-ui` | Keine Einrichtung erforderlich | | **Rechner muss laufen** | Ja | Nein | | **Mobiler Zugriff** | Jeder Browser im Netzwerk | Jedes Gerät, native App in Entwicklung | | **Verfügbare Sitzungen** | Alle Sitzungen automatisch aus `~/.claude` erkannt | Alle Sitzungen in deiner Cloud-Umgebung | | **Unterstützte Agents** | Claude Code, Cursor CLI, Codex, Gemini CLI | Claude Code, Cursor CLI, Codex, Gemini CLI | | **Datei-Explorer und Git** | Ja, direkt in der UI | Ja, direkt in der UI | | **MCP-Konfiguration** | Über UI verwaltet, synchronisiert mit lokalem `~/.claude` | Über UI verwaltet | | **IDE-Zugriff** | Deine lokale IDE | Jede IDE, die mit deiner Cloud-Umgebung verbunden ist | | **REST API** | Ja | Ja | | **n8n-Node** | Nein | Ja | | **Team-Sharing** | Nein | Ja | | **Plattformkosten** | Kostenlos, Open Source | Ab $7/Monat | > Beide Optionen verwenden deine eigenen KI-Abonnements (Claude, Cursor usw.) – CloudCLI stellt die Umgebung bereit, nicht die KI. --- ## Sicherheit & Tool-Konfiguration **🔒 Wichtiger Hinweis**: Alle Claude Code Tools sind **standardmäßig deaktiviert**. Dies verhindert, dass potenziell schädliche Operationen automatisch ausgeführt werden. ### Tools aktivieren Um den vollen Funktionsumfang von Claude Code zu nutzen, müssen Tools manuell aktiviert werden: 1. **Tool-Einstellungen öffnen** – Klicke auf das Zahnrad-Symbol in der Seitenleiste 2. **Selektiv aktivieren** – Nur die benötigten Tools einschalten 3. **Einstellungen übernehmen** – Deine Einstellungen werden lokal gespeichert
![Tool-Einstellungen Modal](public/screenshots/tools-modal.png) *Tool-Einstellungen – nur aktivieren, was benötigt wird*
**Empfohlene Vorgehensweise**: Mit grundlegenden Tools starten und bei Bedarf weitere hinzufügen. Die Einstellungen können jederzeit angepasst werden. --- ## Plugins CloudCLI verfügt über ein Plugin-System, mit dem benutzerdefinierte Tabs mit eigener Frontend-UI und optionalem Node.js-Backend hinzugefügt werden können. Plugins können direkt in **Einstellungen > Plugins** aus Git-Repos installiert oder selbst entwickelt werden. ### Verfügbare Plugins | Plugin | Beschreibung | |---|---| | **[Project Stats](https://github.com/cloudcli-ai/cloudcli-plugin-starter)** | Zeigt Dateianzahl, Codezeilen, Dateityp-Aufschlüsselung, größte Dateien und zuletzt geänderte Dateien des aktuellen Projekts | ### Eigenes Plugin erstellen **[Plugin-Starter-Vorlage →](https://github.com/cloudcli-ai/cloudcli-plugin-starter)** – Forke dieses Repository, um ein eigenes Plugin zu erstellen. Es enthält ein funktionierendes Beispiel mit Frontend-Rendering, Live-Kontext-Updates und RPC-Kommunikation zu einem Backend-Server. **[Plugin-Dokumentation →](https://cloudcli.ai/docs/plugin-overview)** – Vollständige Anleitung zur Plugin-API, zum Manifest-Format, zum Sicherheitsmodell und mehr. --- ## FAQ
Wie unterscheidet sich das von Claude Code Remote Control? Claude Code Remote Control ermöglicht es, Nachrichten an eine bereits im lokalen Terminal laufende Sitzung zu senden. Der Rechner muss eingeschaltet bleiben, das Terminal muss offen bleiben, und Sitzungen laufen nach etwa 10 Minuten ohne Netzwerkverbindung ab. CloudCLI UI und CloudCLI Cloud erweitern Claude Code, anstatt neben ihm zu laufen – MCP-Server, Berechtigungen, Einstellungen und Sitzungen sind exakt dieselben, die Claude Code nativ verwendet. Nichts wird dupliziert oder separat verwaltet. Das bedeutet in der Praxis: - **Alle Sitzungen, nicht nur eine** – CloudCLI UI erkennt automatisch jede Sitzung aus dem `~/.claude`-Ordner. Remote Control stellt nur die einzelne aktive Sitzung bereit, um sie in der Claude Mobile App verfügbar zu machen. - **Deine Einstellungen sind deine Einstellungen** – MCP-Server, Tool-Berechtigungen und Projektkonfiguration, die in CloudCLI UI geändert werden, werden direkt in die Claude Code-Konfiguration geschrieben und treten sofort in Kraft – und umgekehrt. - **Funktioniert mit mehr Agents** – Claude Code, Cursor CLI, Codex und Gemini CLI, nicht nur Claude Code. - **Vollständige UI, nicht nur ein Chat-Fenster** – Datei-Explorer, Git-Integration, MCP-Verwaltung und ein Shell-Terminal sind alle eingebaut. - **CloudCLI Cloud läuft in der Cloud** – Laptop zuklappen, der Agent läuft weiter. Kein Terminal zu überwachen, kein Rechner, der laufen muss.
Muss ich ein KI-Abonnement separat bezahlen? Ja. CloudCLI stellt die Umgebung bereit, nicht die KI. Du bringst dein eigenes Claude-, Cursor-, Codex- oder Gemini-Abonnement mit. CloudCLI Cloud beginnt bei $7/Monat für die gehostete Umgebung zusätzlich dazu.
Kann ich CloudCLI UI auf meinem Smartphone nutzen? Ja. Bei Self-Hosted: Server auf dem eigenen Rechner starten und `[deineIP]:port` in einem beliebigen Browser im Netzwerk öffnen. Bei CloudCLI Cloud: Von jedem Gerät aus öffnen – kein VPN, keine Portweiterleitung, keine Einrichtung. Eine native App ist ebenfalls in Entwicklung.
Wirken sich Änderungen in der UI auf mein lokales Claude Code-Setup aus? Ja, bei Self-Hosted. CloudCLI UI liest aus und schreibt in dieselbe `~/.claude`-Konfiguration, die Claude Code nativ verwendet. MCP-Server, die über die UI hinzugefügt werden, erscheinen sofort in Claude Code und umgekehrt.
--- ## Community & Support - **[Dokumentation](https://cloudcli.ai/docs)** — Installation, Konfiguration, Funktionen und Fehlerbehebung - **[Discord](https://discord.gg/buxwujPNRE)** — Hilfe erhalten und mit anderen Nutzer:innen in Kontakt treten - **[GitHub Issues](https://github.com/siteboon/claudecodeui/issues)** — Fehlerberichte und Feature-Anfragen - **[Beitragsrichtlinien](CONTRIBUTING.md)** — So kannst du zum Projekt beitragen ## Lizenz GNU General Public License v3.0 – siehe [LICENSE](LICENSE)-Datei für Details. Dieses Projekt ist Open Source und kann unter der GPL v3-Lizenz kostenlos genutzt, modifiziert und verteilt werden. ## Danksagungen ### Erstellt mit - **[Claude Code](https://docs.anthropic.com/en/docs/claude-code)** - Anthropics offizielle CLI - **[Cursor CLI](https://docs.cursor.com/en/cli/overview)** - Cursors offizielle CLI - **[Codex](https://developers.openai.com/codex)** - OpenAI Codex - **[Gemini-CLI](https://geminicli.com/)** - Google Gemini CLI - **[React](https://react.dev/)** - UI-Bibliothek - **[Vite](https://vitejs.dev/)** - Schnelles Build-Tool und Dev-Server - **[Tailwind CSS](https://tailwindcss.com/)** - Utility-first CSS-Framework - **[CodeMirror](https://codemirror.net/)** - Erweiterter Code-Editor - **[TaskMaster AI](https://github.com/eyaltoledano/claude-task-master)** *(Optional)* - KI-gestütztes Projektmanagement und Aufgabenplanung ### Sponsoren - [Siteboon - KI-gestützter Website-Builder](https://siteboon.ai) ---
Mit Sorgfalt für die Claude Code-, Cursor- und Codex-Community erstellt.
================================================ FILE: README.ja.md ================================================
CloudCLI UI

Cloud CLI(別名 Claude Code UI)

Claude CodeCursor CLICodexGemini-CLI のためのデスクトップ/モバイル UI。
ローカルでもリモートでも使え、アクティブなプロジェクトとセッションをどこからでも閲覧できます。

CloudCLI Cloud · ドキュメント · Discord · バグ報告 · コントリビュート

CloudCLI Cloud Discord コミュニティに参加

siteboon%2Fclaudecodeui | Trendshift

English · Русский · Deutsch · 한국어 · 中文 · 日本語
--- ## スクリーンショット

デスクトップビュー

デスクトップインターフェース
プロジェクト概要とチャットを表示するメイン画面

モバイル体験

モバイルインターフェース
タッチ操作に対応したレスポンシブなモバイルデザイン

CLI 選択

CLI 選択
Claude Code、Gemini、Cursor CLI、Codex から選択
## 機能 - **レスポンシブデザイン** - デスクトップ/タブレット/モバイルでシームレスに動作し、モバイルからも Agents を利用可能 - **インタラクティブチャット UI** - Agents とスムーズにやり取りできる内蔵チャット UI - **統合シェルターミナル** - 内蔵シェル機能で Agents の CLI に直接アクセス - **ファイルエクスプローラー** - シンタックスハイライトとライブ編集に対応したインタラクティブなファイルツリー - **Git エクスプローラー** - 変更の表示、ステージ、コミット。ブランチ切り替えも可能 - **セッション管理** - 会話の再開、複数セッションの管理、履歴の追跡 - **プラグインシステム** - カスタムプラグインで CloudCLI を拡張 — 新しいタブ、バックエンドサービス、連携を追加できます。[自分で構築する →](https://github.com/cloudcli-ai/cloudcli-plugin-starter) ## クイックスタート ### CloudCLI Cloud(推奨) 最速で始める方法 — ローカルのセットアップは不要です。Web、モバイルアプリ、API、またはお気に入りの IDE からアクセスできる、フルマネージドでコンテナ化された開発環境を利用できます。 **[CloudCLI Cloud を始める](https://cloudcli.ai)** ### セルフホスト(オープンソース) **npx** で今すぐ CloudCLI UI を試せます(**Node.js** v22+ が必要): ```bash npx @siteboon/claude-code-ui ``` または、普段使いするなら **グローバル** にインストール: ```bash npm install -g @siteboon/claude-code-ui cloudcli ``` `http://localhost:3001` を開いてください — 既存のセッションは自動的に検出されます。 より詳細な設定オプション、PM2、リモートサーバー設定などについては **[ドキュメントはこちら →](https://cloudcli.ai/docs)** を参照してください。 --- ## どちらの選択肢が適していますか? CloudCLI UI は、CloudCLI Cloud を支えるオープンソースの UI レイヤーです。自分のマシンにセルフホストすることも、フルマネージドのクラウド環境、チーム機能、より深い統合を備えた CloudCLI Cloud を使うこともできます。 | | CloudCLI UI(セルフホスト) | CloudCLI Cloud | |---|---|---| | **対象ユーザー** | 自分のマシン上でローカルの agent セッションに対してフル UI を使いたい開発者 | クラウド上で動く agents をどこからでも利用したいチーム/開発者 | | **アクセス方法** | ブラウザ(`[yourip]:port`) | ブラウザ、任意の IDE、REST API、n8n | | **セットアップ** | `npx @siteboon/claude-code-ui` | セットアップ不要 | | **マシンの稼働継続** | はい | いいえ | | **モバイルアクセス** | 同一ネットワーク内の任意のブラウザ | 任意のデバイス(ネイティブアプリも準備中) | | **利用可能なセッション** | `~/.claude` から全セッションを自動検出 | クラウド環境内の全セッション | | **対応エージェント** | Claude Code、Cursor CLI、Codex、Gemini CLI | Claude Code、Cursor CLI、Codex、Gemini CLI | | **ファイルエクスプローラとGit** | はい(UI に内蔵) | はい(UI に内蔵) | | **MCP設定** | UI で管理し、ローカルの `~/.claude` 設定と同期 | UI で管理 | | **IDEアクセス** | ローカル IDE | クラウド環境に接続された任意の IDE | | **REST API** | はい | はい | | **n8n ノード** | いいえ | はい | | **チーム共有** | いいえ | はい | | **料金プラン** | 無料(オープンソース) | 月 $7〜 | > どちらの選択肢でも、AI のサブスクリプション(Claude、Cursor など)はご自身のものを使用します — CloudCLI が提供するのは環境であり、AI そのものではありません。 --- ## セキュリティとツール設定 **🔒 重要なお知らせ** すべての Claude Code ツールは **デフォルトで無効** です。これにより、潜在的に有害な操作が自動的に実行されることを防ぎます。 ### ツールの有効化 1. **ツール設定を開く** - サイドバーの歯車アイコンをクリック 2. **必要なツールだけを選んで有効化** - 本当に使うものだけをオンにする 3. **設定を適用** - 設定内容はローカルに保存されます
![ツール設定モーダル](public/screenshots/tools-modal.png) *Tools 設定画面 - 必要なものだけを有効にしてください*
**推奨アプローチ**: まずは基本ツールだけを有効にし、必要に応じて追加してください。これらの設定は後からいつでも調整できます。 --- ## プラグイン CloudCLI にはプラグインシステムがあり、独自のフロントエンド UI と(必要に応じて)Node.js バックエンドを持つカスタムタブを追加できます。プラグインは **Settings > Plugins** から git リポジトリを直接指定してインストールするか、自作できます。 ### 利用可能なプラグイン | プラグイン | 説明 | |---|---| | **[Project Stats](https://github.com/cloudcli-ai/cloudcli-plugin-starter)** | 現在のプロジェクトについて、ファイル数、コード行数、ファイル種別の内訳、最大ファイル、最近変更されたファイルを表示 | ### 自作する **[Plugin Starter Template →](https://github.com/cloudcli-ai/cloudcli-plugin-starter)** — このリポジトリを fork して独自プラグインを作れます。フロントエンド描画、ライブコンテキスト更新、バックエンドサーバーへの RPC 通信を含む動作例が入っています。 **[プラグインのドキュメント →](https://cloudcli.ai/docs/plugin-overview)** — プラグイン API、manifest 形式、セキュリティモデルなどの完全ガイド。 --- ## FAQ
Claude Code Remote Control とはどう違いますか? Claude Code Remote Control は、ローカル端末で既に動作しているセッションへメッセージを送れる仕組みです。マシンを起動したままにし、端末も開いたままにする必要があり、ネットワーク接続がない状態が約 10 分続くとセッションがタイムアウトします。 CloudCLI UI と CloudCLI Cloud は、Claude Code の横に別物として存在するのではなく、Claude Code を拡張します — MCP サーバー、権限、設定、セッションは Claude Code がネイティブに使うものと完全に同一です。複製したり、別系統で管理したりしません。 - **すべてのセッションにアクセス** — CloudCLI UI は `~/.claude` フォルダのすべてのセッションを自動検出します。Remote Control は、Claude モバイルアプリで利用可能にするため、1つのアクティブセッションだけを公開します。 - **設定はあなたの設定** — CloudCLI UI で変更した MCP サーバー、ツール権限、プロジェクト構成は、Claude Code の設定に直接書き込まれて即座に反映され、その逆(Claude Code での変更が UI に反映)も同様です。 - **対応エージェントがさらに充実** — Claude Code に加えて Cursor CLI、Codex、Gemini CLI にも対応しています。 - **チャット窓だけではない完全な UI** — ファイルエクスプローラー、Git 統合、MCP 管理、シェル端末などがすべて組み込まれています。 - **CloudCLI Cloud はクラウド上で稼働** — ノートパソコンを閉じてもエージェントは動き続けます。監視が要る端末も、スリープ防止も不要です。
AI のサブスクリプションは別途支払いが必要ですか? はい。CloudCLI は環境を提供するものであり、AI は含まれません。Claude、Cursor、Codex、または Gemini のサブスクリプションはご自身でご用意ください。CloudCLI Cloud のホスティング環境はそれに加えて月額 $7 から提供されます。
CloudCLI UI をスマホで使えますか? はい。セルフホストの場合は、自身のマシンでサーバーを起動し、ネットワーク内のブラウザで `[yourip]:port` を開いてください。CloudCLI Cloud を使う場合は、任意のデバイスからアクセスできます。VPN もポートフォワーディングも不要で、セットアップも不要です。ネイティブアプリも開発中です。
UI で加えた変更はローカルの Claude Code 設定に影響しますか? はい、セルフホストの場合です。CloudCLI UI は Claude Code がネイティブに使う `~/.claude` 設定を読み書きします。UI から追加した MCP サーバーは即座に Claude Code に反映され、その逆も同様です。
--- ## コミュニティとサポート - **[ドキュメント](https://cloudcli.ai/docs)** — インストール、設定、機能、トラブルシューティング - **[Discord](https://discord.gg/buxwujPNRE)** — ヘルプを得たり、ユーザー同士で交流したりできます - **[GitHub Issues](https://github.com/siteboon/claudecodeui/issues)** — バグ報告と機能要望 - **[コントリビューションガイド](CONTRIBUTING.md)** — プロジェクトへの貢献方法 ## ライセンス GNU General Public License v3.0 - 詳細は [LICENSE](LICENSE) ファイルを参照してください。 このプロジェクトはオープンソースであり、GPL v3 ライセンスの下で無料で使用、修正、再配布できます。 ## 謝辞 ### 使用技術 - **[Claude Code](https://docs.anthropic.com/en/docs/claude-code)** - Anthropic の公式 CLI - **[Cursor CLI](https://docs.cursor.com/en/cli/overview)** - Cursor の公式 CLI - **[Codex](https://developers.openai.com/codex)** - OpenAI Codex - **[Gemini-CLI](https://geminicli.com/)** - Google Gemini CLI - **[React](https://react.dev/)** - ユーザーインターフェースライブラリ - **[Vite](https://vitejs.dev/)** - 高速ビルドツールと開発サーバー - **[Tailwind CSS](https://tailwindcss.com/)** - ユーティリティファーストの CSS フレームワーク - **[CodeMirror](https://codemirror.net/)** - 高度なコードエディタ - **[TaskMaster AI](https://github.com/eyaltoledano/claude-task-master)** *(オプション)* - AI を活用したプロジェクト管理とタスク計画 ## スポンサー - [Siteboon - AI を活用したウェブサイトビルダー](https://siteboon.ai) ---
Claude Code、Cursor、Codex コミュニティのために心を込めて作りました。
================================================ FILE: README.ko.md ================================================
CloudCLI UI

Cloud CLI (일명 Claude Code UI)

Claude Code, Cursor CLI, Codex, Gemini-CLI 용 데스크톱 및 모바일 UI입니다.
로컬 또는 원격에서 실행하여 어디서나 활성 프로젝트와 세션을 확인하세요.

CloudCLI Cloud · 문서 · Discord · 버그 신고 · 기여 안내

CloudCLI Cloud Discord 커뮤니티

siteboon%2Fclaudecodeui | Trendshift

English · Русский · Deutsch · 한국어 · 中文 · 日本語
--- ## 스크린샷

데스크톱 보기

데스크톱 인터페이스
프로젝트 개요와 채팅을 보여주는 메인 인터페이스

모바일 경험

모바일 인터페이스
터치 내비게이션이 포함된 반응형 모바일 디자인

CLI 선택

CLI 선택
Claude Code, Gemini, Cursor CLI 및 Codex 중 선택
## 기능 - **반응형 디자인** - 데스크톱, 태블릿, 모바일을 아우르는 매끄러운 경험으로 어디서든 Agents를 사용할 수 있습니다 - **대화형 채팅 인터페이스** - 내장된 채팅 UI를 통해 에이전트와 자연스럽게 소통 - **통합 셸 터미널** - 셸 기능을 통해 Agents CLI에 직접 접근 - **파일 탐색기** - 구문 강조 및 실시간 편집을 갖춘 인터랙티브 파일 트리 - **Git 탐색기** - 변경 사항 보기, 스테이징 및 커밋. 브랜치 전환 기능 포함 - **세션 관리** - 대화를 재개하고, 여러 세션을 관리하며 기록을 추적 - **플러그인 시스템** - 커스텀 탭, 백엔드 서비스, 통합을 추가하여 CloudCLI 확장. [직접 빌드 →](https://github.com/cloudcli-ai/cloudcli-plugin-starter) - **TaskMaster AI 통합** *(선택사항)* - AI 중심의 작업 계획, PRD 파싱, 워크플로 자동화를 통한 고급 프로젝트 관리 - **모델 호환성** - Claude, GPT, Gemini 모델 계열에서 작동 (`shared/modelConstants.js`에서 전체 지원 모델 확인) ## 빠른 시작 ### CloudCLI Cloud (추천) 가장 빠르게 시작하는 방법 — 로컬 설정 없이도 가능합니다. 웹, 모바일 앱, API 또는 선호하는 IDE에서 이용할 수 있는 완전 관리형 컨테이너화된 개발 환경을 제공합니다. **[CloudCLI Cloud 시작하기](https://cloudcli.ai)** ### 셀프 호스트 (오픈 소스) **npx**로 즉시 CloudCLI UI를 실행하세요 (Node.js v22+ 필요): ```bash npx @siteboon/claude-code-ui ``` **정기적으로 사용한다면 전역 설치:** ```bash npm install -g @siteboon/claude-code-ui cloudcli ``` `http://localhost:3001`을 열면 기존 세션이 자동으로 발견됩니다. 자세한 구성 옵션, PM2, 원격 서버 설정 등은 **[문서 →](https://cloudcli.ai/docs)**를 참고하세요 --- ## 어느 옵션이 적합한가요? CloudCLI UI는 CloudCLI Cloud를 구동하는 오픈 소스 UI 계층입니다. 로컬 머신에서 직접 셀프 호스트하거나, CloudCLI Cloud(완전 관리형 클라우드 환경, 팀 기능, 심화 통합 제공)를 사용할 수 있습니다. | | CloudCLI UI (셀프 호스트) | CloudCLI Cloud | |---|---|---| | **적합한 대상** | 로컬 에이전트 세션을 위한 전체 UI가 필요한 개발자 | 어디서든 접근 가능한 클라우드에서 에이전트를 운영하고자 하는 팀 및 개발자 | | **접근 방법** | `[yourip]:port`를 통해 브라우저 접속 | 브라우저, IDE, REST API, n8n | | **설정** | `npx @siteboon/claude-code-ui` | 설정 불필요 | | **기기 유지 필요 여부** | 예 (머신 켜둬야 함) | 아니오 | | **모바일 접근** | 네트워크 내 브라우저 | 모든 기기 (네이티브 앱 예정) | | **세션 접근** | `~/.claude`에서 자동 발견 | 클라우드 환경 내 세션 | | **지원 에이전트** | Claude Code, Cursor CLI, Codex, Gemini CLI | Claude Code, Cursor CLI, Codex, Gemini CLI | | **파일 탐색기 및 Git** | UI에 통합됨 | UI에 통합됨 | | **MCP 구성** | UI에서 관리, 로컬 `~/.claude` 설정과 동기화됨 | UI에서 관리 | | **IDE 접근** | 로컬 IDE | 클라우드 환경에 연결된 모든 IDE | | **REST API** | 예 | 예 | | **n8n 노드** | 아니오 | 예 | | **팀 공유** | 아니오 | 예 | | **플랫폼 비용** | 무료, 오픈 소스 | 월 $7부터 | > 둘 다 자체 AI 구독(Claude, Cursor 등)을 그대로 사용합니다 — CloudCLI는 환경만 제공합니다. --- ## 보안 및 도구 구성 **🔒 중요 공지**: 모든 Claude Code 도구는 **기본적으로 비활성화**되어 있습니다. 이는 잠재적인 유해 작업이 자동 실행되는 것을 방지하기 위한 조치입니다. ### 도구 활성화 1. **도구 설정 열기** - 사이드바의 톱니바퀴 아이콘 클릭 2. **선택적으로 활성화** - 필요한 도구만 켜기 3. **설정 적용** - 선호도는 로컬에 저장됨
![도구 설정 모달](public/screenshots/tools-modal.png) *도구 설정 인터페이스 - 필요한 것만 켜세요*
**권장 방법**: 기본 도구를 먼저 켜고 필요할 때 추가하세요. 언제든지 조정 가능합니다. --- ## 플러그인 CloudCLI는 커스텀 탭과 선택적 Node.js 백엔드가 포함된 플러그인 시스템을 제공합니다. Settings > Plugins에서 Git 저장소에서 플러그인을 설치하거나 직접 빌드할 수 있습니다. ### 이용 가능한 플러그인 | 플러그인 | 설명 | |---|---| | **[Project Stats](https://github.com/cloudcli-ai/cloudcli-plugin-starter)** | 현재 프로젝트의 파일 수, 코드 줄 수, 파일 유형 분포, 가장 큰 파일, 최근 수정 파일을 표시 | ### 직접 만들기 **[Plugin Starter Template →](https://github.com/cloudcli-ai/cloudcli-plugin-starter)** — 이 저장소를 포크하여 플러그인 구축. 프런트엔드 렌더링, 실시간 컨텍스트 업데이트, RPC 통신 예제 포함. **[플러그인 문서 →](https://cloudcli.ai/docs/plugin-overview)** — 플러그인 API, 매니페스트 포맷, 보안 모델 등을 설명. --- ## FAQ
Claude Code Remote Control과 어떻게 다른가요? Claude Code Remote Control은 이미 로컬 터미널에서 실행 중인 세션으로 메시지를 전송합니다. 이 경우 기계가 켜져 있어야 하고 터미널을 열어 둬야 하며, 네트워크 연결 없이 약 10분 후 타임아웃됩니다. CloudCLI UI와 CloudCLI Cloud는 Claude Code를 확장하며 별도로 존재하지 않습니다 — MCP 서버, 권한, 설정, 세션은 Claude Code에서 그대로 사용됩니다. - **모든 세션을 다룬다** — CloudCLI UI는 `~/.claude` 폴더에서 모든 세션을 자동 발견합니다. Remote Control은 단일 활성 세션만 노출합니다. - **설정은 그대로** — CloudCLI UI에서 변경한 MCP, 도구 권한, 프로젝트 설정은 Claude Code에 즉시 반영됩니다. - **지원 에이전트가 더 많음** — Claude Code, Cursor CLI, Codex, Gemini CLI 지원. - **전체 UI 제공** — 단일 채팅 창이 아닌 파일 탐색기, Git 통합, MCP 관리 및 셸 터미널 포함. - **CloudCLI Cloud는 클라우드에서 실행** — 노트북을 닫아도 에이전트가 실행됩니다. 터미널을 계속 확인할 필요 없음.
AI 구독을 별도로 결제해야 하나요? 네. CloudCLI는 환경만 제공합니다. Claude, Cursor, Codex, Gemini 구독 비용은 별도로 부과됩니다. CloudCLI Cloud는 관리형 환경을 월 $7부터 제공합니다.
CloudCLI UI를 휴대폰에서 사용할 수 있나요? 네. 셀프 호스트인 경우 기계에서 서버를 실행하고 네트워크의 아무 브라우저에서 `[yourip]:port`를 열면 됩니다. CloudCLI Cloud는 어떤 기기에서도 열 수 있으며, 네이티브 앱도 준비 중입니다.
UI에서 변경하면 로컬 Claude Code 설정에 영향을 주나요? 네, 셀프 호스트에서는 그렇습니다. CloudCLI UI는 Claude Code가 사용하는 동일한 `~/.claude` 설정을 읽고 씁니다. UI에서 추가한 MCP 서버가 Claude Code에 즉시 나타납니다.
--- ## 커뮤니티 및 지원 - **[문서](https://cloudcli.ai/docs)** — 설치, 구성, 기능, 문제 해결 안내 - **[Discord](https://discord.gg/buxwujPNRE)** — 도움 및 커뮤니티 참여 - **[GitHub Issues](https://github.com/siteboon/claudecodeui/issues)** — 버그 보고 및 기능 요청 - **[기여 안내](CONTRIBUTING.md)** — 프로젝트 참여 방법 ## 라이선스 GNU General Public License v3.0 - 자세한 내용은 [LICENSE](LICENSE) 파일 참조. 이 프로젝트는 GPL v3 라이선스 하에 오픈 소스로 공개되어 있으며 자유롭게 사용, 수정, 배포할 수 있습니다. ## 감사의 말 ### 사용 기술 - **[Claude Code](https://docs.anthropic.com/en/docs/claude-code)** - Anthropic 공식 CLI - **[Cursor CLI](https://docs.cursor.com/en/cli/overview)** - Cursor 공식 CLI - **[Codex](https://developers.openai.com/codex)** - OpenAI Codex - **[Gemini-CLI](https://geminicli.com/)** - Google Gemini CLI - **[React](https://react.dev/)** - 사용자 인터페이스 라이브러리 - **[Vite](https://vitejs.dev/)** - 빠른 빌드 도구 및 개발 서버 - **[Tailwind CSS](https://tailwindcss.com/)** - 유틸리티 우선 CSS 프레임워크 - **[CodeMirror](https://codemirror.net/)** - 고급 코드 에디터 - **[TaskMaster AI](https://github.com/eyaltoledano/claude-task-master)** *(선택사항)* - AI 기반 프로젝트 관리 및 작업 계획 ### 스폰서 - [Siteboon - AI powered website builder](https://siteboon.ai) ---
Claude Code, Cursor, Codex 커뮤니티를 위해 정성껏 제작되었습니다.
================================================ FILE: README.md ================================================
CloudCLI UI

Cloud CLI (aka Claude Code UI)

A desktop and mobile UI for Claude Code, Cursor CLI, Codex, and Gemini-CLI.
Use it locally or remotely to view your active projects and sessions from everywhere.

CloudCLI Cloud · Documentation · Discord · Bug Reports · Contributing

CloudCLI Cloud Join our Discord

siteboon%2Fclaudecodeui | Trendshift

--- ## Screenshots

Desktop View

Desktop Interface
Main interface showing project overview and chat

Mobile Experience

Mobile Interface
Responsive mobile design with touch navigation

CLI Selection

CLI Selection
Select between Claude Code, Gemini, Cursor CLI and Codex
## Features - **Responsive Design** - Works seamlessly across desktop, tablet, and mobile so you can also use Agents from mobile - **Interactive Chat Interface** - Built-in chat interface for seamless communication with the Agents - **Integrated Shell Terminal** - Direct access to the Agents CLI through built-in shell functionality - **File Explorer** - Interactive file tree with syntax highlighting and live editing - **Git Explorer** - View, stage and commit your changes. You can also switch branches - **Session Management** - Resume conversations, manage multiple sessions, and track history - **Plugin System** - Extend CloudCLI with custom plugins — add new tabs, backend services, and integrations. [Build your own →](https://github.com/cloudcli-ai/cloudcli-plugin-starter) - **TaskMaster AI Integration** *(Optional)* - Advanced project management with AI-powered task planning, PRD parsing, and workflow automation - **Model Compatibility** - Works with Claude, GPT, and Gemini model families (see [`shared/modelConstants.js`](shared/modelConstants.js) for the full list of supported models) ## Quick Start ### CloudCLI Cloud (Recommended) The fastest way to get started — no local setup required. Get a fully managed, containerized development environment accessible from the web, mobile app, API, or your favorite IDE. **[Get started with CloudCLI Cloud](https://cloudcli.ai)** ### Self-Hosted (Open source) Try CloudCLI UI instantly with **npx** (requires **Node.js** v22+): ``` npx @siteboon/claude-code-ui ``` Or install **globally** for regular use: ``` npm install -g @siteboon/claude-code-ui cloudcli ``` Open `http://localhost:3001` — all your existing sessions are discovered automatically. Visit the **[documentation →](https://cloudcli.ai/docs)** for more full configuration options, PM2, remote server setup and more --- ## Which option is right for you? CloudCLI UI is the open source UI layer that powers CloudCLI Cloud. You can self-host it on your own machine, or use CloudCLI Cloud which builds on top of it with a full managed cloud environment, team features, and deeper integrations. | | CloudCLI UI (Self-hosted) | CloudCLI Cloud | |---|---|---| | **Best for** | Developers who want a full UI for local agent sessions on their own machine | Teams and developers who want agents running in the cloud, accessible from anywhere | | **How you access it** | Browser via `[yourip]:port` | Browser, any IDE, REST API, n8n | | **Setup** | `npx @siteboon/claude-code-ui` | No setup required | | **Machine needs to stay on** | Yes | No | | **Mobile access** | Any browser on your network | Any device, native app coming | | **Sessions available** | All sessions auto-discovered from `~/.claude` | All sessions within your cloud environment | | **Agents supported** | Claude Code, Cursor CLI, Codex, Gemini CLI | Claude Code, Cursor CLI, Codex, Gemini CLI | | **File explorer and Git** | Yes, built into the UI | Yes, built into the UI | | **MCP configuration** | Managed via UI, synced with your local `~/.claude` config | Managed via UI | | **IDE access** | Your local IDE | Any IDE connected to your cloud environment | | **REST API** | Yes | Yes | | **n8n node** | No | Yes | | **Team sharing** | No | Yes | | **Platform cost** | Free, open source | Starts at $7/month | > Both options use your own AI subscriptions (Claude, Cursor, etc.) — CloudCLI provides the environment, not the AI. --- ## Security & Tools Configuration **🔒 Important Notice**: All Claude Code tools are **disabled by default**. This prevents potentially harmful operations from running automatically. ### Enabling Tools To use Claude Code's full functionality, you'll need to manually enable tools: 1. **Open Tools Settings** - Click the gear icon in the sidebar 2. **Enable Selectively** - Turn on only the tools you need 3. **Apply Settings** - Your preferences are saved locally
![Tools Settings Modal](public/screenshots/tools-modal.png) *Tools Settings interface - enable only what you need*
**Recommended approach**: Start with basic tools enabled and add more as needed. You can always adjust these settings later. --- ## Plugins CloudCLI has a plugin system that lets you add custom tabs with their own frontend UI and optional Node.js backend. Install plugins from git repos directly in **Settings > Plugins**, or build your own. ### Available Plugins | Plugin | Description | |---|---| | **[Project Stats](https://github.com/cloudcli-ai/cloudcli-plugin-starter)** | Shows file counts, lines of code, file-type breakdown, largest files, and recently modified files for your current project | | **[Web Terminal](https://github.com/cloudcli-ai/cloudcli-plugin-terminal)** | Full xterm.js terminal with multi-tab support| ### Build Your Own **[Plugin Starter Template →](https://github.com/cloudcli-ai/cloudcli-plugin-starter)** — fork this repo to create your own plugin. It includes a working example with frontend rendering, live context updates, and RPC communication to a backend server. **[Plugin Documentation →](https://cloudcli.ai/docs/plugin-overview)** — full guide to the plugin API, manifest format, security model, and more. --- ## FAQ
How is this different from Claude Code Remote Control? Claude Code Remote Control lets you send messages to a session already running in your local terminal. Your machine has to stay on, your terminal has to stay open, and sessions time out after roughly 10 minutes without a network connection. CloudCLI UI and CloudCLI Cloud extend Claude Code rather than sit alongside it — your MCP servers, permissions, settings, and sessions are the exact same ones Claude Code uses natively. Nothing is duplicated or managed separately. Here's what that means in practice: - **All your sessions, not just one** — CloudCLI UI auto-discovers every session from your `~/.claude` folder. Remote Control only exposes the single active session to make it available in the Claude mobile app. - **Your settings are your settings** — MCP servers, tool permissions, and project config you change in CloudCLI UI are written directly to your Claude Code config and take effect immediately, and vice versa. - **Works with more agents** — Claude Code, Cursor CLI, Codex, and Gemini CLI, not just Claude Code. - **Full UI, not just a chat window** — file explorer, Git integration, MCP management, and a shell terminal are all built in. - **CloudCLI Cloud runs in the cloud** — close your laptop, the agent keeps running. No terminal to babysit, no machine to keep awake.
Do I need to pay for an AI subscription separately? Yes. CloudCLI provides the environment, not the AI. You bring your own Claude, Cursor, Codex, or Gemini subscription. CloudCLI Cloud starts at $7/month for the hosted environment on top of that.
Can I use CloudCLI UI on my phone? Yes. For self-hosted, run the server on your machine and open `[yourip]:port` in any browser on your network. For CloudCLI Cloud, open it from any device — no VPN, no port forwarding, no setup. A native app is also in the works.
Will changes I make in the UI affect my local Claude Code setup? Yes, for self-hosted. CloudCLI UI reads from and writes to the same `~/.claude` config that Claude Code uses natively. MCP servers you add via the UI show up in Claude Code immediately and vice versa.
--- ## Community & Support - **[Documentation](https://cloudcli.ai/docs)** — installation, configuration, features, and troubleshooting - **[Discord](https://discord.gg/buxwujPNRE)** — get help and connect with other users - **[GitHub Issues](https://github.com/siteboon/claudecodeui/issues)** — bug reports and feature requests - **[Contributing Guide](CONTRIBUTING.md)** — how to contribute to the project ## License GNU General Public License v3.0 - see [LICENSE](LICENSE) file for details. This project is open source and free to use, modify, and distribute under the GPL v3 license. ## Acknowledgments ### Built With - **[Claude Code](https://docs.anthropic.com/en/docs/claude-code)** - Anthropic's official CLI - **[Cursor CLI](https://docs.cursor.com/en/cli/overview)** - Cursor's official CLI - **[Codex](https://developers.openai.com/codex)** - OpenAI Codex - **[Gemini-CLI](https://geminicli.com/)** - Google Gemini CLI - **[React](https://react.dev/)** - User interface library - **[Vite](https://vitejs.dev/)** - Fast build tool and dev server - **[Tailwind CSS](https://tailwindcss.com/)** - Utility-first CSS framework - **[CodeMirror](https://codemirror.net/)** - Advanced code editor - **[TaskMaster AI](https://github.com/eyaltoledano/claude-task-master)** *(Optional)* - AI-powered project management and task planning ### Sponsors - [Siteboon - AI powered website builder](https://siteboon.ai) ---
Made with care for the Claude Code, Cursor and Codex community.
================================================ FILE: README.ru.md ================================================
CloudCLI UI

Cloud CLI (aka Claude Code UI)

Десктопный и мобильный UI для Claude Code, Cursor CLI, Codex и Gemini-CLI.
Используйте локально или удалённо, чтобы просматривать активные проекты и сессии отовсюду.

CloudCLI Cloud · Документация · Discord · Сообщить об ошибке · Участие в разработке

CloudCLI Cloud Join our Discord

siteboon%2Fclaudecodeui | Trendshift

English · Русский · Deutsch · 한국어 · 中文 · 日本語
--- ## Скриншоты

Версия для десктопа

Desktop Interface
Основной интерфейс с обзором проекта и чатом

Мобильный режим

Mobile Interface
Адаптивный мобильный дизайн с сенсорной навигацией

Выбор CLI

CLI Selection
Выбирайте между Claude Code, Gemini, Cursor CLI и Codex
## Возможности - **Адаптивный дизайн** - одинаково хорошо работает на десктопе, планшете и телефоне, поэтому можно пользоваться агентами и с мобильных устройств - **Интерактивный чат-интерфейс** - встроенный чат для бесшовного общения с агентами - **Интегрированный shell-терминал** - прямой доступ к CLI агентов через встроенную оболочку - **Проводник файлов** - интерактивное дерево файлов с подсветкой синтаксиса и редактированием в реальном времени - **Git Explorer** - просмотр, stage и commit изменений. Также можно переключать ветки - **Управление сессиями** - возобновляйте диалоги, управляйте несколькими сессиями и отслеживайте историю - **Система плагинов** - расширяйте CloudCLI кастомными плагинами — добавляйте новые вкладки, бэкенд-сервисы и интеграции. [Создать свой →](https://github.com/cloudcli-ai/cloudcli-plugin-starter) - **Интеграция с TaskMaster AI** *(опционально)* - продвинутое управление проектами с планированием задач на базе AI, разбором PRD и автоматизацией workflow - **Совместимость с моделями** - работает с семействами моделей Claude, GPT и Gemini (см. [`shared/modelConstants.js`](shared/modelConstants.js) для полного списка поддерживаемых моделей) ## Быстрый старт ### CloudCLI Cloud (рекомендуется) Самый быстрый способ начать — локальная настройка не требуется. Получите полностью управляемую контейнеризированную среду разработки с доступом из веба, мобильного приложения, API или вашей любимой IDE. **[Начать с CloudCLI Cloud](https://cloudcli.ai)** ### Self-Hosted (Open source) Попробовать CloudCLI UI можно сразу через **npx** (требуется **Node.js** v22+): ```bash npx @siteboon/claude-code-ui ``` Или установить **глобально** для регулярного использования: ```bash npm install -g @siteboon/claude-code-ui cloudcli ``` Откройте `http://localhost:3001` — все ваши существующие сессии будут обнаружены автоматически. Посетите **[документацию →](https://cloudcli.ai/docs)**, чтобы узнать про дополнительные варианты конфигурации, PM2, настройку удалённого сервера и многое другое --- ## Какой вариант подходит вам? CloudCLI UI — это open source UI-слой, на котором построен CloudCLI Cloud. Вы можете развернуть его на своей машине или использовать CloudCLI Cloud, который добавляет полностью управляемую облачную среду, командные функции и более глубокие интеграции. | | CloudCLI UI (Self-hosted) | CloudCLI Cloud | |---|---|---| | **Лучше всего подходит для** | Разработчиков, которым нужен полноценный UI для локальных агентских сессий на своей машине | Команд и разработчиков, которым нужны агенты в облаке с доступом откуда угодно | | **Как вы получаете доступ** | Браузер через `[yourip]:port` | Браузер, любая IDE, REST API, n8n | | **Настройка** | `npx @siteboon/claude-code-ui` | Настройка не требуется | | **Машина должна оставаться включённой** | Да | Нет | | **Доступ с мобильных устройств** | Любой браузер в вашей сети | Любое устройство, нативное приложение в разработке | | **Доступные сессии** | Все сессии автоматически обнаруживаются из `~/.claude` | Все сессии внутри вашей облачной среды | | **Поддерживаемые агенты** | Claude Code, Cursor CLI, Codex, Gemini CLI | Claude Code, Cursor CLI, Codex, Gemini CLI | | **Проводник файлов и Git** | Да, встроены в UI | Да, встроены в UI | | **Конфигурация MCP** | Управляется через UI, синхронизируется с вашим локальным конфигом `~/.claude` | Управляется через UI | | **Доступ из IDE** | Ваша локальная IDE | Любая IDE, подключенная к вашей облачной среде | | **REST API** | Да | Да | | **n8n node** | Нет | Да | | **Совместная работа** | Нет | Да | | **Стоимость платформы** | Бесплатно, open source | От $7/месяц | > В обоих вариантах используются ваши собственные AI-подписки (Claude, Cursor и т.д.) — CloudCLI предоставляет среду, а не сам AI. --- ## Безопасность и конфигурация инструментов **🔒 Важное примечание**: все инструменты Claude Code **по умолчанию отключены**. Это предотвращает автоматический запуск потенциально опасных операций. ### Включение инструментов Чтобы использовать всю функциональность Claude Code, вам нужно вручную включить инструменты: 1. **Откройте настройки инструментов** - нажмите на иконку шестерёнки в боковой панели 2. **Включайте выборочно** - активируйте только те инструменты, которые вам нужны 3. **Примените настройки** - ваши предпочтения сохраняются локально
![Tools Settings Modal](public/screenshots/tools-modal.png) *Интерфейс настройки инструментов — включайте только то, что вам нужно*
**Рекомендуемый подход**: начните с базовых инструментов и добавляйте остальные по мере необходимости. Эти настройки всегда можно изменить позже. --- ## Плагины У CloudCLI есть система плагинов, которая позволяет добавлять кастомные вкладки со своим frontend UI и (опционально) Node.js бэкендом. Устанавливайте плагины напрямую из git-репозиториев в **Settings > Plugins** или создавайте свои. ### Доступные плагины | Плагин | Описание | |---|---| | **[Project Stats](https://github.com/cloudcli-ai/cloudcli-plugin-starter)** | Показывает количество файлов, строки кода, разбивку по типам файлов, самые большие файлы и недавно изменённые файлы для текущего проекта | ### Создать свой **[Plugin Starter Template →](https://github.com/cloudcli-ai/cloudcli-plugin-starter)** — сделайте форк этого репозитория, чтобы создать свой плагин. В шаблоне есть рабочий пример с рендерингом на фронтенде, live-обновлением контекста и RPC-коммуникацией с бэкенд-сервером. **[Plugin Documentation →](https://cloudcli.ai/docs/plugin-overview)** — полный гайд по plugin API, формату манифеста, модели безопасности и другому. --- ## FAQ
Чем это отличается от Claude Code Remote Control? Claude Code Remote Control позволяет отправлять сообщения в сессию, которая уже запущена в вашем локальном терминале. Ваша машина должна оставаться включённой, терминал — открытым, а сессии завершаются примерно через 10 минут без сетевого соединения. CloudCLI UI и CloudCLI Cloud расширяют Claude Code, а не работают рядом с ним — ваши MCP-серверы, разрешения, настройки и сессии остаются теми же самыми, что и в нативном Claude Code. Ничего не дублируется и не управляется отдельно. Вот что это означает на практике: - **Все ваши сессии, а не одна** — CloudCLI UI автоматически находит каждую сессию из папки `~/.claude`. Remote Control предоставляет только одну активную сессию, чтобы сделать её доступной в мобильном приложении Claude. - **Ваши настройки — это ваши настройки** — MCP-серверы, права инструментов и конфигурация проекта, изменённые в CloudCLI UI, записываются напрямую в конфиг Claude Code и вступают в силу сразу же, и наоборот. - **Работает с большим числом агентов** — Claude Code, Cursor CLI, Codex и Gemini CLI, а не только Claude Code. - **Полноценный UI, а не просто окно чата** — проводник файлов, Git-интеграция, управление MCP и shell-терминал — всё встроено. - **CloudCLI Cloud работает в облаке** — закройте ноутбук, и агент продолжит работать. Не нужно следить за терминалом и держать машину постоянно активной.
Нужно ли отдельно платить за AI-подписку? Да. CloudCLI предоставляет среду, а не сам AI. Вы приносите свою подписку Claude, Cursor, Codex или Gemini. CloudCLI Cloud начинается от $7/месяц за хостируемую среду поверх этого.
Можно ли пользоваться CloudCLI UI с телефона? Да. Для self-hosted запустите сервер на своей машине и откройте `[yourip]:port` в любом браузере в вашей сети. Для CloudCLI Cloud откройте сервис с любого устройства — без VPN, проброса портов и дополнительной настройки. Нативное приложение тоже в разработке.
Повлияют ли изменения, сделанные в UI, на мой локальный Claude Code? Да, в self-hosted режиме. CloudCLI UI читает и записывает тот же конфиг `~/.claude`, который Claude Code использует нативно. MCP-серверы, добавленные через UI, сразу появляются в Claude Code, и наоборот.
--- ## Сообщество и поддержка - **[Документация](https://cloudcli.ai/docs)** — установка, настройка, возможности и устранение неполадок - **[Discord](https://discord.gg/buxwujPNRE)** — помощь и общение с другими пользователями - **[GitHub Issues](https://github.com/siteboon/claudecodeui/issues)** — сообщения об ошибках и запросы новых функций - **[Руководство для контрибьюторов](CONTRIBUTING.md)** — как участвовать в развитии проекта ## Лицензия GNU General Public License v3.0 - подробности в файле [LICENSE](LICENSE). Этот проект open source и бесплатен для использования, модификации и распространения в рамках лицензии GPL v3. ## Благодарности ### Используется - **[Claude Code](https://docs.anthropic.com/en/docs/claude-code)** - официальный CLI от Anthropic - **[Cursor CLI](https://docs.cursor.com/en/cli/overview)** - официальный CLI от Cursor - **[Codex](https://developers.openai.com/codex)** - OpenAI Codex - **[Gemini-CLI](https://geminicli.com/)** - Google Gemini CLI - **[React](https://react.dev/)** - библиотека пользовательских интерфейсов - **[Vite](https://vitejs.dev/)** - быстрый инструмент сборки и dev-сервер - **[Tailwind CSS](https://tailwindcss.com/)** - utility-first CSS framework - **[CodeMirror](https://codemirror.net/)** - продвинутый редактор кода - **[TaskMaster AI](https://github.com/eyaltoledano/claude-task-master)** *(опционально)* - AI-управление проектами и планирование задач ### Спонсоры - [Siteboon - AI powered website builder](https://siteboon.ai) ---
Сделано с заботой для сообщества Claude Code, Cursor и Codex.
================================================ FILE: README.zh-CN.md ================================================
CloudCLI UI

Cloud CLI(又名 Claude Code UI)

Claude CodeCursor CLICodexGemini-CLI 的桌面和移动端 UI。可在本地或远程使用,从任何地方查看激活的项目与会话。

CloudCLI Cloud · 文档 · Discord · Bug 报告 · 贡献指南

CloudCLI Cloud 加入 Discord 社区

siteboon%2Fclaudecodeui | Trendshift

--- ## 截图

桌面视图

桌面界面
显示项目概览和聊天的主界面

移动体验

移动界面
具有触控导航的响应式移动设计

CLI 选择

CLI 选择
在 Claude Code、Gemini、Cursor CLI 与 Codex 之间进行选择
## 功能 - **响应式设计** - 在桌面、平板和移动设备上无缝运行,让您随时随地使用 Agents - **交互聊天界面** - 内置聊天 UI,轻松与 Agents 交流 - **集成 Shell 终端** - 通过内置 shell 功能直接访问 Agents CLI - **文件浏览器** - 交互式文件树,支持语法高亮与实时编辑 - **Git 浏览器** - 查看、暂存并提交更改,还可切换分支 - **会话管理** - 恢复对话、管理多个会话并跟踪历史记录 - **插件系统** - 通过自定义选项卡、后端服务与集成扩展 CloudCLI。 [开始构建 →](https://github.com/cloudcli-ai/cloudcli-plugin-starter) - **TaskMaster AI 集成** *(可选)* - 结合 AI 任务规划、PRD 分析与工作流自动化,实现高级项目管理 - **模型兼容性** - 支持 Claude、GPT、Gemini 模型家族(完整支持列表见 [`shared/modelConstants.js`](shared/modelConstants.js)) ## 快速开始 ### CloudCLI Cloud(推荐) 无需本地设置即可快速启动。提供可通过网络浏览器、移动应用、API 或喜欢的 IDE 访问的完全集装式托管开发环境。 **[立即开始 CloudCLI Cloud](https://cloudcli.ai)** ### 自托管(开源) 启动 CloudCLI UI,只需一行 `npx`(需要 Node.js v22+): ```bash npx @siteboon/claude-code-ui ``` 或进行全局安装,便于日常使用: ```bash npm install -g @siteboon/claude-code-ui cloudcli ``` 打开 `http://localhost:3001`,系统会自动发现所有现有会话。 更多配置选项、PM2、远程服务器设置等,请参阅 **[文档 →](https://cloudcli.ai/docs)** --- ## 哪个选项更适合你? CloudCLI UI 是 CloudCLI Cloud 的开源 UI 层。你可以在本地机器上自托管它,也可以使用提供团队功能与深入集成的 CloudCLI Cloud。 | | CloudCLI UI(自托管) | CloudCLI Cloud | |---|---|---| | **适合对象** | 需要为本地代理会话提供完整 UI 的开发者 | 需要部署在云端,随时从任何地方访问代理的团队与开发者 | | **访问方式** | 通过 `[yourip]:port` 在浏览器中访问 | 浏览器、任意 IDE、REST API、n8n | | **设置** | `npx @siteboon/claude-code-ui` | 无需设置 | | **机器需保持开机吗** | 是 | 否 | | **移动端访问** | 网络内任意浏览器 | 任意设备(原生应用即将推出) | | **可用会话** | 自动发现 `~/.claude` 中的所有会话 | 云端环境内的会话 | | **支持的 Agents** | Claude Code、Cursor CLI、Codex、Gemini CLI | Claude Code、Cursor CLI、Codex、Gemini CLI | | **文件浏览与 Git** | 内置于 UI | 内置于 UI | | **MCP 配置** | UI 管理,与本地 `~/.claude` 配置同步 | UI 管理 | | **IDE 访问** | 本地 IDE | 任何连接到云环境的 IDE | | **REST API** | 是 | 是 | | **n8n 节点** | 否 | 是 | | **团队共享** | 否 | 是 | | **平台费用** | 免费开源 | 起价 $7/月 | > 两种方式都使用你自己的 AI 订阅(Claude、Cursor 等)— CloudCLI 提供环境,而非 AI。 --- ## 安全与工具配置 **🔒 重要提示**: 所有 Claude Code 工具默认**禁用**,可防止潜在的有害操作自动运行。 ### 启用工具 1. **打开工具设置** - 点击侧边栏齿轮图标 2. **选择性启用** - 仅启用所需工具 3. **应用设置** - 偏好设置保存在本地
![工具设置弹窗](public/screenshots/tools-modal.png) *工具设置界面 - 只启用你需要的内容*
**推荐做法**: 先启用基础工具,再根据需要添加其他工具。随时可以调整。 --- ## 插件 CloudCLI 配备插件系统,允许你添加带自定义前端 UI 和可选 Node.js 后端的选项卡。在 Settings > Plugins 中直接从 Git 仓库安装插件,或自行开发。 ### 可用插件 | 插件 | 描述 | |---|---| | **[Project Stats](https://github.com/cloudcli-ai/cloudcli-plugin-starter)** | 展示当前项目的文件数、代码行数、文件类型分布、最大文件以及最近修改的文件 | ### 自行构建 **[Plugin Starter Template →](https://github.com/cloudcli-ai/cloudcli-plugin-starter)** — Fork 该仓库以构建自己的插件。示例包括前端渲染、实时上下文更新和 RPC 通信。 **[插件文档 →](https://cloudcli.ai/docs/plugin-overview)** — 提供插件 API、清单格式、安全模型等完整指南。 --- ## 常见问题
与 Claude Code Remote Control 有何不同? Claude Code Remote Control 让你发送消息到本地终端中已经运行的会话。该方式要求你的机器保持开机,终端保持开启,断开网络后约 10 分钟会话会超时。 CloudCLI UI 与 CloudCLI Cloud 是对 Claude Code 的扩展,而非旁观 — MCP 服务器、权限、设置、会话与 Claude Code 完全一致。 - **覆盖全部会话** — CloudCLI UI 会自动扫描 `~/.claude` 文件夹中的每个会话。Remote Control 只暴露当前活动的会话。 - **设置统一** — 在 CloudCLI UI 中修改的 MCP、工具权限等设置会立即写入 Claude Code。 - **支持更多 Agents** — Claude Code、Cursor CLI、Codex、Gemini CLI。 - **完整 UI** — 除了聊天界面,还包括文件浏览器、Git 集成、MCP 管理和 Shell 终端。 - **CloudCLI Cloud 保持运行于云端** — 关闭本地设备也不会中断代理运行,无需监控终端。
需要额外购买 AI 订阅吗? 需要。CloudCLI 只提供环境。你仍需自行获取 Claude、Cursor、Codex 或 Gemini 订阅。CloudCLI Cloud 从 $7/月起提供托管环境。
能在手机上使用 CloudCLI UI 吗? 可以。自托管时,在你的设备上运行服务器,然后在网络中的任意浏览器打开 `[yourip]:port`。CloudCLI Cloud 可从任意设备访问,内置原生应用也在开发中。
UI 中的更改会影响本地 Claude Code 配置吗? 会的。自托管模式下,CloudCLI UI 读取并写入 Claude Code 使用的 `~/.claude` 配置。通过 UI 添加的 MCP 服务器会立即在 Claude Code 中可见。
--- ## 社区与支持 - **[文档](https://cloudcli.ai/docs)** — 安装、配置、功能与故障排除指南 - **[Discord](https://discord.gg/buxwujPNRE)** — 获取帮助并与社区交流 - **[GitHub Issues](https://github.com/siteboon/claudecodeui/issues)** — 报告 Bug 与建议功能 - **[贡献指南](CONTRIBUTING.md)** — 如何参与项目贡献 ## 许可证 GNU 通用公共许可证 v3.0 - 详见 [LICENSE](LICENSE) 文件。 该项目为开源软件,在 GPL v3 许可证下可自由使用、修改与分发。 ## 致谢 ### 使用技术 - **[Claude Code](https://docs.anthropic.com/en/docs/claude-code)** - Anthropic 官方 CLI - **[Cursor CLI](https://docs.cursor.com/en/cli/overview)** - Cursor 官方 CLI - **[Codex](https://developers.openai.com/codex)** - OpenAI Codex - **[Gemini-CLI](https://geminicli.com/)** - Google Gemini CLI - **[React](https://react.dev/)** - 用户界面库 - **[Vite](https://vitejs.dev/)** - 快速构建工具与开发服务器 - **[Tailwind CSS](https://tailwindcss.com/)** - 实用先行 CSS 框架 - **[CodeMirror](https://codemirror.net/)** - 高级代码编辑器 - **[TaskMaster AI](https://github.com/eyaltoledano/claude-task-master)** *(可选)* - AI 驱动的项目管理与任务规划 ### 赞助商 - [Siteboon - AI powered website builder](https://siteboon.ai) ---
为 Claude Code、Cursor 和 Codex 社区精心打造。
================================================ FILE: commitlint.config.js ================================================ export default { extends: ["@commitlint/config-conventional"], }; ================================================ FILE: eslint.config.js ================================================ import js from "@eslint/js"; import tseslint from "typescript-eslint"; import react from "eslint-plugin-react"; import reactHooks from "eslint-plugin-react-hooks"; import reactRefresh from "eslint-plugin-react-refresh"; import importX from "eslint-plugin-import-x"; import tailwindcss from "eslint-plugin-tailwindcss"; import unusedImports from "eslint-plugin-unused-imports"; import globals from "globals"; export default tseslint.config( { ignores: ["dist/**", "node_modules/**", "public/**"], }, { files: ["src/**/*.{ts,tsx,js,jsx}"], extends: [js.configs.recommended, ...tseslint.configs.recommended], plugins: { react, "react-hooks": reactHooks, // for following React rules such as dependencies in hooks, keys in lists, etc. "react-refresh": reactRefresh, // for Vite HMR compatibility "import-x": importX, // for import order/sorting. It also detercts circular dependencies and duplicate imports. tailwindcss, // for detecting invalid Tailwind classnames and enforcing classname order "unused-imports": unusedImports, // for detecting unused imports }, languageOptions: { globals: { ...globals.browser, }, parserOptions: { ecmaFeatures: { jsx: true }, }, }, settings: { react: { version: "detect" }, }, rules: { // --- Unused imports/vars --- "unused-imports/no-unused-imports": "warn", "unused-imports/no-unused-vars": [ "warn", { vars: "all", varsIgnorePattern: "^_", args: "after-used", argsIgnorePattern: "^_", }, ], "no-unused-vars": "off", "@typescript-eslint/no-unused-vars": "off", // --- React --- "react/jsx-key": "warn", "react/jsx-no-duplicate-props": "error", "react/jsx-no-undef": "error", "react/no-children-prop": "warn", "react/no-danger-with-children": "error", "react/no-direct-mutation-state": "error", "react/no-unknown-property": "warn", "react/react-in-jsx-scope": "off", // --- React Hooks --- "react-hooks/rules-of-hooks": "error", "react-hooks/exhaustive-deps": "warn", // --- React Refresh (Vite HMR) --- "react-refresh/only-export-components": [ "warn", { allowConstantExport: true }, ], // --- Import ordering & hygiene --- "import-x/no-duplicates": "warn", "import-x/order": [ "warn", { groups: [ "builtin", "external", "internal", "parent", "sibling", "index", ], "newlines-between": "never", }, ], // --- Tailwind CSS --- "tailwindcss/classnames-order": "warn", "tailwindcss/no-contradicting-classname": "warn", "tailwindcss/no-unnecessary-arbitrary-value": "warn", // --- Disabled base rules --- "@typescript-eslint/no-explicit-any": "off", "@typescript-eslint/no-require-imports": "off", "no-case-declarations": "off", "no-control-regex": "off", "no-useless-escape": "off", }, } ); ================================================ FILE: index.html ================================================ CloudCLI UI
================================================ FILE: package.json ================================================ { "name": "@siteboon/claude-code-ui", "version": "1.26.0", "description": "A web-based UI for Claude Code CLI", "type": "module", "main": "server/index.js", "bin": { "claude-code-ui": "server/cli.js", "cloudcli": "server/cli.js" }, "files": [ "server/", "shared/", "dist/", "scripts/", "README.md" ], "homepage": "https://cloudcli.ai", "repository": { "type": "git", "url": "git+https://github.com/siteboon/claudecodeui.git" }, "bugs": { "url": "https://github.com/siteboon/claudecodeui/issues" }, "scripts": { "dev": "concurrently --kill-others \"npm run server\" \"npm run client\"", "server": "node server/index.js", "client": "vite", "build": "vite build", "preview": "vite preview", "typecheck": "tsc --noEmit -p tsconfig.json", "lint": "eslint src/", "lint:fix": "eslint src/ --fix", "start": "npm run build && npm run server", "release": "./release.sh", "prepublishOnly": "npm run build", "postinstall": "node scripts/fix-node-pty.js", "prepare": "husky" }, "keywords": [ "claude code", "ai", "anthropic", "ui", "mobile" ], "author": "CloudCLI UI Contributors", "license": "GPL-3.0", "dependencies": { "@anthropic-ai/claude-agent-sdk": "^0.2.59", "@codemirror/lang-css": "^6.3.1", "@codemirror/lang-html": "^6.4.9", "@codemirror/lang-javascript": "^6.2.4", "@codemirror/lang-json": "^6.0.1", "@codemirror/lang-markdown": "^6.3.3", "@codemirror/lang-python": "^6.2.1", "@codemirror/merge": "^6.11.1", "@codemirror/theme-one-dark": "^6.1.2", "@iarna/toml": "^2.2.5", "@octokit/rest": "^22.0.0", "@openai/codex-sdk": "^0.101.0", "@replit/codemirror-minimap": "^0.5.2", "@tailwindcss/typography": "^0.5.16", "@uiw/react-codemirror": "^4.23.13", "@xterm/addon-clipboard": "^0.1.0", "@xterm/addon-fit": "^0.10.0", "@xterm/addon-web-links": "^0.11.0", "@xterm/addon-webgl": "^0.18.0", "@xterm/xterm": "^5.5.0", "bcrypt": "^6.0.0", "better-sqlite3": "^12.6.2", "chokidar": "^4.0.3", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cors": "^2.8.5", "cross-spawn": "^7.0.3", "express": "^4.18.2", "fuse.js": "^7.0.0", "gray-matter": "^4.0.3", "i18next": "^25.7.4", "i18next-browser-languagedetector": "^8.2.0", "jsonwebtoken": "^9.0.2", "jszip": "^3.10.1", "katex": "^0.16.25", "lucide-react": "^0.515.0", "mime-types": "^3.0.1", "multer": "^2.0.1", "node-fetch": "^2.7.0", "node-pty": "^1.1.0-beta34", "react": "^18.2.0", "react-dom": "^18.2.0", "react-dropzone": "^14.2.3", "react-error-boundary": "^4.1.2", "react-i18next": "^16.5.3", "react-markdown": "^10.1.0", "react-router-dom": "^6.8.1", "react-syntax-highlighter": "^15.6.1", "rehype-katex": "^7.0.1", "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.4.3", "@commitlint/config-conventional": "^20.4.3", "@eslint/js": "^9.39.3", "@release-it/conventional-changelog": "^10.0.5", "@types/node": "^22.19.7", "@types/react": "^18.2.43", "@types/react-dom": "^18.2.17", "@vitejs/plugin-react": "^4.6.0", "auto-changelog": "^2.5.0", "autoprefixer": "^10.4.16", "concurrently": "^8.2.2", "eslint": "^9.39.3", "eslint-plugin-import-x": "^4.16.1", "eslint-plugin-react": "^7.37.5", "eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-refresh": "^0.5.2", "eslint-plugin-tailwindcss": "^3.18.2", "eslint-plugin-unused-imports": "^4.4.1", "globals": "^17.4.0", "husky": "^9.1.7", "lint-staged": "^16.3.2", "node-gyp": "^10.0.0", "postcss": "^8.4.32", "release-it": "^19.0.5", "sharp": "^0.34.2", "tailwindcss": "^3.4.0", "typescript": "^5.9.3", "typescript-eslint": "^8.56.1", "vite": "^7.0.4" }, "lint-staged": { "src/**/*.{ts,tsx,js,jsx}": "eslint" } } ================================================ FILE: postcss.config.js ================================================ export default { plugins: { tailwindcss: {}, autoprefixer: {}, }, } ================================================ FILE: public/api-docs.html ================================================ Claude Code UI - API Documentation

Claude Code UI

API Documentation
Back to App

Programmatically trigger AI agents to work on projects. Clone GitHub repositories or use existing project paths. Perfect for CI/CD pipelines, automated code reviews, and bulk processing.

Authentication

All API requests require authentication using an API key in the X-API-Key header.

Generate API keys in Settings → API & Tokens.

GitHub Credentials

For private repositories, store a GitHub token in settings or pass it with each request.

Note: GitHub tokens in the request override stored tokens.

Authentication Header

X-API-Key: ck_your_api_key_here

Agent

POST http://localhost:3001/api/agent

Trigger an AI agent (Claude, Cursor, or Codex) to work on a project.

Request Body Parameters

Parameter Type Required Description
githubUrl string Conditional GitHub repository URL to clone. If path exists with same repo, reuses it. If path exists with different repo, returns error.
projectPath string Conditional Path to existing project OR destination for cloning. If omitted with githubUrl, auto-generates path. If used alone, must point to existing project directory.
message string Required Task for the AI agent
provider string Optional claude, cursor, or codex (default: claude)
stream boolean Optional Enable streaming (default: true)
model string Optional Model identifier for the AI provider (loading from constants...)
cleanup boolean Optional Auto-cleanup after completion (default: true). Only applies when cloning via githubUrl. Existing projects specified via projectPath are never cleaned up.
githubToken string Optional GitHub token for private repos
branchName string Optional Custom branch name to use. If provided, createBranch is automatically enabled. Branch names are validated against Git naming rules. Works with githubUrl or projectPath (if it has a GitHub remote).
createBranch boolean Optional Create a new branch after successful completion (default: false). Automatically set to true if branchName is provided. Works with githubUrl or projectPath (if it has a GitHub remote).
createPR boolean Optional Create a pull request after successful completion (default: false). PR title and description auto-generated from commit messages. Works with githubUrl or projectPath (if it has a GitHub remote).
Path Handling Behavior:

Scenario 1: Only githubUrl → Clones to auto-generated temporary path
Scenario 2: Only projectPath → Uses existing project at specified path
Scenario 3: Both provided → Clones githubUrl to projectPath

Validation: If projectPath exists and contains a git repository, the remote URL is compared with githubUrl. If URLs match, the existing repo is reused. If URLs differ, an error is returned.

Response (Streaming)

Server-sent events (SSE) format with real-time updates. Content-Type: text/event-stream

Response (Non-Streaming)

JSON object containing session details, assistant messages only (filtered), and token usage summary. Content-Type: application/json

Error Response

Returns error details with appropriate HTTP status code.

Basic Request

curl -X POST http://localhost:3001/api/agent \
  -H "Content-Type: application/json" \
  -H "X-API-Key: ck_..." \
  -d '{
    "githubUrl": "https://github.com/user/repo",
    "message": "Add error handling to main.js"
  }'
const response = await fetch('http://localhost:3001/api/agent', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'X-API-Key': process.env.CLAUDE_API_KEY
  },
  body: JSON.stringify({
    githubUrl: 'https://github.com/user/repo',
    message: 'Add error handling',
    stream: false
  })
});

const result = await response.json();
import requests
import os

response = requests.post(
    'http://localhost:3001/api/agent',
    headers={
        'Content-Type': 'application/json',
        'X-API-Key': os.environ['CLAUDE_API_KEY']
    },
    json={
        'githubUrl': 'https://github.com/user/repo',
        'message': 'Add error handling',
        'stream': False
    }
)

print(response.json())

Streaming Response

data: {"type":"status","message":"Repository cloned"}
data: {"type":"thinking","content":"Analyzing..."}
data: {"type":"tool_use","tool":"read_file"}
data: {"type":"content","content":"Done!"}
data: {"type":"done"}

Non-Streaming Response

{
  "success": true,
  "sessionId": "abc123",
  "messages": [
    {
      "type": "assistant",
      "message": {
        "role": "assistant",
        "content": [
          {
            "type": "text",
            "text": "I've completed the task..."
          }
        ],
        "usage": {
          "input_tokens": 150,
          "output_tokens": 50
        }
      }
    }
  ],
  "tokens": {
    "inputTokens": 150,
    "outputTokens": 50,
    "cacheReadTokens": 0,
    "cacheCreationTokens": 0,
    "totalTokens": 200
  },
  "projectPath": "/path/to/project",
  "branch": {
    "name": "fix-authentication-bug-abc123",
    "url": "https://github.com/user/repo/tree/fix-authentication-bug-abc123"
  },
  "pullRequest": {
    "number": 42,
    "url": "https://github.com/user/repo/pull/42"
  }
}

Error Response

{
  "success": false,
  "error": "Directory exists with different repo"
}

Usage Patterns

Clone and Process Repository

Clone a repository to an auto-generated temporary path and process it.

Use Existing Project

Work with an existing project at a specific path.

Clone to Specific Path

Clone a repository to a custom location for later reuse.

CI/CD Integration

Integrate with GitHub Actions or other CI/CD pipelines.

Create Branch and Pull Request

Automatically create a new branch and pull request after the agent completes its work. Branch names are auto-generated from the message, and PR title/description are auto-generated from commit messages.

Use Existing Project

curl -X POST http://localhost:3001/api/agent \
  -H "Content-Type: application/json" \
  -H "X-API-Key: ck_..." \
  -d '{
    "projectPath": "/home/user/my-project",
    "message": "Refactor database queries"
  }'

Clone to Custom Path

curl -X POST http://localhost:3001/api/agent \
  -H "Content-Type: application/json" \
  -H "X-API-Key: ck_..." \
  -d '{
    "githubUrl": "https://github.com/user/repo",
    "projectPath": "/tmp/my-location",
    "message": "Review security",
    "cleanup": false
  }'

CI/CD (GitHub Actions)

- name: Trigger Agent
  run: |
    curl -X POST ${{ secrets.API_URL }}/api/agent \
      -H "X-API-Key: ${{ secrets.API_KEY }}" \
      -H "Content-Type: application/json" \
      -d '{
        "githubUrl": "${{ github.repository }}",
        "message": "Review for security",
        "githubToken": "${{ secrets.GITHUB_TOKEN }}"
      }'

Create Branch and PR

curl -X POST http://localhost:3001/api/agent \
  -H "Content-Type: application/json" \
  -H "X-API-Key: ck_..." \
  -d '{
    "githubUrl": "https://github.com/user/repo",
    "message": "Fix authentication bug",
    "createBranch": true,
    "createPR": true,
    "stream": false
  }'

Custom Branch Name

curl -X POST http://localhost:3001/api/agent \
  -H "Content-Type: application/json" \
  -H "X-API-Key: ck_..." \
  -d '{
    "githubUrl": "https://github.com/user/repo",
    "message": "Add user authentication",
    "branchName": "feature/user-auth",
    "createPR": true,
    "stream": false
  }'

Branch & PR Response

{
  "success": true,
  "branch": {
    "name": "feature/user-auth",
    "url": "https://github.com/user/repo/tree/feature/user-auth"
  },
  "pullRequest": {
    "number": 42,
    "url": "https://github.com/user/repo/pull/42"
  }
}
================================================ FILE: public/clear-cache.html ================================================ Clear Cache - Claude Code UI

Clear Cache & Service Worker

If you're seeing a blank page or old content, click the button below to clear all cached data.

================================================ FILE: public/convert-icons.md ================================================ # Convert SVG Icons to PNG I've created SVG versions of the app icons that match the MessageSquare design from the sidebar. To convert them to PNG format, you can use one of these methods: ## Method 1: Online Converter (Easiest) 1. Go to https://cloudconvert.com/svg-to-png 2. Upload each SVG file from the `/icons/` directory 3. Download the PNG versions 4. Replace the existing PNG files ## Method 2: Using Node.js (if you have it) ```bash npm install sharp node -e " const sharp = require('sharp'); const fs = require('fs'); const sizes = [72, 96, 128, 144, 152, 192, 384, 512]; sizes.forEach(size => { const svgPath = \`./icons/icon-\${size}x\${size}.svg\`; const pngPath = \`./icons/icon-\${size}x\${size}.png\`; if (fs.existsSync(svgPath)) { sharp(svgPath).png().toFile(pngPath); console.log(\`Converted \${svgPath} to \${pngPath}\`); } }); " ``` ## Method 3: Using ImageMagick (if installed) ```bash cd public/icons for size in 72 96 128 144 152 192 384 512; do convert "icon-${size}x${size}.svg" "icon-${size}x${size}.png" done ``` ## Method 4: Using Inkscape (if installed) ```bash cd public/icons for size in 72 96 128 144 152 192 384 512; do inkscape --export-type=png "icon-${size}x${size}.svg" done ``` ## Icon Design The new icons feature: - Clean MessageSquare (chat bubble) design matching the sidebar - Primary color background with rounded corners - White stroke icon that's clearly visible - Consistent sizing and proportions across all sizes - Proper PWA-compliant format Once converted, the PNG files will replace the existing ones and provide a consistent icon experience across all platforms. ================================================ FILE: public/generate-icons.js ================================================ const fs = require('fs'); const path = require('path'); // Icon sizes needed const sizes = [72, 96, 128, 144, 152, 192, 384, 512]; // SVG template function function createIconSVG(size) { const cornerRadius = Math.round(size * 0.25); // 25% corner radius const strokeWidth = Math.max(2, Math.round(size * 0.06)); // Scale stroke width // MessageSquare path scaled to size const padding = Math.round(size * 0.25); const iconSize = size - (padding * 2); const startX = padding; const startY = Math.round(padding * 0.7); const endX = startX + iconSize; const endY = startY + Math.round(iconSize * 0.6); const tailX = startX; const tailY = endY + Math.round(iconSize * 0.3); return ` `; } // Generate SVG files for each size sizes.forEach(size => { const svgContent = createIconSVG(size); const filename = `icon-${size}x${size}.svg`; const filepath = path.join(__dirname, 'icons', filename); fs.writeFileSync(filepath, svgContent); console.log(`Created ${filename}`); }); console.log('\nSVG icons created! To convert to PNG, you can use:'); console.log('1. Online converter like cloudconvert.com'); console.log('2. If you have ImageMagick: convert icon.svg icon.png'); console.log('3. If you have Inkscape: inkscape --export-type=png icon.svg'); ================================================ FILE: public/manifest.json ================================================ { "name": "CloudCLI UI", "short_name": "CloudCLI UI", "description": "CloudCLI UI web application", "start_url": "/", "display": "standalone", "background_color": "#ffffff", "theme_color": "#ffffff", "orientation": "portrait-primary", "scope": "/", "icons": [ { "src": "/icons/icon-72x72.png", "sizes": "72x72", "type": "image/png", "purpose": "maskable any" }, { "src": "/icons/icon-96x96.png", "sizes": "96x96", "type": "image/png", "purpose": "maskable any" }, { "src": "/icons/icon-128x128.png", "sizes": "128x128", "type": "image/png", "purpose": "maskable any" }, { "src": "/icons/icon-144x144.png", "sizes": "144x144", "type": "image/png", "purpose": "maskable any" }, { "src": "/icons/icon-152x152.png", "sizes": "152x152", "type": "image/png", "purpose": "maskable any" }, { "src": "/icons/icon-192x192.png", "sizes": "192x192", "type": "image/png", "purpose": "maskable any" }, { "src": "/icons/icon-384x384.png", "sizes": "384x384", "type": "image/png", "purpose": "maskable any" }, { "src": "/icons/icon-512x512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable any" } ] } ================================================ FILE: public/sw.js ================================================ // Service Worker for Claude Code UI PWA const CACHE_NAME = 'claude-ui-v1'; const urlsToCache = [ '/', '/index.html', '/manifest.json' ]; // Install event self.addEventListener('install', event => { event.waitUntil( caches.open(CACHE_NAME) .then(cache => { return cache.addAll(urlsToCache); }) ); self.skipWaiting(); }); // Fetch event self.addEventListener('fetch', event => { // Never cache API requests or WebSocket upgrades if (event.request.url.includes('/api/') || event.request.url.includes('/ws')) { return; } event.respondWith( caches.match(event.request) .then(response => { if (response) { return response; } return fetch(event.request); } ) ); }); // Activate event self.addEventListener('activate', event => { event.waitUntil( caches.keys().then(cacheNames => { return Promise.all( cacheNames.map(cacheName => { if (cacheName !== CACHE_NAME) { return caches.delete(cacheName); } }) ); }) ); self.clients.claim(); }); // Push notification event self.addEventListener('push', event => { if (!event.data) return; let payload; try { payload = event.data.json(); } catch { payload = { title: 'Claude Code UI', body: event.data.text() }; } const options = { body: payload.body || '', icon: '/logo-256.png', badge: '/logo-128.png', data: payload.data || {}, tag: payload.data?.tag || `${payload.data?.sessionId || 'global'}:${payload.data?.code || 'default'}`, renotify: true }; event.waitUntil( self.registration.showNotification(payload.title || 'Claude Code UI', options) ); }); // Notification click event self.addEventListener('notificationclick', event => { event.notification.close(); const sessionId = event.notification.data?.sessionId; const provider = event.notification.data?.provider || null; const urlPath = sessionId ? `/session/${sessionId}` : '/'; event.waitUntil( self.clients.matchAll({ type: 'window', includeUncontrolled: true }).then(async clientList => { for (const client of clientList) { if (client.url.includes(self.location.origin)) { await client.focus(); client.postMessage({ type: 'notification:navigate', sessionId: sessionId || null, provider, urlPath }); return; } } return self.clients.openWindow(urlPath); }) ); }); ================================================ FILE: release.sh ================================================ #!/bin/bash # Load environment variables from .env export $(grep -v '^#' .env | grep '^GITHUB_TOKEN=' | xargs) exec npx release-it "$@" ================================================ FILE: scripts/fix-node-pty.js ================================================ #!/usr/bin/env node /** * Fix node-pty spawn-helper permissions on macOS * * This script fixes a known issue with node-pty where the spawn-helper * binary is shipped without execute permissions, causing "posix_spawnp failed" errors. * * @see https://github.com/microsoft/node-pty/issues/850 * @module scripts/fix-node-pty */ import { promises as fs } from 'fs'; import path from 'path'; import { fileURLToPath } from 'url'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); /** * Fixes the spawn-helper binary permissions for node-pty on macOS. * * The node-pty package ships the spawn-helper binary without execute permissions * (644 instead of 755), which causes "posix_spawnp failed" errors when trying * to spawn terminal processes. * * This function: * 1. Checks if running on macOS (darwin) * 2. Locates spawn-helper binaries for both arm64 and x64 architectures * 3. Sets execute permissions (755) on each binary found * * @async * @function fixSpawnHelper * @returns {Promise} Resolves when permissions are fixed or skipped * @example * // Run as postinstall script * await fixSpawnHelper(); */ async function fixSpawnHelper() { const nodeModulesPath = path.join(__dirname, '..', 'node_modules', 'node-pty', 'prebuilds'); // Only run on macOS if (process.platform !== 'darwin') { return; } const darwinDirs = ['darwin-arm64', 'darwin-x64']; for (const dir of darwinDirs) { const spawnHelperPath = path.join(nodeModulesPath, dir, 'spawn-helper'); try { // Check if file exists await fs.access(spawnHelperPath); // Make it executable (755) await fs.chmod(spawnHelperPath, 0o755); console.log(`[postinstall] Fixed permissions for ${spawnHelperPath}`); } catch (err) { // File doesn't exist or other error - ignore if (err.code !== 'ENOENT') { console.warn(`[postinstall] Warning: Could not fix ${spawnHelperPath}: ${err.message}`); } } } } fixSpawnHelper().catch(console.error); ================================================ FILE: server/claude-sdk.js ================================================ /** * Claude SDK Integration * * This module provides SDK-based integration with Claude using the @anthropic-ai/claude-agent-sdk. * It mirrors the interface of claude-cli.js but uses the SDK internally for better performance * and maintainability. * * Key features: * - Direct SDK integration without child processes * - Session management with abort capability * - Options mapping between CLI and SDK formats * - WebSocket message streaming */ import { query } from '@anthropic-ai/claude-agent-sdk'; import crypto from 'crypto'; import { promises as fs } from 'fs'; import path from 'path'; import os from 'os'; import { CLAUDE_MODELS } from '../shared/modelConstants.js'; import { createNotificationEvent, notifyRunFailed, notifyRunStopped, notifyUserIfEnabled } from './services/notification-orchestrator.js'; import { claudeAdapter } from './providers/claude/adapter.js'; import { createNormalizedMessage } from './providers/types.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']); function createRequestId() { if (typeof crypto.randomUUID === 'function') { return crypto.randomUUID(); } return crypto.randomBytes(16).toString('hex'); } function waitForToolApproval(requestId, options = {}) { const { timeoutMs = TOOL_APPROVAL_TIMEOUT_MS, signal, onCancel, metadata } = options; return new Promise(resolve => { let settled = false; const finalize = (decision) => { if (settled) return; settled = true; cleanup(); resolve(decision); }; let timeout; const cleanup = () => { pendingToolApprovals.delete(requestId); if (timeout) clearTimeout(timeout); if (signal && abortHandler) { signal.removeEventListener('abort', abortHandler); } }; // timeoutMs 0 = wait indefinitely (interactive tools) if (timeoutMs > 0) { timeout = setTimeout(() => { onCancel?.('timeout'); finalize(null); }, timeoutMs); } const abortHandler = () => { onCancel?.('cancelled'); finalize({ cancelled: true }); }; if (signal) { if (signal.aborted) { onCancel?.('cancelled'); finalize({ cancelled: true }); return; } signal.addEventListener('abort', abortHandler, { once: true }); } const resolver = (decision) => { finalize(decision); }; // Attach metadata for getPendingApprovalsForSession lookup if (metadata) { Object.assign(resolver, metadata); } pendingToolApprovals.set(requestId, resolver); }); } function resolveToolApproval(requestId, decision) { const resolver = pendingToolApprovals.get(requestId); if (resolver) { resolver(decision); } } // Match stored permission entries against a tool + input combo. // This only supports exact tool names and the Bash(command:*) shorthand // used by the UI; it intentionally does not implement full glob semantics, // introduced to stay consistent with the UI's "Allow rule" format. function matchesToolPermission(entry, toolName, input) { if (!entry || !toolName) { return false; } if (entry === toolName) { return true; } const bashMatch = entry.match(/^Bash\((.+):\*\)$/); if (toolName === 'Bash' && bashMatch) { const allowedPrefix = bashMatch[1]; let command = ''; if (typeof input === 'string') { command = input.trim(); } else if (input && typeof input === 'object' && typeof input.command === 'string') { command = input.command.trim(); } if (!command) { return false; } return command.startsWith(allowedPrefix); } return false; } /** * Maps CLI options to SDK-compatible options format * @param {Object} options - CLI options * @returns {Object} SDK-compatible options */ function mapCliOptionsToSDK(options = {}) { const { sessionId, cwd, toolsSettings, permissionMode } = options; const sdkOptions = {}; // Map working directory if (cwd) { sdkOptions.cwd = cwd; } // Map permission mode if (permissionMode && permissionMode !== 'default') { sdkOptions.permissionMode = permissionMode; } // Map tool settings const settings = toolsSettings || { allowedTools: [], disallowedTools: [], skipPermissions: false }; // Handle tool permissions if (settings.skipPermissions && permissionMode !== 'plan') { // When skipping permissions, use bypassPermissions mode sdkOptions.permissionMode = 'bypassPermissions'; } let allowedTools = [...(settings.allowedTools || [])]; // Add plan mode default tools if (permissionMode === 'plan') { const planModeTools = ['Read', 'Task', 'exit_plan_mode', 'TodoRead', 'TodoWrite', 'WebFetch', 'WebSearch']; for (const tool of planModeTools) { if (!allowedTools.includes(tool)) { allowedTools.push(tool); } } } sdkOptions.allowedTools = allowedTools; // Use the tools preset to make all default built-in tools available (including AskUserQuestion). // This was introduced in SDK 0.1.57. Omitting this preserves existing behavior (all tools available), // but being explicit ensures forward compatibility and clarity. sdkOptions.tools = { type: 'preset', preset: 'claude_code' }; sdkOptions.disallowedTools = settings.disallowedTools || []; // Map model (default to sonnet) // Valid models: sonnet, opus, haiku, opusplan, sonnet[1m] sdkOptions.model = options.model || CLAUDE_MODELS.DEFAULT; // Model logged at query start below // Map system prompt configuration sdkOptions.systemPrompt = { type: 'preset', preset: 'claude_code' // Required to use CLAUDE.md }; // Map setting sources for CLAUDE.md loading // This loads CLAUDE.md from project, user (~/.config/claude/CLAUDE.md), and local directories sdkOptions.settingSources = ['project', 'user', 'local']; // Map resume session if (sessionId) { sdkOptions.resume = sessionId; } return sdkOptions; } /** * Adds a session to the active sessions map * @param {string} sessionId - Session identifier * @param {Object} queryInstance - SDK query instance * @param {Array} tempImagePaths - Temp image file paths for cleanup * @param {string} tempDir - Temp directory for cleanup */ function addSession(sessionId, queryInstance, tempImagePaths = [], tempDir = null, writer = null) { activeSessions.set(sessionId, { instance: queryInstance, startTime: Date.now(), status: 'active', tempImagePaths, tempDir, writer }); } /** * Removes a session from the active sessions map * @param {string} sessionId - Session identifier */ function removeSession(sessionId) { activeSessions.delete(sessionId); } /** * Gets a session from the active sessions map * @param {string} sessionId - Session identifier * @returns {Object|undefined} Session data or undefined */ function getSession(sessionId) { return activeSessions.get(sessionId); } /** * Gets all active session IDs * @returns {Array} Array of active session IDs */ function getAllSessions() { return Array.from(activeSessions.keys()); } /** * Transforms SDK messages to WebSocket format expected by frontend * @param {Object} sdkMessage - SDK message object * @returns {Object} Transformed message ready for WebSocket */ function transformMessage(sdkMessage) { // Extract parent_tool_use_id for subagent tool grouping if (sdkMessage.parent_tool_use_id) { return { ...sdkMessage, parentToolUseId: sdkMessage.parent_tool_use_id }; } return sdkMessage; } /** * Extracts token usage from SDK result messages * @param {Object} resultMessage - SDK result message * @returns {Object|null} Token budget object or null */ function extractTokenBudget(resultMessage) { if (resultMessage.type !== 'result' || !resultMessage.modelUsage) { return null; } // Get the first model's usage data const modelKey = Object.keys(resultMessage.modelUsage)[0]; const modelData = resultMessage.modelUsage[modelKey]; if (!modelData) { return null; } // Use cumulative tokens if available (tracks total for the session) // Otherwise fall back to per-request tokens const inputTokens = modelData.cumulativeInputTokens || modelData.inputTokens || 0; const outputTokens = modelData.cumulativeOutputTokens || modelData.outputTokens || 0; const cacheReadTokens = modelData.cumulativeCacheReadInputTokens || modelData.cacheReadInputTokens || 0; const cacheCreationTokens = modelData.cumulativeCacheCreationInputTokens || modelData.cacheCreationInputTokens || 0; // Total used = input + output + cache tokens const totalUsed = inputTokens + outputTokens + cacheReadTokens + cacheCreationTokens; // Use configured context window budget from environment (default 160000) // This is the user's budget limit, not the model's context window const contextWindow = parseInt(process.env.CONTEXT_WINDOW) || 160000; // Token calc logged via token-budget WS event return { used: totalUsed, total: contextWindow }; } /** * Handles image processing for SDK queries * Saves base64 images to temporary files and returns modified prompt with file paths * @param {string} command - Original user prompt * @param {Array} images - Array of image objects with base64 data * @param {string} cwd - Working directory for temp file creation * @returns {Promise} {modifiedCommand, tempImagePaths, tempDir} */ async function handleImages(command, images, cwd) { const tempImagePaths = []; let tempDir = null; if (!images || images.length === 0) { return { modifiedCommand: command, tempImagePaths, tempDir }; } try { // Create temp directory in the project directory const workingDir = cwd || process.cwd(); tempDir = path.join(workingDir, '.tmp', 'images', Date.now().toString()); await fs.mkdir(tempDir, { recursive: true }); // Save each image to a temp file for (const [index, image] of images.entries()) { // Extract base64 data and mime type const matches = image.data.match(/^data:([^;]+);base64,(.+)$/); if (!matches) { console.error('Invalid image data format'); continue; } const [, mimeType, base64Data] = matches; const extension = mimeType.split('/')[1] || 'png'; const filename = `image_${index}.${extension}`; const filepath = path.join(tempDir, filename); // Write base64 data to file await fs.writeFile(filepath, Buffer.from(base64Data, 'base64')); tempImagePaths.push(filepath); } // Include the full image paths in the prompt let modifiedCommand = command; if (tempImagePaths.length > 0 && command && command.trim()) { const imageNote = `\n\n[Images provided at the following paths:]\n${tempImagePaths.map((p, i) => `${i + 1}. ${p}`).join('\n')}`; modifiedCommand = command + imageNote; } // Images processed return { modifiedCommand, tempImagePaths, tempDir }; } catch (error) { console.error('Error processing images for SDK:', error); return { modifiedCommand: command, tempImagePaths, tempDir }; } } /** * Cleans up temporary image files * @param {Array} tempImagePaths - Array of temp file paths to delete * @param {string} tempDir - Temp directory to remove */ async function cleanupTempFiles(tempImagePaths, tempDir) { if (!tempImagePaths || tempImagePaths.length === 0) { return; } try { // Delete individual temp files for (const imagePath of tempImagePaths) { await fs.unlink(imagePath).catch(err => console.error(`Failed to delete temp image ${imagePath}:`, err) ); } // Delete temp directory if (tempDir) { await fs.rm(tempDir, { recursive: true, force: true }).catch(err => console.error(`Failed to delete temp directory ${tempDir}:`, err) ); } // Temp files cleaned } catch (error) { console.error('Error during temp file cleanup:', error); } } /** * Loads MCP server configurations from ~/.claude.json * @param {string} cwd - Current working directory for project-specific configs * @returns {Object|null} MCP servers object or null if none found */ async function loadMcpConfig(cwd) { try { const claudeConfigPath = path.join(os.homedir(), '.claude.json'); // Check if config file exists try { await fs.access(claudeConfigPath); } catch (error) { // File doesn't exist, return null // No config file return null; } // Read and parse config file let claudeConfig; try { const configContent = await fs.readFile(claudeConfigPath, 'utf8'); claudeConfig = JSON.parse(configContent); } catch (error) { console.error('Failed to parse ~/.claude.json:', error.message); return null; } // Extract MCP servers (merge global and project-specific) let mcpServers = {}; // Add global MCP servers if (claudeConfig.mcpServers && typeof claudeConfig.mcpServers === 'object') { mcpServers = { ...claudeConfig.mcpServers }; // Global MCP servers loaded } // Add/override with project-specific MCP servers if (claudeConfig.claudeProjects && cwd) { const projectConfig = claudeConfig.claudeProjects[cwd]; if (projectConfig && projectConfig.mcpServers && typeof projectConfig.mcpServers === 'object') { mcpServers = { ...mcpServers, ...projectConfig.mcpServers }; // Project MCP servers merged } } // Return null if no servers found if (Object.keys(mcpServers).length === 0) { return null; } return mcpServers; } catch (error) { console.error('Error loading MCP config:', error.message); return null; } } /** * Executes a Claude query using the SDK * @param {string} command - User prompt/command * @param {Object} options - Query options * @param {Object} ws - WebSocket connection * @returns {Promise} */ async function queryClaudeSDK(command, options = {}, ws) { const { sessionId, sessionSummary } = options; let capturedSessionId = sessionId; let sessionCreatedSent = false; let tempImagePaths = []; let tempDir = null; const emitNotification = (event) => { notifyUserIfEnabled({ userId: ws?.userId || null, writer: ws, event }); }; try { // Map CLI options to SDK format const sdkOptions = mapCliOptionsToSDK(options); // Load MCP configuration const mcpServers = await loadMcpConfig(options.cwd); if (mcpServers) { sdkOptions.mcpServers = mcpServers; } // Handle images - save to temp files and modify prompt const imageResult = await handleImages(command, options.images, options.cwd); const finalCommand = imageResult.modifiedCommand; tempImagePaths = imageResult.tempImagePaths; tempDir = imageResult.tempDir; sdkOptions.hooks = { Notification: [{ matcher: '', hooks: [async (input) => { const message = typeof input?.message === 'string' ? input.message : 'Claude requires your attention.'; emitNotification(createNotificationEvent({ provider: 'claude', sessionId: capturedSessionId || sessionId || null, kind: 'action_required', code: 'agent.notification', meta: { message, sessionName: sessionSummary }, severity: 'warning', requiresUserAction: true, dedupeKey: `claude:hook:notification:${capturedSessionId || sessionId || 'none'}:${message}` })); return {}; }] }] }; sdkOptions.canUseTool = async (toolName, input, context) => { const requiresInteraction = TOOLS_REQUIRING_INTERACTION.has(toolName); if (!requiresInteraction) { if (sdkOptions.permissionMode === 'bypassPermissions') { return { behavior: 'allow', updatedInput: input }; } const isDisallowed = (sdkOptions.disallowedTools || []).some(entry => matchesToolPermission(entry, toolName, input) ); if (isDisallowed) { return { behavior: 'deny', message: 'Tool disallowed by settings' }; } const isAllowed = (sdkOptions.allowedTools || []).some(entry => matchesToolPermission(entry, toolName, input) ); if (isAllowed) { return { behavior: 'allow', updatedInput: input }; } } const requestId = createRequestId(); ws.send(createNormalizedMessage({ kind: 'permission_request', requestId, toolName, input, sessionId: capturedSessionId || sessionId || null, provider: 'claude' })); emitNotification(createNotificationEvent({ provider: 'claude', sessionId: capturedSessionId || sessionId || null, kind: 'action_required', code: 'permission.required', meta: { toolName, sessionName: sessionSummary }, severity: 'warning', requiresUserAction: true, dedupeKey: `claude:permission:${capturedSessionId || sessionId || 'none'}:${requestId}` })); const decision = await waitForToolApproval(requestId, { timeoutMs: requiresInteraction ? 0 : undefined, signal: context?.signal, metadata: { _sessionId: capturedSessionId || sessionId || null, _toolName: toolName, _input: input, _receivedAt: new Date(), }, onCancel: (reason) => { ws.send(createNormalizedMessage({ kind: 'permission_cancelled', requestId, reason, sessionId: capturedSessionId || sessionId || null, provider: 'claude' })); } }); if (!decision) { return { behavior: 'deny', message: 'Permission request timed out' }; } if (decision.cancelled) { return { behavior: 'deny', message: 'Permission request cancelled' }; } if (decision.allow) { if (decision.rememberEntry && typeof decision.rememberEntry === 'string') { if (!sdkOptions.allowedTools.includes(decision.rememberEntry)) { sdkOptions.allowedTools.push(decision.rememberEntry); } if (Array.isArray(sdkOptions.disallowedTools)) { sdkOptions.disallowedTools = sdkOptions.disallowedTools.filter(entry => entry !== decision.rememberEntry); } } return { behavior: 'allow', updatedInput: decision.updatedInput ?? input }; } return { behavior: 'deny', message: decision.message ?? 'User denied tool use' }; }; // Set stream-close timeout for interactive tools (Query constructor reads it synchronously). Claude Agent SDK has a default of 5s and this overrides it const prevStreamTimeout = process.env.CLAUDE_CODE_STREAM_CLOSE_TIMEOUT; process.env.CLAUDE_CODE_STREAM_CLOSE_TIMEOUT = '300000'; let queryInstance; try { queryInstance = query({ prompt: finalCommand, options: sdkOptions }); } catch (hookError) { // Older/newer SDK versions may not accept hook shapes yet. // Keep notification behavior operational via runtime events even if hook registration fails. console.warn('Failed to initialize Claude query with hooks, retrying without hooks:', hookError?.message || hookError); delete sdkOptions.hooks; queryInstance = query({ prompt: finalCommand, options: sdkOptions }); } // Restore immediately — Query constructor already captured the value if (prevStreamTimeout !== undefined) { process.env.CLAUDE_CODE_STREAM_CLOSE_TIMEOUT = prevStreamTimeout; } else { delete process.env.CLAUDE_CODE_STREAM_CLOSE_TIMEOUT; } // Track the query instance for abort capability if (capturedSessionId) { addSession(capturedSessionId, queryInstance, tempImagePaths, tempDir, ws); } // Process streaming messages console.log('Starting async generator loop for session:', capturedSessionId || 'NEW'); for await (const message of queryInstance) { // Capture session ID from first message if (message.session_id && !capturedSessionId) { capturedSessionId = message.session_id; addSession(capturedSessionId, queryInstance, tempImagePaths, tempDir, ws); // Set session ID on writer if (ws.setSessionId && typeof ws.setSessionId === 'function') { ws.setSessionId(capturedSessionId); } // Send session-created event only once for new sessions if (!sessionId && !sessionCreatedSent) { sessionCreatedSent = true; ws.send(createNormalizedMessage({ kind: 'session_created', newSessionId: capturedSessionId, sessionId: capturedSessionId, provider: 'claude' })); } } else { // session_id already captured } // Transform and normalize message via adapter const transformedMessage = transformMessage(message); const sid = capturedSessionId || sessionId || null; // Use adapter to normalize SDK events into NormalizedMessage[] const normalized = claudeAdapter.normalizeMessage(transformedMessage, sid); for (const msg of normalized) { // Preserve parentToolUseId from SDK wrapper for subagent tool grouping if (transformedMessage.parentToolUseId && !msg.parentToolUseId) { msg.parentToolUseId = transformedMessage.parentToolUseId; } ws.send(msg); } // Extract and send token budget updates from result messages if (message.type === 'result') { const models = Object.keys(message.modelUsage || {}); if (models.length > 0) { // Model info available in result message } const tokenBudgetData = extractTokenBudget(message); if (tokenBudgetData) { ws.send(createNormalizedMessage({ kind: 'status', text: 'token_budget', tokenBudget: tokenBudgetData, sessionId: capturedSessionId || sessionId || null, provider: 'claude' })); } } } // Clean up session on completion if (capturedSessionId) { removeSession(capturedSessionId); } // Clean up temporary image files await cleanupTempFiles(tempImagePaths, tempDir); // Send completion event ws.send(createNormalizedMessage({ kind: 'complete', exitCode: 0, isNewSession: !sessionId && !!command, sessionId: capturedSessionId, provider: 'claude' })); notifyRunStopped({ userId: ws?.userId || null, provider: 'claude', sessionId: capturedSessionId || sessionId || null, sessionName: sessionSummary, stopReason: 'completed' }); // Complete } catch (error) { console.error('SDK query error:', error); // Clean up session on error if (capturedSessionId) { removeSession(capturedSessionId); } // Clean up temporary image files on error await cleanupTempFiles(tempImagePaths, tempDir); // Send error to WebSocket ws.send(createNormalizedMessage({ kind: 'error', content: error.message, sessionId: capturedSessionId || sessionId || null, provider: 'claude' })); notifyRunFailed({ userId: ws?.userId || null, provider: 'claude', sessionId: capturedSessionId || sessionId || null, sessionName: sessionSummary, error }); throw error; } } /** * Aborts an active SDK session * @param {string} sessionId - Session identifier * @returns {boolean} True if session was aborted, false if not found */ async function abortClaudeSDKSession(sessionId) { const session = getSession(sessionId); if (!session) { console.log(`Session ${sessionId} not found`); return false; } try { console.log(`Aborting SDK session: ${sessionId}`); // Call interrupt() on the query instance await session.instance.interrupt(); // Update session status session.status = 'aborted'; // Clean up temporary image files await cleanupTempFiles(session.tempImagePaths, session.tempDir); // Clean up session removeSession(sessionId); return true; } catch (error) { console.error(`Error aborting session ${sessionId}:`, error); return false; } } /** * Checks if an SDK session is currently active * @param {string} sessionId - Session identifier * @returns {boolean} True if session is active */ function isClaudeSDKSessionActive(sessionId) { const session = getSession(sessionId); return session && session.status === 'active'; } /** * Gets all active SDK session IDs * @returns {Array} Array of active session IDs */ function getActiveClaudeSDKSessions() { return getAllSessions(); } /** * Get pending tool approvals for a specific session. * @param {string} sessionId - The session ID * @returns {Array} Array of pending permission request objects */ function getPendingApprovalsForSession(sessionId) { const pending = []; for (const [requestId, resolver] of pendingToolApprovals.entries()) { if (resolver._sessionId === sessionId) { pending.push({ requestId, toolName: resolver._toolName || 'UnknownTool', input: resolver._input, context: resolver._context, sessionId, receivedAt: resolver._receivedAt || new Date(), }); } } return pending; } /** * Reconnect a session's WebSocketWriter to a new raw WebSocket. * Called when client reconnects (e.g. page refresh) while SDK is still running. * @param {string} sessionId - The session ID * @param {Object} newRawWs - The new raw WebSocket connection * @returns {boolean} True if writer was successfully reconnected */ function reconnectSessionWriter(sessionId, newRawWs) { const session = getSession(sessionId); if (!session?.writer?.updateWebSocket) return false; session.writer.updateWebSocket(newRawWs); console.log(`[RECONNECT] Writer swapped for session ${sessionId}`); return true; } // Export public API export { queryClaudeSDK, abortClaudeSDKSession, isClaudeSDKSessionActive, getActiveClaudeSDKSessions, resolveToolApproval, getPendingApprovalsForSession, reconnectSessionWriter }; ================================================ FILE: server/cli.js ================================================ #!/usr/bin/env node /** * Claude Code UI CLI * * Provides command-line utilities for managing Claude Code UI * * Commands: * (no args) - Start the server (default) * start - Start the server * status - Show configuration and data locations * help - Show help information * version - Show version information */ import fs from 'fs'; import path from 'path'; import os from 'os'; import { fileURLToPath } from 'url'; import { dirname } from 'path'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); // ANSI color codes for terminal output const colors = { reset: '\x1b[0m', bright: '\x1b[1m', dim: '\x1b[2m', // Foreground colors cyan: '\x1b[36m', green: '\x1b[32m', yellow: '\x1b[33m', blue: '\x1b[34m', magenta: '\x1b[35m', white: '\x1b[37m', gray: '\x1b[90m', }; // Helper to colorize text const c = { info: (text) => `${colors.cyan}${text}${colors.reset}`, ok: (text) => `${colors.green}${text}${colors.reset}`, warn: (text) => `${colors.yellow}${text}${colors.reset}`, error: (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}`, }; // Load package.json for version info const packageJsonPath = path.join(__dirname, '../package.json'); const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')); // Load environment variables from .env file if it exists function loadEnvFile() { try { const envPath = path.join(__dirname, '../.env'); const envFile = fs.readFileSync(envPath, 'utf8'); envFile.split('\n').forEach(line => { const trimmedLine = line.trim(); if (trimmedLine && !trimmedLine.startsWith('#')) { const [key, ...valueParts] = trimmedLine.split('='); if (key && valueParts.length > 0 && !process.env[key]) { process.env[key] = valueParts.join('=').trim(); } } }); } catch (e) { // .env file is optional } } // Get the database path (same logic as db.js) function getDatabasePath() { loadEnvFile(); return process.env.DATABASE_PATH || path.join(__dirname, 'database', 'auth.db'); } // Get the installation directory function getInstallDir() { return path.join(__dirname, '..'); } // Show status command function showStatus() { console.log(`\n${c.bright('Claude Code UI - Status')}\n`); console.log(c.dim('═'.repeat(60))); // Version info console.log(`\n${c.info('[INFO]')} Version: ${c.bright(packageJson.version)}`); // Installation location const installDir = getInstallDir(); console.log(`\n${c.info('[INFO]')} Installation Directory:`); console.log(` ${c.dim(installDir)}`); // Database location const dbPath = getDatabasePath(); const dbExists = fs.existsSync(dbPath); console.log(`\n${c.info('[INFO]')} Database Location:`); console.log(` ${c.dim(dbPath)}`); console.log(` Status: ${dbExists ? c.ok('[OK] Exists') : c.warn('[WARN] Not created yet (will be created on first run)')}`); if (dbExists) { const stats = fs.statSync(dbPath); console.log(` Size: ${c.dim((stats.size / 1024).toFixed(2) + ' KB')}`); console.log(` Modified: ${c.dim(stats.mtime.toLocaleString())}`); } // Environment variables console.log(`\n${c.info('[INFO]')} Configuration:`); console.log(` SERVER_PORT: ${c.bright(process.env.SERVER_PORT || process.env.PORT || '3001')} ${c.dim(process.env.SERVER_PORT || process.env.PORT ? '' : '(default)')}`); console.log(` DATABASE_PATH: ${c.dim(process.env.DATABASE_PATH || '(using default location)')}`); console.log(` CLAUDE_CLI_PATH: ${c.dim(process.env.CLAUDE_CLI_PATH || 'claude (default)')}`); console.log(` CONTEXT_WINDOW: ${c.dim(process.env.CONTEXT_WINDOW || '160000 (default)')}`); // Claude projects folder const claudeProjectsPath = path.join(os.homedir(), '.claude', 'projects'); const projectsExists = fs.existsSync(claudeProjectsPath); console.log(`\n${c.info('[INFO]')} Claude Projects Folder:`); console.log(` ${c.dim(claudeProjectsPath)}`); console.log(` Status: ${projectsExists ? c.ok('[OK] Exists') : c.warn('[WARN] Not found')}`); // Config file location const envFilePath = path.join(__dirname, '../.env'); const envExists = fs.existsSync(envFilePath); console.log(`\n${c.info('[INFO]')} Configuration File:`); console.log(` ${c.dim(envFilePath)}`); console.log(` Status: ${envExists ? c.ok('[OK] Exists') : c.warn('[WARN] Not found (using defaults)')}`); console.log('\n' + c.dim('═'.repeat(60))); console.log(`\n${c.tip('[TIP]')} Hints:`); console.log(` ${c.dim('>')} Use ${c.bright('cloudcli --port 8080')} to run on a custom port`); console.log(` ${c.dim('>')} Use ${c.bright('cloudcli --database-path /path/to/db')} for custom database`); console.log(` ${c.dim('>')} Run ${c.bright('cloudcli help')} for all options`); console.log(` ${c.dim('>')} Access the UI at http://localhost:${process.env.SERVER_PORT || process.env.PORT || '3001'}\n`); } // Show help function showHelp() { console.log(` ╔═══════════════════════════════════════════════════════════════╗ ║ Claude Code UI - Command Line Tool ║ ╚═══════════════════════════════════════════════════════════════╝ Usage: claude-code-ui [command] [options] cloudcli [command] [options] Commands: start Start the Claude Code UI server (default) status Show configuration and data locations update Update to the latest version help Show this help information version Show version information Options: -p, --port Set server port (default: 3001) --database-path Set custom database location -h, --help Show this help information -v, --version Show version information Examples: $ cloudcli # Start with defaults $ cloudcli --port 8080 # Start on port 8080 $ cloudcli -p 3000 # Short form for port $ cloudcli start --port 4000 # Explicit start command $ cloudcli status # Show configuration Environment Variables: SERVER_PORT Set server port (default: 3001) PORT Set server port (default: 3001) (LEGACY) DATABASE_PATH Set custom database location CLAUDE_CLI_PATH Set custom Claude CLI path CONTEXT_WINDOW Set context window size (default: 160000) Documentation: ${packageJson.homepage || 'https://github.com/siteboon/claudecodeui'} Report Issues: ${packageJson.bugs?.url || 'https://github.com/siteboon/claudecodeui/issues'} `); } // Show version function showVersion() { console.log(`${packageJson.version}`); } // Compare semver versions, returns true if v1 > v2 function isNewerVersion(v1, v2) { const parts1 = v1.split('.').map(Number); const parts2 = v2.split('.').map(Number); for (let i = 0; i < 3; i++) { if (parts1[i] > parts2[i]) return true; if (parts1[i] < parts2[i]) return false; } return false; } // Check for updates async function checkForUpdates(silent = false) { try { const { execSync } = await import('child_process'); const latestVersion = execSync('npm show @siteboon/claude-code-ui version', { encoding: 'utf8' }).trim(); const currentVersion = packageJson.version; if (isNewerVersion(latestVersion, currentVersion)) { console.log(`\n${c.warn('[UPDATE]')} New version available: ${c.bright(latestVersion)} (current: ${currentVersion})`); console.log(` Run ${c.bright('cloudcli update')} to update\n`); return { hasUpdate: true, latestVersion, currentVersion }; } else if (!silent) { console.log(`${c.ok('[OK]')} You are on the latest version (${currentVersion})`); } return { hasUpdate: false, latestVersion, currentVersion }; } catch (e) { if (!silent) { console.log(`${c.warn('[WARN]')} Could not check for updates`); } return { hasUpdate: false, error: e.message }; } } // Update the package async function updatePackage() { try { const { execSync } = await import('child_process'); console.log(`${c.info('[INFO]')} Checking for updates...`); const { hasUpdate, latestVersion, currentVersion } = await checkForUpdates(true); if (!hasUpdate) { console.log(`${c.ok('[OK]')} Already on the latest version (${currentVersion})`); return; } console.log(`${c.info('[INFO]')} Updating from ${currentVersion} to ${latestVersion}...`); execSync('npm update -g @siteboon/claude-code-ui', { stdio: 'inherit' }); console.log(`${c.ok('[OK]')} Update complete! Restart cloudcli to use the new version.`); } catch (e) { console.error(`${c.error('[ERROR]')} Update failed: ${e.message}`); console.log(`${c.tip('[TIP]')} Try running manually: npm update -g @siteboon/claude-code-ui`); } } // Start the server async function startServer() { // Check for updates silently on startup checkForUpdates(true); // Import and run the server await import('./index.js'); } // Parse CLI arguments function parseArgs(args) { const parsed = { command: 'start', options: {} }; for (let i = 0; i < args.length; i++) { const arg = args[i]; if (arg === '--port' || arg === '-p') { parsed.options.serverPort = args[++i]; } else if (arg.startsWith('--port=')) { parsed.options.serverPort = arg.split('=')[1]; } else if (arg === '--database-path') { parsed.options.databasePath = args[++i]; } else if (arg.startsWith('--database-path=')) { parsed.options.databasePath = arg.split('=')[1]; } else if (arg === '--help' || arg === '-h') { parsed.command = 'help'; } else if (arg === '--version' || arg === '-v') { parsed.command = 'version'; } else if (!arg.startsWith('-')) { parsed.command = arg; } } return parsed; } // Main CLI handler async function main() { const args = process.argv.slice(2); const { command, options } = parseArgs(args); // Apply CLI options to environment variables if (options.serverPort) { process.env.SERVER_PORT = options.serverPort; } else if (!process.env.SERVER_PORT && process.env.PORT) { process.env.SERVER_PORT = process.env.PORT; } if (options.databasePath) { process.env.DATABASE_PATH = options.databasePath; } switch (command) { case 'start': await startServer(); break; case 'status': case 'info': showStatus(); break; case 'help': case '-h': case '--help': showHelp(); break; case 'version': case '-v': case '--version': showVersion(); break; case 'update': await updatePackage(); break; default: console.error(`\n❌ Unknown command: ${command}`); console.log(' Run "cloudcli help" for usage information.\n'); process.exit(1); } } // Run the CLI main().catch(error => { console.error('\n❌ Error:', error.message); process.exit(1); }); ================================================ FILE: server/constants/config.js ================================================ /** * Environment Flag: Is Platform * Indicates if the app is running in Platform mode (hosted) or OSS mode (self-hosted) */ export const IS_PLATFORM = process.env.VITE_IS_PLATFORM === 'true'; ================================================ FILE: server/cursor-cli.js ================================================ 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'; // Use cross-spawn on Windows for better command execution const spawnFunction = process.platform === 'win32' ? crossSpawn : spawn; let activeCursorProcesses = new Map(); // Track active processes by session ID const WORKSPACE_TRUST_PATTERNS = [ /workspace trust required/i, /do you trust the contents of this directory/i, /working with untrusted contents/i, /pass --trust,\s*--yolo,\s*or -f/i ]; function isWorkspaceTrustPrompt(text = '') { if (!text || typeof text !== 'string') { return false; } return WORKSPACE_TRUST_PATTERNS.some((pattern) => pattern.test(text)); } async function spawnCursor(command, options = {}, ws) { return new Promise(async (resolve, reject) => { const { sessionId, projectPath, cwd, resume, toolsSettings, skipPermissions, model, sessionSummary } = options; let capturedSessionId = sessionId; // Track session ID throughout the process let sessionCreatedSent = false; // Track if we've already sent session-created event let hasRetriedWithTrust = false; let settled = false; // Use tools settings passed from frontend, or defaults const settings = toolsSettings || { allowedShellCommands: [], skipPermissions: false }; // Build Cursor CLI command const baseArgs = []; // Build flags allowing both resume and prompt together (reply in existing session) // Treat presence of sessionId as intention to resume, regardless of resume flag if (sessionId) { baseArgs.push('--resume=' + sessionId); } if (command && command.trim()) { // Provide a prompt (works for both new and resumed sessions) baseArgs.push('-p', command); // Add model flag if specified (only meaningful for new sessions; harmless on resume) if (!sessionId && model) { baseArgs.push('--model', model); } // Request streaming JSON when we are providing a prompt baseArgs.push('--output-format', 'stream-json'); } // Add skip permissions flag if enabled if (skipPermissions || settings.skipPermissions) { baseArgs.push('-f'); console.log('Using -f flag (skip permissions)'); } // Use cwd (actual project directory) instead of projectPath const workingDir = cwd || projectPath || process.cwd(); // Store process reference for potential abort const processKey = capturedSessionId || Date.now().toString(); const settleOnce = (callback) => { if (settled) { return; } settled = true; callback(); }; const runCursorProcess = (args, runReason = 'initial') => { const isTrustRetry = runReason === 'trust-retry'; let runSawWorkspaceTrustPrompt = false; let stdoutLineBuffer = ''; let terminalNotificationSent = false; const notifyTerminalState = ({ code = null, error = null } = {}) => { if (terminalNotificationSent) { return; } terminalNotificationSent = true; const finalSessionId = capturedSessionId || sessionId || processKey; if (code === 0 && !error) { notifyRunStopped({ userId: ws?.userId || null, provider: 'cursor', sessionId: finalSessionId, sessionName: sessionSummary, stopReason: 'completed' }); return; } notifyRunFailed({ userId: ws?.userId || null, provider: 'cursor', sessionId: finalSessionId, sessionName: sessionSummary, error: error || `Cursor CLI exited with code ${code}` }); }; if (isTrustRetry) { console.log('Retrying Cursor CLI with --trust after workspace trust prompt'); } console.log('Spawning Cursor CLI:', 'cursor-agent', args.join(' ')); console.log('Working directory:', workingDir); console.log('Session info - Input sessionId:', sessionId, 'Resume:', resume); const cursorProcess = spawnFunction('cursor-agent', args, { cwd: workingDir, stdio: ['pipe', 'pipe', 'pipe'], env: { ...process.env } // Inherit all environment variables }); activeCursorProcesses.set(processKey, cursorProcess); const shouldSuppressForTrustRetry = (text) => { if (hasRetriedWithTrust || args.includes('--trust')) { return false; } if (!isWorkspaceTrustPrompt(text)) { return false; } runSawWorkspaceTrustPrompt = true; return true; }; const processCursorOutputLine = (line) => { if (!line || !line.trim()) { return; } try { const response = JSON.parse(line); console.log('Parsed JSON response:', response); // Handle different message types switch (response.type) { case 'system': if (response.subtype === 'init') { // Capture session ID if (response.session_id && !capturedSessionId) { capturedSessionId = response.session_id; console.log('Captured session ID:', capturedSessionId); // Update process key with captured session ID if (processKey !== capturedSessionId) { activeCursorProcesses.delete(processKey); activeCursorProcesses.set(capturedSessionId, cursorProcess); } // Set session ID on writer (for API endpoint compatibility) if (ws.setSessionId && typeof ws.setSessionId === 'function') { ws.setSessionId(capturedSessionId); } // Send session-created event only once for new sessions if (!sessionId && !sessionCreatedSent) { sessionCreatedSent = true; ws.send(createNormalizedMessage({ kind: 'session_created', newSessionId: capturedSessionId, model: response.model, cwd: response.cwd, sessionId: capturedSessionId, provider: 'cursor' })); } } // System info — no longer needed by the frontend (session-lifecycle 'created' handles nav). } break; case 'user': // User messages are not displayed in the UI — skip. break; 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); for (const msg of normalized) ws.send(msg); } break; case 'result': { // Session complete — send stream end + lifecycle complete with result payload console.log('Cursor session result:', response); const resultText = typeof response.result === 'string' ? response.result : ''; ws.send(createNormalizedMessage({ kind: 'complete', exitCode: response.subtype === 'success' ? 0 : 1, resultText, isError: response.subtype !== 'success', sessionId: capturedSessionId || sessionId, provider: 'cursor', })); break; } default: // Unknown message types — ignore. } } catch (parseError) { console.log('Non-JSON response:', line); if (shouldSuppressForTrustRetry(line)) { return; } // If not JSON, send as stream delta via adapter const normalized = cursorAdapter.normalizeMessage(line, capturedSessionId || sessionId || null); for (const msg of normalized) ws.send(msg); } }; // Handle stdout (streaming JSON responses) cursorProcess.stdout.on('data', (data) => { const rawOutput = data.toString(); console.log('Cursor CLI stdout:', rawOutput); // Stream chunks can split JSON objects across packets; keep trailing partial line. stdoutLineBuffer += rawOutput; const completeLines = stdoutLineBuffer.split(/\r?\n/); stdoutLineBuffer = completeLines.pop() || ''; completeLines.forEach((line) => { processCursorOutputLine(line.trim()); }); }); // Handle stderr cursorProcess.stderr.on('data', (data) => { const stderrText = data.toString(); console.error('Cursor CLI stderr:', stderrText); if (shouldSuppressForTrustRetry(stderrText)) { return; } ws.send(createNormalizedMessage({ kind: 'error', content: stderrText, sessionId: capturedSessionId || sessionId || null, provider: 'cursor' })); }); // Handle process completion cursorProcess.on('close', async (code) => { console.log(`Cursor CLI process exited with code ${code}`); const finalSessionId = capturedSessionId || sessionId || processKey; activeCursorProcesses.delete(finalSessionId); // Flush any final unterminated stdout line before completion handling. if (stdoutLineBuffer.trim()) { processCursorOutputLine(stdoutLineBuffer.trim()); stdoutLineBuffer = ''; } if ( runSawWorkspaceTrustPrompt && code !== 0 && !hasRetriedWithTrust && !args.includes('--trust') ) { hasRetriedWithTrust = true; runCursorProcess([...args, '--trust'], 'trust-retry'); return; } ws.send(createNormalizedMessage({ kind: 'complete', exitCode: code, isNewSession: !sessionId && !!command, sessionId: finalSessionId, provider: 'cursor' })); if (code === 0) { notifyTerminalState({ code }); settleOnce(() => resolve()); } else { notifyTerminalState({ code }); settleOnce(() => reject(new Error(`Cursor CLI exited with code ${code}`))); } }); // Handle process errors cursorProcess.on('error', (error) => { console.error('Cursor CLI process error:', error); // Clean up process reference on error const finalSessionId = capturedSessionId || sessionId || processKey; activeCursorProcesses.delete(finalSessionId); ws.send(createNormalizedMessage({ kind: 'error', content: error.message, sessionId: capturedSessionId || sessionId || null, provider: 'cursor' })); notifyTerminalState({ error }); settleOnce(() => reject(error)); }); // Close stdin since Cursor doesn't need interactive input cursorProcess.stdin.end(); }; runCursorProcess(baseArgs, 'initial'); }); } function abortCursorSession(sessionId) { const process = activeCursorProcesses.get(sessionId); if (process) { console.log(`Aborting Cursor session: ${sessionId}`); process.kill('SIGTERM'); activeCursorProcesses.delete(sessionId); return true; } return false; } function isCursorSessionActive(sessionId) { return activeCursorProcesses.has(sessionId); } function getActiveCursorSessions() { return Array.from(activeCursorProcesses.keys()); } export { spawnCursor, abortCursorSession, isCursorSessionActive, getActiveCursorSessions }; ================================================ FILE: server/database/db.js ================================================ import Database from 'better-sqlite3'; import path from 'path'; import fs from 'fs'; import crypto from 'crypto'; import { fileURLToPath } from 'url'; import { dirname } from 'path'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); // ANSI color codes for terminal output const colors = { reset: '\x1b[0m', bright: '\x1b[1m', cyan: '\x1b[36m', dim: '\x1b[2m', }; const c = { info: (text) => `${colors.cyan}${text}${colors.reset}`, bright: (text) => `${colors.bright}${text}${colors.reset}`, dim: (text) => `${colors.dim}${text}${colors.reset}`, }; // Use DATABASE_PATH environment variable if set, otherwise use default location const DB_PATH = process.env.DATABASE_PATH || path.join(__dirname, 'auth.db'); const INIT_SQL_PATH = path.join(__dirname, 'init.sql'); // Ensure database directory exists if custom path is provided if (process.env.DATABASE_PATH) { const dbDir = path.dirname(DB_PATH); try { if (!fs.existsSync(dbDir)) { fs.mkdirSync(dbDir, { recursive: true }); console.log(`Created database directory: ${dbDir}`); } } catch (error) { console.error(`Failed to create database directory ${dbDir}:`, error.message); throw error; } } // As part of 1.19.2 we are introducing a new location for auth.db. The below handles exisitng moving legacy database from install directory to new location const LEGACY_DB_PATH = path.join(__dirname, 'auth.db'); if (DB_PATH !== LEGACY_DB_PATH && !fs.existsSync(DB_PATH) && fs.existsSync(LEGACY_DB_PATH)) { try { fs.copyFileSync(LEGACY_DB_PATH, DB_PATH); console.log(`[MIGRATION] Copied database from ${LEGACY_DB_PATH} to ${DB_PATH}`); for (const suffix of ['-wal', '-shm']) { if (fs.existsSync(LEGACY_DB_PATH + suffix)) { fs.copyFileSync(LEGACY_DB_PATH + suffix, DB_PATH + suffix); } } } catch (err) { console.warn(`[MIGRATION] Could not copy legacy database: ${err.message}`); } } // Create database connection const db = new Database(DB_PATH); // app_config must exist before any other module imports (auth.js reads the JWT secret at load time). // runMigrations() also creates this table, but it runs too late for existing installations // where auth.js is imported before initializeDatabase() is called. db.exec(`CREATE TABLE IF NOT EXISTS app_config ( key TEXT PRIMARY KEY, value TEXT NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP )`); // Show app installation path prominently const appInstallPath = path.join(__dirname, '../..'); console.log(''); console.log(c.dim('═'.repeat(60))); console.log(`${c.info('[INFO]')} App Installation: ${c.bright(appInstallPath)}`); console.log(`${c.info('[INFO]')} Database: ${c.dim(path.relative(appInstallPath, DB_PATH))}`); if (process.env.DATABASE_PATH) { console.log(` ${c.dim('(Using custom DATABASE_PATH from environment)')}`); } console.log(c.dim('═'.repeat(60))); console.log(''); const runMigrations = () => { try { const tableInfo = db.prepare("PRAGMA table_info(users)").all(); const columnNames = tableInfo.map(col => col.name); if (!columnNames.includes('git_name')) { console.log('Running migration: Adding git_name column'); db.exec('ALTER TABLE users ADD COLUMN git_name TEXT'); } if (!columnNames.includes('git_email')) { console.log('Running migration: Adding git_email column'); db.exec('ALTER TABLE users ADD COLUMN git_email TEXT'); } if (!columnNames.includes('has_completed_onboarding')) { console.log('Running migration: Adding has_completed_onboarding column'); db.exec('ALTER TABLE users ADD COLUMN has_completed_onboarding BOOLEAN DEFAULT 0'); } db.exec(` CREATE TABLE IF NOT EXISTS user_notification_preferences ( user_id INTEGER PRIMARY KEY, preferences_json TEXT NOT NULL, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ) `); db.exec(` CREATE TABLE IF NOT EXISTS vapid_keys ( id INTEGER PRIMARY KEY AUTOINCREMENT, public_key TEXT NOT NULL, private_key TEXT NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ) `); db.exec(` CREATE TABLE IF NOT EXISTS push_subscriptions ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, endpoint TEXT NOT NULL UNIQUE, keys_p256dh TEXT NOT NULL, keys_auth TEXT NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ) `); // Create app_config table if it doesn't exist (for existing installations) db.exec(`CREATE TABLE IF NOT EXISTS app_config ( key TEXT PRIMARY KEY, value TEXT NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP )`); // Create session_names table if it doesn't exist (for existing installations) db.exec(`CREATE TABLE IF NOT EXISTS session_names ( id INTEGER PRIMARY KEY AUTOINCREMENT, session_id TEXT NOT NULL, provider TEXT NOT NULL DEFAULT 'claude', custom_name TEXT NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE(session_id, provider) )`); db.exec('CREATE INDEX IF NOT EXISTS idx_session_names_lookup ON session_names(session_id, provider)'); console.log('Database migrations completed successfully'); } catch (error) { console.error('Error running migrations:', error.message); throw error; } }; // Initialize database with schema const initializeDatabase = async () => { try { const initSQL = fs.readFileSync(INIT_SQL_PATH, 'utf8'); db.exec(initSQL); console.log('Database initialized successfully'); runMigrations(); } catch (error) { console.error('Error initializing database:', error.message); throw error; } }; // User database operations const userDb = { // Check if any users exist hasUsers: () => { try { const row = db.prepare('SELECT COUNT(*) as count FROM users').get(); return row.count > 0; } catch (err) { throw err; } }, // Create a new user createUser: (username, passwordHash) => { try { const stmt = db.prepare('INSERT INTO users (username, password_hash) VALUES (?, ?)'); const result = stmt.run(username, passwordHash); return { id: result.lastInsertRowid, username }; } catch (err) { throw err; } }, // Get user by username getUserByUsername: (username) => { try { const row = db.prepare('SELECT * FROM users WHERE username = ? AND is_active = 1').get(username); return row; } catch (err) { throw err; } }, // Update last login time (non-fatal — logged but not thrown) updateLastLogin: (userId) => { try { db.prepare('UPDATE users SET last_login = CURRENT_TIMESTAMP WHERE id = ?').run(userId); } catch (err) { console.warn('Failed to update last login:', err.message); } }, // Get user by ID getUserById: (userId) => { try { const row = db.prepare('SELECT id, username, created_at, last_login FROM users WHERE id = ? AND is_active = 1').get(userId); return row; } catch (err) { throw err; } }, getFirstUser: () => { try { const row = db.prepare('SELECT id, username, created_at, last_login FROM users WHERE is_active = 1 LIMIT 1').get(); return row; } catch (err) { throw err; } }, updateGitConfig: (userId, gitName, gitEmail) => { try { const stmt = db.prepare('UPDATE users SET git_name = ?, git_email = ? WHERE id = ?'); stmt.run(gitName, gitEmail, userId); } catch (err) { throw err; } }, getGitConfig: (userId) => { try { const row = db.prepare('SELECT git_name, git_email FROM users WHERE id = ?').get(userId); return row; } catch (err) { throw err; } }, completeOnboarding: (userId) => { try { const stmt = db.prepare('UPDATE users SET has_completed_onboarding = 1 WHERE id = ?'); stmt.run(userId); } catch (err) { throw err; } }, hasCompletedOnboarding: (userId) => { try { const row = db.prepare('SELECT has_completed_onboarding FROM users WHERE id = ?').get(userId); return row?.has_completed_onboarding === 1; } catch (err) { throw err; } } }; // API Keys database operations const apiKeysDb = { // Generate a new API key generateApiKey: () => { return 'ck_' + crypto.randomBytes(32).toString('hex'); }, // Create a new API key createApiKey: (userId, keyName) => { try { const apiKey = apiKeysDb.generateApiKey(); const stmt = db.prepare('INSERT INTO api_keys (user_id, key_name, api_key) VALUES (?, ?, ?)'); const result = stmt.run(userId, keyName, apiKey); return { id: result.lastInsertRowid, keyName, apiKey }; } catch (err) { throw err; } }, // Get all API keys for a user getApiKeys: (userId) => { try { const rows = db.prepare('SELECT id, key_name, api_key, created_at, last_used, is_active FROM api_keys WHERE user_id = ? ORDER BY created_at DESC').all(userId); return rows; } catch (err) { throw err; } }, // Validate API key and get user validateApiKey: (apiKey) => { try { const row = db.prepare(` SELECT u.id, u.username, ak.id as api_key_id FROM api_keys ak JOIN users u ON ak.user_id = u.id WHERE ak.api_key = ? AND ak.is_active = 1 AND u.is_active = 1 `).get(apiKey); if (row) { // Update last_used timestamp db.prepare('UPDATE api_keys SET last_used = CURRENT_TIMESTAMP WHERE id = ?').run(row.api_key_id); } return row; } catch (err) { throw err; } }, // Delete an API key deleteApiKey: (userId, apiKeyId) => { try { const stmt = db.prepare('DELETE FROM api_keys WHERE id = ? AND user_id = ?'); const result = stmt.run(apiKeyId, userId); return result.changes > 0; } catch (err) { throw err; } }, // Toggle API key active status toggleApiKey: (userId, apiKeyId, isActive) => { try { const stmt = db.prepare('UPDATE api_keys SET is_active = ? WHERE id = ? AND user_id = ?'); const result = stmt.run(isActive ? 1 : 0, apiKeyId, userId); return result.changes > 0; } catch (err) { throw err; } } }; // User credentials database operations (for GitHub tokens, GitLab tokens, etc.) const credentialsDb = { // Create a new credential createCredential: (userId, credentialName, credentialType, credentialValue, description = null) => { try { const stmt = db.prepare('INSERT INTO user_credentials (user_id, credential_name, credential_type, credential_value, description) VALUES (?, ?, ?, ?, ?)'); const result = stmt.run(userId, credentialName, credentialType, credentialValue, description); return { id: result.lastInsertRowid, credentialName, credentialType }; } catch (err) { throw err; } }, // Get all credentials for a user, optionally filtered by type getCredentials: (userId, credentialType = null) => { try { let query = 'SELECT id, credential_name, credential_type, description, created_at, is_active FROM user_credentials WHERE user_id = ?'; const params = [userId]; if (credentialType) { query += ' AND credential_type = ?'; params.push(credentialType); } query += ' ORDER BY created_at DESC'; const rows = db.prepare(query).all(...params); return rows; } catch (err) { throw err; } }, // Get active credential value for a user by type (returns most recent active) getActiveCredential: (userId, credentialType) => { try { const row = db.prepare('SELECT credential_value FROM user_credentials WHERE user_id = ? AND credential_type = ? AND is_active = 1 ORDER BY created_at DESC LIMIT 1').get(userId, credentialType); return row?.credential_value || null; } catch (err) { throw err; } }, // Delete a credential deleteCredential: (userId, credentialId) => { try { const stmt = db.prepare('DELETE FROM user_credentials WHERE id = ? AND user_id = ?'); const result = stmt.run(credentialId, userId); return result.changes > 0; } catch (err) { throw err; } }, // Toggle credential active status toggleCredential: (userId, credentialId, isActive) => { try { const stmt = db.prepare('UPDATE user_credentials SET is_active = ? WHERE id = ? AND user_id = ?'); const result = stmt.run(isActive ? 1 : 0, credentialId, userId); return result.changes > 0; } catch (err) { throw err; } } }; const DEFAULT_NOTIFICATION_PREFERENCES = { channels: { inApp: false, webPush: false }, events: { actionRequired: true, stop: true, error: true } }; const normalizeNotificationPreferences = (value) => { const source = value && typeof value === 'object' ? value : {}; return { channels: { inApp: source.channels?.inApp === true, webPush: source.channels?.webPush === true }, events: { actionRequired: source.events?.actionRequired !== false, stop: source.events?.stop !== false, error: source.events?.error !== false } }; }; const notificationPreferencesDb = { getPreferences: (userId) => { try { const row = db.prepare('SELECT preferences_json FROM user_notification_preferences WHERE user_id = ?').get(userId); if (!row) { const defaults = normalizeNotificationPreferences(DEFAULT_NOTIFICATION_PREFERENCES); db.prepare( 'INSERT INTO user_notification_preferences (user_id, preferences_json, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP)' ).run(userId, JSON.stringify(defaults)); return defaults; } let parsed; try { parsed = JSON.parse(row.preferences_json); } catch { parsed = DEFAULT_NOTIFICATION_PREFERENCES; } return normalizeNotificationPreferences(parsed); } catch (err) { throw err; } }, updatePreferences: (userId, preferences) => { try { const normalized = normalizeNotificationPreferences(preferences); db.prepare( `INSERT INTO user_notification_preferences (user_id, preferences_json, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP) ON CONFLICT(user_id) DO UPDATE SET preferences_json = excluded.preferences_json, updated_at = CURRENT_TIMESTAMP` ).run(userId, JSON.stringify(normalized)); return normalized; } catch (err) { throw err; } } }; const pushSubscriptionsDb = { saveSubscription: (userId, endpoint, keysP256dh, keysAuth) => { try { db.prepare( `INSERT INTO push_subscriptions (user_id, endpoint, keys_p256dh, keys_auth) VALUES (?, ?, ?, ?) ON CONFLICT(endpoint) DO UPDATE SET user_id = excluded.user_id, keys_p256dh = excluded.keys_p256dh, keys_auth = excluded.keys_auth` ).run(userId, endpoint, keysP256dh, keysAuth); } catch (err) { throw err; } }, getSubscriptions: (userId) => { try { return db.prepare('SELECT endpoint, keys_p256dh, keys_auth FROM push_subscriptions WHERE user_id = ?').all(userId); } catch (err) { throw err; } }, removeSubscription: (endpoint) => { try { db.prepare('DELETE FROM push_subscriptions WHERE endpoint = ?').run(endpoint); } catch (err) { throw err; } }, removeAllForUser: (userId) => { try { db.prepare('DELETE FROM push_subscriptions WHERE user_id = ?').run(userId); } catch (err) { throw err; } } }; // Session custom names database operations const sessionNamesDb = { // Set (insert or update) a custom session name setName: (sessionId, provider, customName) => { db.prepare(` INSERT INTO session_names (session_id, provider, custom_name) VALUES (?, ?, ?) ON CONFLICT(session_id, provider) DO UPDATE SET custom_name = excluded.custom_name, updated_at = CURRENT_TIMESTAMP `).run(sessionId, provider, customName); }, // Get a single custom session name getName: (sessionId, provider) => { const row = db.prepare( 'SELECT custom_name FROM session_names WHERE session_id = ? AND provider = ?' ).get(sessionId, provider); return row?.custom_name || null; }, // Batch lookup — returns Map getNames: (sessionIds, provider) => { if (!sessionIds.length) return new Map(); const placeholders = sessionIds.map(() => '?').join(','); const rows = db.prepare( `SELECT session_id, custom_name FROM session_names WHERE session_id IN (${placeholders}) AND provider = ?` ).all(...sessionIds, provider); return new Map(rows.map(r => [r.session_id, r.custom_name])); }, // Delete a custom session name deleteName: (sessionId, provider) => { return db.prepare( 'DELETE FROM session_names WHERE session_id = ? AND provider = ?' ).run(sessionId, provider).changes > 0; }, }; // Apply custom session names from the database (overrides CLI-generated summaries) function applyCustomSessionNames(sessions, provider) { if (!sessions?.length) return; try { const ids = sessions.map(s => s.id); const customNames = sessionNamesDb.getNames(ids, provider); for (const session of sessions) { const custom = customNames.get(session.id); if (custom) session.summary = custom; } } catch (error) { console.warn(`[DB] Failed to apply custom session names for ${provider}:`, error.message); } } // App config database operations const appConfigDb = { get: (key) => { try { const row = db.prepare('SELECT value FROM app_config WHERE key = ?').get(key); return row?.value || null; } catch (err) { return null; } }, set: (key, value) => { db.prepare( 'INSERT INTO app_config (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value' ).run(key, value); }, getOrCreateJwtSecret: () => { let secret = appConfigDb.get('jwt_secret'); if (!secret) { secret = crypto.randomBytes(64).toString('hex'); appConfigDb.set('jwt_secret', secret); } return secret; } }; // Backward compatibility - keep old names pointing to new system const githubTokensDb = { createGithubToken: (userId, tokenName, githubToken, description = null) => { return credentialsDb.createCredential(userId, tokenName, 'github_token', githubToken, description); }, getGithubTokens: (userId) => { return credentialsDb.getCredentials(userId, 'github_token'); }, getActiveGithubToken: (userId) => { return credentialsDb.getActiveCredential(userId, 'github_token'); }, deleteGithubToken: (userId, tokenId) => { return credentialsDb.deleteCredential(userId, tokenId); }, toggleGithubToken: (userId, tokenId, isActive) => { return credentialsDb.toggleCredential(userId, tokenId, isActive); } }; export { db, initializeDatabase, userDb, apiKeysDb, credentialsDb, notificationPreferencesDb, pushSubscriptionsDb, sessionNamesDb, applyCustomSessionNames, appConfigDb, githubTokensDb // Backward compatibility }; ================================================ FILE: server/database/init.sql ================================================ -- Initialize authentication database PRAGMA foreign_keys = ON; -- Users table (single user system) CREATE TABLE IF NOT EXISTS users ( id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT UNIQUE NOT NULL, password_hash TEXT NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, last_login DATETIME, is_active BOOLEAN DEFAULT 1, git_name TEXT, git_email TEXT, has_completed_onboarding BOOLEAN DEFAULT 0 ); -- Indexes for performance CREATE INDEX IF NOT EXISTS idx_users_username ON users(username); CREATE INDEX IF NOT EXISTS idx_users_active ON users(is_active); -- API Keys table for external API access CREATE TABLE IF NOT EXISTS api_keys ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, key_name TEXT NOT NULL, api_key TEXT UNIQUE NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, last_used DATETIME, is_active BOOLEAN DEFAULT 1, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ); CREATE INDEX IF NOT EXISTS idx_api_keys_key ON api_keys(api_key); CREATE INDEX IF NOT EXISTS idx_api_keys_user_id ON api_keys(user_id); CREATE INDEX IF NOT EXISTS idx_api_keys_active ON api_keys(is_active); -- User credentials table for storing various tokens/credentials (GitHub, GitLab, etc.) CREATE TABLE IF NOT EXISTS user_credentials ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, credential_name TEXT NOT NULL, credential_type TEXT NOT NULL, -- 'github_token', 'gitlab_token', 'bitbucket_token', etc. credential_value TEXT NOT NULL, description TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, is_active BOOLEAN DEFAULT 1, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ); CREATE INDEX IF NOT EXISTS idx_user_credentials_user_id ON user_credentials(user_id); CREATE INDEX IF NOT EXISTS idx_user_credentials_type ON user_credentials(credential_type); CREATE INDEX IF NOT EXISTS idx_user_credentials_active ON user_credentials(is_active); -- User notification preferences (backend-owned, provider-agnostic) CREATE TABLE IF NOT EXISTS user_notification_preferences ( user_id INTEGER PRIMARY KEY, preferences_json TEXT NOT NULL, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ); -- VAPID key pair for Web Push notifications CREATE TABLE IF NOT EXISTS vapid_keys ( id INTEGER PRIMARY KEY AUTOINCREMENT, public_key TEXT NOT NULL, private_key TEXT NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ); -- Browser push subscriptions CREATE TABLE IF NOT EXISTS push_subscriptions ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, endpoint TEXT NOT NULL UNIQUE, keys_p256dh TEXT NOT NULL, keys_auth TEXT NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ); -- Session custom names (provider-agnostic display name overrides) CREATE TABLE IF NOT EXISTS session_names ( id INTEGER PRIMARY KEY AUTOINCREMENT, session_id TEXT NOT NULL, provider TEXT NOT NULL DEFAULT 'claude', custom_name TEXT NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE(session_id, provider) ); CREATE INDEX IF NOT EXISTS idx_session_names_lookup ON session_names(session_id, provider); -- App configuration table (auto-generated secrets, settings, etc.) CREATE TABLE IF NOT EXISTS app_config ( key TEXT PRIMARY KEY, value TEXT NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ); ================================================ FILE: server/gemini-cli.js ================================================ import { spawn } from 'child_process'; import crossSpawn from 'cross-spawn'; // Use cross-spawn on Windows for correct .cmd resolution (same pattern as cursor-cli.js) const spawnFunction = process.platform === 'win32' ? crossSpawn : spawn; import { promises as fs } from 'fs'; import path from 'path'; 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'; let activeGeminiProcesses = new Map(); // Track active processes by session ID async function spawnGemini(command, options = {}, ws) { const { sessionId, projectPath, cwd, toolsSettings, permissionMode, images, sessionSummary } = options; let capturedSessionId = sessionId; // Track session ID throughout the process let sessionCreatedSent = false; // Track if we've already sent session-created event let assistantBlocks = []; // Accumulate the full response blocks including tools // Use tools settings passed from frontend, or defaults const settings = toolsSettings || { allowedTools: [], disallowedTools: [], skipPermissions: false }; // Build Gemini CLI command - start with print/resume flags first const args = []; // Add prompt flag with command if we have a command if (command && command.trim()) { args.push('--prompt', command); } // If we have a sessionId, we want to resume if (sessionId) { const session = sessionManager.getSession(sessionId); if (session && session.cliSessionId) { args.push('--resume', session.cliSessionId); } } // Use cwd (actual project directory) instead of projectPath (Gemini's metadata directory) // Clean the path by removing any non-printable characters const cleanPath = (cwd || projectPath || process.cwd()).replace(/[^\x20-\x7E]/g, '').trim(); const workingDir = cleanPath; // Handle images by saving them to temporary files and passing paths to Gemini const tempImagePaths = []; let tempDir = null; if (images && images.length > 0) { try { // Create temp directory in the project directory so Gemini can access it tempDir = path.join(workingDir, '.tmp', 'images', Date.now().toString()); await fs.mkdir(tempDir, { recursive: true }); // Save each image to a temp file for (const [index, image] of images.entries()) { // Extract base64 data and mime type const matches = image.data.match(/^data:([^;]+);base64,(.+)$/); if (!matches) { continue; } const [, mimeType, base64Data] = matches; const extension = mimeType.split('/')[1] || 'png'; const filename = `image_${index}.${extension}`; const filepath = path.join(tempDir, filename); // Write base64 data to file await fs.writeFile(filepath, Buffer.from(base64Data, 'base64')); tempImagePaths.push(filepath); } // Include the full image paths in the prompt for Gemini to reference // Gemini CLI can read images from file paths in the prompt if (tempImagePaths.length > 0 && command && command.trim()) { const imageNote = `\n\n[Images given: ${tempImagePaths.length} images are located at the following paths:]\n${tempImagePaths.map((p, i) => `${i + 1}. ${p}`).join('\n')}`; const modifiedCommand = command + imageNote; // Update the command in args const promptIndex = args.indexOf('--prompt'); if (promptIndex !== -1 && args[promptIndex + 1] === command) { args[promptIndex + 1] = modifiedCommand; } else if (promptIndex !== -1) { // If we're using context, update the full prompt args[promptIndex + 1] = args[promptIndex + 1] + imageNote; } } } catch (error) { console.error('Error processing images for Gemini:', error); } } // Add basic flags for Gemini if (options.debug) { args.push('--debug'); } // Add MCP config flag only if MCP servers are configured try { const geminiConfigPath = path.join(os.homedir(), '.gemini.json'); let hasMcpServers = false; try { await fs.access(geminiConfigPath); const geminiConfigRaw = await fs.readFile(geminiConfigPath, 'utf8'); const geminiConfig = JSON.parse(geminiConfigRaw); // Check global MCP servers if (geminiConfig.mcpServers && Object.keys(geminiConfig.mcpServers).length > 0) { hasMcpServers = true; } // Check project-specific MCP servers if (!hasMcpServers && geminiConfig.geminiProjects) { const currentProjectPath = process.cwd(); const projectConfig = geminiConfig.geminiProjects[currentProjectPath]; if (projectConfig && projectConfig.mcpServers && Object.keys(projectConfig.mcpServers).length > 0) { hasMcpServers = true; } } } catch (e) { // Ignore if file doesn't exist or isn't parsable } if (hasMcpServers) { args.push('--mcp-config', geminiConfigPath); } } catch (error) { // Ignore outer errors } // Add model for all sessions (both new and resumed) let modelToUse = options.model || 'gemini-2.5-flash'; args.push('--model', modelToUse); args.push('--output-format', 'stream-json'); // Handle approval modes and allowed tools if (settings.skipPermissions || options.skipPermissions || permissionMode === 'yolo') { args.push('--yolo'); } else if (permissionMode === 'auto_edit') { args.push('--approval-mode', 'auto_edit'); } else if (permissionMode === 'plan') { args.push('--approval-mode', 'plan'); } if (settings.allowedTools && settings.allowedTools.length > 0) { args.push('--allowed-tools', settings.allowedTools.join(',')); } // Try to find gemini in PATH first, then fall back to environment variable const geminiPath = process.env.GEMINI_PATH || 'gemini'; console.log('Spawning Gemini CLI:', geminiPath, args.join(' ')); console.log('Working directory:', workingDir); let spawnCmd = geminiPath; let spawnArgs = args; // On non-Windows platforms, wrap the execution in a shell to avoid ENOEXEC // which happens when the target is a script lacking a shebang. if (os.platform() !== 'win32') { spawnCmd = 'sh'; // Use exec to replace the shell process, ensuring signals hit gemini directly spawnArgs = ['-c', 'exec "$0" "$@"', geminiPath, ...args]; } return new Promise((resolve, reject) => { const geminiProcess = spawnFunction(spawnCmd, spawnArgs, { cwd: workingDir, stdio: ['pipe', 'pipe', 'pipe'], env: { ...process.env } // Inherit all environment variables }); let terminalNotificationSent = false; let terminalFailureReason = null; const notifyTerminalState = ({ code = null, error = null } = {}) => { if (terminalNotificationSent) { return; } terminalNotificationSent = true; const finalSessionId = capturedSessionId || sessionId || processKey; if (code === 0 && !error) { notifyRunStopped({ userId: ws?.userId || null, provider: 'gemini', sessionId: finalSessionId, sessionName: sessionSummary, stopReason: 'completed' }); return; } notifyRunFailed({ userId: ws?.userId || null, provider: 'gemini', sessionId: finalSessionId, sessionName: sessionSummary, error: error || terminalFailureReason || `Gemini CLI exited with code ${code}` }); }; // Attach temp file info to process for cleanup later geminiProcess.tempImagePaths = tempImagePaths; geminiProcess.tempDir = tempDir; // Store process reference for potential abort const processKey = capturedSessionId || sessionId || Date.now().toString(); activeGeminiProcesses.set(processKey, geminiProcess); // Store sessionId on the process object for debugging geminiProcess.sessionId = processKey; // Close stdin to signal we're done sending input geminiProcess.stdin.end(); // Add timeout handler const timeoutMs = 120000; // 120 seconds for slower models let timeout; const startTimeout = () => { if (timeout) clearTimeout(timeout); timeout = setTimeout(() => { const socketSessionId = typeof ws.getSessionId === 'function' ? ws.getSessionId() : (capturedSessionId || sessionId || processKey); terminalFailureReason = `Gemini CLI timeout - no response received for ${timeoutMs / 1000} seconds`; ws.send(createNormalizedMessage({ kind: 'error', content: terminalFailureReason, sessionId: socketSessionId, provider: 'gemini' })); try { geminiProcess.kill('SIGTERM'); } catch (e) { } }, timeoutMs); }; startTimeout(); // Save user message to session when starting if (command && capturedSessionId) { sessionManager.addMessage(capturedSessionId, 'user', command); } // Create response handler for NDJSON buffering let responseHandler; if (ws) { responseHandler = new GeminiResponseHandler(ws, { onContentFragment: (content) => { if (assistantBlocks.length > 0 && assistantBlocks[assistantBlocks.length - 1].type === 'text') { assistantBlocks[assistantBlocks.length - 1].text += content; } else { assistantBlocks.push({ type: 'text', text: content }); } }, onToolUse: (event) => { assistantBlocks.push({ type: 'tool_use', id: event.tool_id, name: event.tool_name, input: event.parameters }); }, onToolResult: (event) => { if (capturedSessionId) { if (assistantBlocks.length > 0) { sessionManager.addMessage(capturedSessionId, 'assistant', [...assistantBlocks]); assistantBlocks = []; } sessionManager.addMessage(capturedSessionId, 'user', [{ type: 'tool_result', tool_use_id: event.tool_id, content: event.output === undefined ? null : event.output, is_error: event.status === 'error' }]); } }, onInit: (event) => { if (capturedSessionId) { const sess = sessionManager.getSession(capturedSessionId); if (sess && !sess.cliSessionId) { sess.cliSessionId = event.session_id; sessionManager.saveSession(capturedSessionId); } } } }); } // Handle stdout geminiProcess.stdout.on('data', (data) => { const rawOutput = data.toString(); startTimeout(); // Re-arm the timeout // For new sessions, create a session ID FIRST if (!sessionId && !sessionCreatedSent && !capturedSessionId) { capturedSessionId = `gemini_${Date.now()}`; sessionCreatedSent = true; // Create session in session manager sessionManager.createSession(capturedSessionId, cwd || process.cwd()); // Save the user message now that we have a session ID if (command) { sessionManager.addMessage(capturedSessionId, 'user', command); } // Update process key with captured session ID if (processKey !== capturedSessionId) { activeGeminiProcesses.delete(processKey); activeGeminiProcesses.set(capturedSessionId, geminiProcess); } ws.setSessionId && typeof ws.setSessionId === 'function' && ws.setSessionId(capturedSessionId); ws.send(createNormalizedMessage({ kind: 'session_created', newSessionId: capturedSessionId, sessionId: capturedSessionId, provider: 'gemini' })); } if (responseHandler) { responseHandler.processData(rawOutput); } else if (rawOutput) { // Fallback to direct sending for raw CLI mode without WS if (assistantBlocks.length > 0 && assistantBlocks[assistantBlocks.length - 1].type === 'text') { assistantBlocks[assistantBlocks.length - 1].text += rawOutput; } else { assistantBlocks.push({ type: 'text', text: rawOutput }); } const socketSessionId = typeof ws.getSessionId === 'function' ? ws.getSessionId() : (capturedSessionId || sessionId); ws.send(createNormalizedMessage({ kind: 'stream_delta', content: rawOutput, sessionId: socketSessionId, provider: 'gemini' })); } }); // Handle stderr geminiProcess.stderr.on('data', (data) => { const errorMsg = data.toString(); // Filter out deprecation warnings and "Loaded cached credentials" message if (errorMsg.includes('[DEP0040]') || errorMsg.includes('DeprecationWarning') || errorMsg.includes('--trace-deprecation') || errorMsg.includes('Loaded cached credentials')) { return; } const socketSessionId = typeof ws.getSessionId === 'function' ? ws.getSessionId() : (capturedSessionId || sessionId); ws.send(createNormalizedMessage({ kind: 'error', content: errorMsg, sessionId: socketSessionId, provider: 'gemini' })); }); // Handle process completion geminiProcess.on('close', async (code) => { clearTimeout(timeout); // Flush any remaining buffered content if (responseHandler) { responseHandler.forceFlush(); responseHandler.destroy(); } // Clean up process reference const finalSessionId = capturedSessionId || sessionId || processKey; activeGeminiProcesses.delete(finalSessionId); // Save assistant response to session if we have one if (finalSessionId && assistantBlocks.length > 0) { sessionManager.addMessage(finalSessionId, 'assistant', assistantBlocks); } ws.send(createNormalizedMessage({ kind: 'complete', exitCode: code, isNewSession: !sessionId && !!command, sessionId: finalSessionId, provider: 'gemini' })); // Clean up temporary image files if any if (geminiProcess.tempImagePaths && geminiProcess.tempImagePaths.length > 0) { for (const imagePath of geminiProcess.tempImagePaths) { await fs.unlink(imagePath).catch(err => { }); } if (geminiProcess.tempDir) { await fs.rm(geminiProcess.tempDir, { recursive: true, force: true }).catch(err => { }); } } if (code === 0) { notifyTerminalState({ code }); resolve(); } else { notifyTerminalState({ code, error: code === null ? 'Gemini CLI process was terminated or timed out' : null }); reject(new Error(code === null ? 'Gemini CLI process was terminated or timed out' : `Gemini CLI exited with code ${code}`)); } }); // Handle process errors geminiProcess.on('error', (error) => { // Clean up process reference on error const finalSessionId = capturedSessionId || sessionId || processKey; activeGeminiProcesses.delete(finalSessionId); const errorSessionId = typeof ws.getSessionId === 'function' ? ws.getSessionId() : finalSessionId; ws.send(createNormalizedMessage({ kind: 'error', content: error.message, sessionId: errorSessionId, provider: 'gemini' })); notifyTerminalState({ error }); reject(error); }); }); } function abortGeminiSession(sessionId) { let geminiProc = activeGeminiProcesses.get(sessionId); let processKey = sessionId; if (!geminiProc) { for (const [key, proc] of activeGeminiProcesses.entries()) { if (proc.sessionId === sessionId) { geminiProc = proc; processKey = key; break; } } } if (geminiProc) { try { geminiProc.kill('SIGTERM'); setTimeout(() => { if (activeGeminiProcesses.has(processKey)) { try { geminiProc.kill('SIGKILL'); } catch (e) { } } }, 2000); // Wait 2 seconds before force kill return true; } catch (error) { return false; } } return false; } function isGeminiSessionActive(sessionId) { return activeGeminiProcesses.has(sessionId); } function getActiveGeminiSessions() { return Array.from(activeGeminiProcesses.keys()); } export { spawnGemini, abortGeminiSession, isGeminiSessionActive, getActiveGeminiSessions }; ================================================ FILE: server/gemini-response-handler.js ================================================ // Gemini Response Handler - JSON Stream processing import { geminiAdapter } from './providers/gemini/adapter.js'; class GeminiResponseHandler { constructor(ws, options = {}) { this.ws = ws; this.buffer = ''; this.onContentFragment = options.onContentFragment || null; this.onInit = options.onInit || null; this.onToolUse = options.onToolUse || null; this.onToolResult = options.onToolResult || null; } // Process incoming raw data from Gemini stream-json processData(data) { this.buffer += data; // Split by newline const lines = this.buffer.split('\n'); // Keep the last incomplete line in the buffer this.buffer = lines.pop() || ''; for (const line of lines) { if (!line.trim()) continue; try { const event = JSON.parse(line); this.handleEvent(event); } catch (err) { // Not a JSON line, probably debug output or CLI warnings } } } handleEvent(event) { const sid = typeof this.ws.getSessionId === 'function' ? this.ws.getSessionId() : null; if (event.type === 'init') { if (this.onInit) { this.onInit(event); } return; } // Invoke per-type callbacks for session tracking if (event.type === 'message' && event.role === 'assistant') { const content = event.content || ''; if (this.onContentFragment && content) { this.onContentFragment(content); } } else if (event.type === 'tool_use' && this.onToolUse) { this.onToolUse(event); } else if (event.type === 'tool_result' && this.onToolResult) { this.onToolResult(event); } // Normalize via adapter and send all resulting messages const normalized = geminiAdapter.normalizeMessage(event, sid); for (const msg of normalized) { this.ws.send(msg); } } forceFlush() { if (this.buffer.trim()) { try { const event = JSON.parse(this.buffer); this.handleEvent(event); } catch (err) { } } } destroy() { this.buffer = ''; } } export default GeminiResponseHandler; ================================================ FILE: server/index.js ================================================ #!/usr/bin/env node // Load environment variables before other imports execute import './load-env.js'; import fs from 'fs'; import path from 'path'; import { fileURLToPath } from 'url'; import { dirname } from 'path'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); const installMode = fs.existsSync(path.join(__dirname, '..', '.git')) ? 'git' : 'npm'; // 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); import express from 'express'; import { WebSocketServer, WebSocket } from 'ws'; import os from 'os'; import http from 'http'; 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 { 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'; import { spawnGemini, abortGeminiSession, isGeminiSessionActive, getActiveGeminiSessions } from './gemini-cli.js'; 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'; 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 { startEnabledPluginServers, stopAllPlugins, getPluginPort } from './utils/plugin-process-manager.js'; import { initializeDatabase, sessionNamesDb, applyCustomSessionNames } from './database/db.js'; import { configureWebPush } from './services/vapid-keys.js'; import { validateApiKey, authenticateToken, authenticateWebSocket } from './middleware/auth.js'; import { IS_PLATFORM } from './constants/config.js'; import { getConnectableHost } from '../shared/networkHosts.js'; const VALID_PROVIDERS = ['claude', 'codex', 'cursor', 'gemini']; // File system watchers for provider project/session folders const PROVIDER_WATCH_PATHS = [ { provider: 'claude', rootPath: path.join(os.homedir(), '.claude', 'projects') }, { provider: 'cursor', rootPath: path.join(os.homedir(), '.cursor', 'chats') }, { provider: 'codex', rootPath: path.join(os.homedir(), '.codex', 'sessions') }, { provider: 'gemini', rootPath: path.join(os.homedir(), '.gemini', 'projects') }, { provider: 'gemini_sessions', rootPath: path.join(os.homedir(), '.gemini', 'sessions') } ]; const WATCHER_IGNORED_PATTERNS = [ '**/node_modules/**', '**/.git/**', '**/dist/**', '**/build/**', '**/*.tmp', '**/*.swp', '**/.DS_Store' ]; const WATCHER_DEBOUNCE_MS = 300; let projectsWatchers = []; let projectsWatcherDebounceTimer = null; const connectedClients = new Set(); let isGetProjectsRunning = false; // Flag to prevent reentrant calls // Broadcast progress to all connected WebSocket clients function broadcastProgress(progress) { const message = JSON.stringify({ type: 'loading_progress', ...progress }); connectedClients.forEach(client => { if (client.readyState === WebSocket.OPEN) { client.send(message); } }); } // Setup file system watchers for Claude, Cursor, and Codex project/session folders async function setupProjectsWatcher() { const chokidar = (await import('chokidar')).default; if (projectsWatcherDebounceTimer) { clearTimeout(projectsWatcherDebounceTimer); projectsWatcherDebounceTimer = null; } await Promise.all( projectsWatchers.map(async (watcher) => { try { await watcher.close(); } catch (error) { console.error('[WARN] Failed to close watcher:', error); } }) ); projectsWatchers = []; const debouncedUpdate = (eventType, filePath, provider, rootPath) => { if (projectsWatcherDebounceTimer) { clearTimeout(projectsWatcherDebounceTimer); } projectsWatcherDebounceTimer = setTimeout(async () => { // Prevent reentrant calls if (isGetProjectsRunning) { return; } try { isGetProjectsRunning = true; // Clear project directory cache when files change clearProjectDirectoryCache(); // Get updated projects list const updatedProjects = await getProjects(broadcastProgress); // Notify all connected clients about the project changes const updateMessage = JSON.stringify({ type: 'projects_updated', projects: updatedProjects, timestamp: new Date().toISOString(), changeType: eventType, changedFile: path.relative(rootPath, filePath), watchProvider: provider }); connectedClients.forEach(client => { if (client.readyState === WebSocket.OPEN) { client.send(updateMessage); } }); } catch (error) { console.error('[ERROR] Error handling project changes:', error); } finally { isGetProjectsRunning = false; } }, WATCHER_DEBOUNCE_MS); }; for (const { provider, rootPath } of PROVIDER_WATCH_PATHS) { try { // chokidar v4 emits ENOENT via the "error" event for missing roots and will not auto-recover. // Ensure provider folders exist before creating the watcher so watching stays active. await fsPromises.mkdir(rootPath, { recursive: true }); // Initialize chokidar watcher with optimized settings const watcher = chokidar.watch(rootPath, { ignored: WATCHER_IGNORED_PATTERNS, persistent: true, ignoreInitial: true, // Don't fire events for existing files on startup followSymlinks: false, depth: 10, // Reasonable depth limit awaitWriteFinish: { stabilityThreshold: 100, // Wait 100ms for file to stabilize pollInterval: 50 } }); // Set up event listeners watcher .on('add', (filePath) => debouncedUpdate('add', filePath, provider, rootPath)) .on('change', (filePath) => debouncedUpdate('change', filePath, provider, rootPath)) .on('unlink', (filePath) => debouncedUpdate('unlink', filePath, provider, rootPath)) .on('addDir', (dirPath) => debouncedUpdate('addDir', dirPath, provider, rootPath)) .on('unlinkDir', (dirPath) => debouncedUpdate('unlinkDir', dirPath, provider, rootPath)) .on('error', (error) => { console.error(`[ERROR] ${provider} watcher error:`, error); }) .on('ready', () => { }); projectsWatchers.push(watcher); } catch (error) { console.error(`[ERROR] Failed to setup ${provider} watcher for ${rootPath}:`, error); } } if (projectsWatchers.length === 0) { console.error('[ERROR] Failed to setup any provider watchers'); } } const app = express(); const server = http.createServer(app); const ptySessionsMap = new Map(); const PTY_SESSION_TIMEOUT = 30 * 60 * 1000; const SHELL_URL_PARSE_BUFFER_LIMIT = 32768; 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({ server, verifyClient: (info) => { console.log('WebSocket connection attempt to:', info.req.url); // Platform mode: always allow connection if (IS_PLATFORM) { const user = authenticateWebSocket(null); // Will return first user if (!user) { console.log('[WARN] Platform mode: No user found in database'); return false; } info.req.user = user; console.log('[OK] Platform mode WebSocket authenticated for user:', user.username); return true; } // Normal mode: verify token // Extract token from query parameters or headers const url = new URL(info.req.url, 'http://localhost'); const token = url.searchParams.get('token') || info.req.headers.authorization?.split(' ')[1]; // Verify token const user = authenticateWebSocket(token); if (!user) { console.log('[WARN] WebSocket authentication failed'); return false; } // Store user info in the request for later use info.req.user = user; console.log('[OK] WebSocket authenticated for user:', user.username); return true; } }); // Make WebSocket server available to routes app.locals.wss = wss; app.use(cors({ exposedHeaders: ['X-Refreshed-Token'] })); app.use(express.json({ limit: '50mb', type: (req) => { // Skip multipart/form-data requests (for file uploads like images) const contentType = req.headers['content-type'] || ''; if (contentType.includes('multipart/form-data')) { return false; } return contentType.includes('json'); } })); app.use(express.urlencoded({ limit: '50mb', extended: true })); // Public health check endpoint (no authentication required) app.get('/health', (req, res) => { res.json({ status: 'ok', timestamp: new Date().toISOString(), installMode }); }); // Optional API key validation (if configured) app.use('/api', validateApiKey); // Authentication routes (public) app.use('/api/auth', authRoutes); // Projects API Routes (protected) 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); // TaskMaster API Routes (protected) app.use('/api/taskmaster', authenticateToken, taskmasterRoutes); // MCP utilities app.use('/api/mcp-utils', authenticateToken, mcpUtilsRoutes); // Commands API Routes (protected) 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); // Codex API Routes (protected) app.use('/api/codex', authenticateToken, codexRoutes); // Gemini API Routes (protected) app.use('/api/gemini', authenticateToken, geminiRoutes); // Plugins API Routes (protected) app.use('/api/plugins', authenticateToken, pluginsRoutes); // Unified session messages route (protected) app.use('/api/sessions', authenticateToken, messagesRoutes); // Agent API Routes (uses API key authentication) app.use('/api/agent', agentRoutes); // Serve public files (like api-docs.html) app.use(express.static(path.join(__dirname, '../public'))); // Static files served after API routes // Add cache control: HTML files should not be cached, but assets can be cached app.use(express.static(path.join(__dirname, '../dist'), { setHeaders: (res, filePath) => { if (filePath.endsWith('.html')) { // Prevent HTML caching to avoid service worker issues after builds res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate'); res.setHeader('Pragma', 'no-cache'); res.setHeader('Expires', '0'); } else if (filePath.match(/\.(js|css|woff2?|ttf|eot|svg|png|jpg|jpeg|gif|ico)$/)) { // Cache static assets for 1 year (they have hashed names) res.setHeader('Cache-Control', 'public, max-age=31536000, immutable'); } } })); // API Routes (protected) // /api/config endpoint removed - no longer needed // Frontend now uses window.location for WebSocket URLs // System update endpoint app.post('/api/system/update', authenticateToken, async (req, res) => { try { // Get the project root directory (parent of server directory) const projectRoot = path.join(__dirname, '..'); console.log('Starting system update from directory:', projectRoot); // Run the update command based on install mode const updateCommand = installMode === 'git' ? 'git checkout main && git pull && npm install' : 'npm install -g @siteboon/claude-code-ui@latest'; const child = spawn('sh', ['-c', updateCommand], { cwd: installMode === 'git' ? projectRoot : os.homedir(), env: process.env }); let output = ''; let errorOutput = ''; child.stdout.on('data', (data) => { const text = data.toString(); output += text; console.log('Update output:', text); }); child.stderr.on('data', (data) => { const text = data.toString(); errorOutput += text; console.error('Update error:', text); }); child.on('close', (code) => { if (code === 0) { res.json({ success: true, output: output || 'Update completed successfully', message: 'Update completed. Please restart the server to apply changes.' }); } else { res.status(500).json({ success: false, error: 'Update command failed', output: output, errorOutput: errorOutput }); } }); child.on('error', (error) => { console.error('Update process error:', error); res.status(500).json({ success: false, error: error.message }); }); } catch (error) { console.error('System update error:', error); res.status(500).json({ success: false, error: error.message }); } }); app.get('/api/projects', authenticateToken, async (req, res) => { try { const projects = await getProjects(broadcastProgress); res.json(projects); } catch (error) { res.status(500).json({ error: error.message }); } }); app.get('/api/projects/:projectName/sessions', authenticateToken, async (req, res) => { try { const { limit = 5, offset = 0 } = req.query; const result = await getSessions(req.params.projectName, parseInt(limit), parseInt(offset)); applyCustomSessionNames(result.sessions, 'claude'); res.json(result); } catch (error) { res.status(500).json({ error: error.message }); } }); // Rename project endpoint app.put('/api/projects/:projectName/rename', authenticateToken, async (req, res) => { try { const { displayName } = req.body; await renameProject(req.params.projectName, displayName); res.json({ success: true }); } catch (error) { res.status(500).json({ error: error.message }); } }); // Delete session endpoint app.delete('/api/projects/:projectName/sessions/:sessionId', authenticateToken, async (req, res) => { try { const { projectName, sessionId } = req.params; console.log(`[API] Deleting session: ${sessionId} from project: ${projectName}`); await deleteSession(projectName, sessionId); sessionNamesDb.deleteName(sessionId, 'claude'); console.log(`[API] Session ${sessionId} deleted successfully`); res.json({ success: true }); } catch (error) { console.error(`[API] Error deleting session ${req.params.sessionId}:`, error); res.status(500).json({ error: error.message }); } }); // Rename session endpoint app.put('/api/sessions/:sessionId/rename', authenticateToken, async (req, res) => { try { const { sessionId } = req.params; const safeSessionId = String(sessionId).replace(/[^a-zA-Z0-9._-]/g, ''); if (!safeSessionId || safeSessionId !== String(sessionId)) { return res.status(400).json({ error: 'Invalid sessionId' }); } const { summary, provider } = req.body; if (!summary || typeof summary !== 'string' || summary.trim() === '') { return res.status(400).json({ error: 'Summary is required' }); } if (summary.trim().length > 500) { return res.status(400).json({ error: 'Summary must not exceed 500 characters' }); } if (!provider || !VALID_PROVIDERS.includes(provider)) { return res.status(400).json({ error: `Provider must be one of: ${VALID_PROVIDERS.join(', ')}` }); } sessionNamesDb.setName(safeSessionId, provider, summary.trim()); res.json({ success: true }); } catch (error) { console.error(`[API] Error renaming session ${req.params.sessionId}:`, error); res.status(500).json({ error: error.message }); } }); // 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'; 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() : ''; const parsedLimit = Number.parseInt(String(req.query.limit), 10); const limit = Number.isNaN(parsedLimit) ? 50 : Math.max(1, Math.min(parsedLimit, 100)); if (query.length < 2) { return res.status(400).json({ error: 'Query must be at least 2 characters' }); } res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', 'Connection': 'keep-alive', 'X-Accel-Buffering': 'no', }); let closed = false; const abortController = new AbortController(); req.on('close', () => { closed = true; abortController.abort(); }); try { await searchConversations(query, limit, ({ projectResult, totalMatches, scannedProjects, totalProjects }) => { if (closed) return; if (projectResult) { res.write(`event: result\ndata: ${JSON.stringify({ projectResult, totalMatches, scannedProjects, totalProjects })}\n\n`); } else { res.write(`event: progress\ndata: ${JSON.stringify({ totalMatches, scannedProjects, totalProjects })}\n\n`); } }, abortController.signal); if (!closed) { res.write(`event: done\ndata: {}\n\n`); } } catch (error) { console.error('Error searching conversations:', error); if (!closed) { res.write(`event: error\ndata: ${JSON.stringify({ error: 'Search failed' })}\n\n`); } } finally { if (!closed) { res.end(); } } }); const expandWorkspacePath = (inputPath) => { if (!inputPath) return inputPath; if (inputPath === '~') { return WORKSPACES_ROOT; } if (inputPath.startsWith('~/') || inputPath.startsWith('~\\')) { return path.join(WORKSPACES_ROOT, inputPath.slice(2)); } return inputPath; }; // Browse filesystem endpoint for project suggestions - uses existing getFileTree app.get('/api/browse-filesystem', authenticateToken, async (req, res) => { try { const { path: dirPath } = req.query; console.log('[API] Browse filesystem request for path:', dirPath); console.log('[API] WORKSPACES_ROOT is:', WORKSPACES_ROOT); // Default to home directory if no path provided const defaultRoot = WORKSPACES_ROOT; let targetPath = dirPath ? expandWorkspacePath(dirPath) : defaultRoot; // Resolve and normalize the path targetPath = path.resolve(targetPath); // Security check - ensure path is within allowed workspace root const validation = await validateWorkspacePath(targetPath); if (!validation.valid) { return res.status(403).json({ error: validation.error }); } const resolvedPath = validation.resolvedPath || targetPath; // Security check - ensure path is accessible try { await fs.promises.access(resolvedPath); const stats = await fs.promises.stat(resolvedPath); if (!stats.isDirectory()) { return res.status(400).json({ error: 'Path is not a directory' }); } } catch (err) { return res.status(404).json({ error: 'Directory not accessible' }); } // Use existing getFileTree function with shallow depth (only direct children) const fileTree = await getFileTree(resolvedPath, 1, 0, false); // maxDepth=1, showHidden=false // Filter only directories and format for suggestions const directories = fileTree .filter(item => item.type === 'directory') .map(item => ({ path: item.path, name: item.name, type: 'directory' })) .sort((a, b) => { const aHidden = a.name.startsWith('.'); const bHidden = b.name.startsWith('.'); if (aHidden && !bHidden) return 1; if (!aHidden && bHidden) return -1; return a.name.localeCompare(b.name); }); // Add common directories if browsing home directory const suggestions = []; let resolvedWorkspaceRoot = defaultRoot; try { resolvedWorkspaceRoot = await fsPromises.realpath(defaultRoot); } catch (error) { // Use default root as-is if realpath fails } if (resolvedPath === resolvedWorkspaceRoot) { const commonDirs = ['Desktop', 'Documents', 'Projects', 'Development', 'Dev', 'Code', 'workspace']; const existingCommon = directories.filter(dir => commonDirs.includes(dir.name)); const otherDirs = directories.filter(dir => !commonDirs.includes(dir.name)); suggestions.push(...existingCommon, ...otherDirs); } else { suggestions.push(...directories); } res.json({ path: resolvedPath, suggestions: suggestions }); } catch (error) { console.error('Error browsing filesystem:', error); res.status(500).json({ error: 'Failed to browse filesystem' }); } }); app.post('/api/create-folder', authenticateToken, async (req, res) => { try { const { path: folderPath } = req.body; if (!folderPath) { return res.status(400).json({ error: 'Path is required' }); } const expandedPath = expandWorkspacePath(folderPath); const resolvedInput = path.resolve(expandedPath); const validation = await validateWorkspacePath(resolvedInput); if (!validation.valid) { return res.status(403).json({ error: validation.error }); } const targetPath = validation.resolvedPath || resolvedInput; const parentDir = path.dirname(targetPath); try { await fs.promises.access(parentDir); } catch (err) { return res.status(404).json({ error: 'Parent directory does not exist' }); } try { await fs.promises.access(targetPath); return res.status(409).json({ error: 'Folder already exists' }); } catch (err) { // Folder doesn't exist, which is what we want } try { await fs.promises.mkdir(targetPath, { recursive: false }); res.json({ success: true, path: targetPath }); } catch (mkdirError) { if (mkdirError.code === 'EEXIST') { return res.status(409).json({ error: 'Folder already exists' }); } throw mkdirError; } } catch (error) { console.error('Error creating folder:', error); res.status(500).json({ error: 'Failed to create folder' }); } }); // Read file content endpoint app.get('/api/projects/:projectName/file', authenticateToken, async (req, res) => { try { const { projectName } = req.params; const { filePath } = req.query; // Security: ensure the requested path is inside the project root if (!filePath) { return res.status(400).json({ error: 'Invalid file path' }); } const projectRoot = await extractProjectDirectory(projectName).catch(() => null); if (!projectRoot) { return res.status(404).json({ error: 'Project not found' }); } // Handle both absolute and relative paths const resolved = path.isAbsolute(filePath) ? path.resolve(filePath) : path.resolve(projectRoot, filePath); const normalizedRoot = path.resolve(projectRoot) + path.sep; if (!resolved.startsWith(normalizedRoot)) { return res.status(403).json({ error: 'Path must be under project root' }); } const content = await fsPromises.readFile(resolved, 'utf8'); res.json({ content, path: resolved }); } catch (error) { console.error('Error reading file:', error); if (error.code === 'ENOENT') { res.status(404).json({ error: 'File not found' }); } else if (error.code === 'EACCES') { res.status(403).json({ error: 'Permission denied' }); } else { res.status(500).json({ error: error.message }); } } }); // Serve binary file content endpoint (for images, etc.) app.get('/api/projects/:projectName/files/content', authenticateToken, async (req, res) => { try { const { projectName } = req.params; const { path: filePath } = req.query; // Security: ensure the requested path is inside the project root if (!filePath) { return res.status(400).json({ error: 'Invalid file path' }); } const projectRoot = await extractProjectDirectory(projectName).catch(() => null); if (!projectRoot) { return res.status(404).json({ error: 'Project not found' }); } const resolved = path.resolve(filePath); const normalizedRoot = path.resolve(projectRoot) + path.sep; if (!resolved.startsWith(normalizedRoot)) { return res.status(403).json({ error: 'Path must be under project root' }); } // Check if file exists try { await fsPromises.access(resolved); } catch (error) { return res.status(404).json({ error: 'File not found' }); } // Get file extension and set appropriate content type const mimeType = mime.lookup(resolved) || 'application/octet-stream'; res.setHeader('Content-Type', mimeType); // Stream the file const fileStream = fs.createReadStream(resolved); fileStream.pipe(res); fileStream.on('error', (error) => { console.error('Error streaming file:', error); if (!res.headersSent) { res.status(500).json({ error: 'Error reading file' }); } }); } catch (error) { console.error('Error serving binary file:', error); if (!res.headersSent) { res.status(500).json({ error: error.message }); } } }); // Save file content endpoint app.put('/api/projects/:projectName/file', authenticateToken, async (req, res) => { try { const { projectName } = req.params; const { filePath, content } = req.body; // Security: ensure the requested path is inside the project root if (!filePath) { return res.status(400).json({ error: 'Invalid file path' }); } if (content === undefined) { return res.status(400).json({ error: 'Content is required' }); } const projectRoot = await extractProjectDirectory(projectName).catch(() => null); if (!projectRoot) { return res.status(404).json({ error: 'Project not found' }); } // Handle both absolute and relative paths const resolved = path.isAbsolute(filePath) ? path.resolve(filePath) : path.resolve(projectRoot, filePath); const normalizedRoot = path.resolve(projectRoot) + path.sep; if (!resolved.startsWith(normalizedRoot)) { return res.status(403).json({ error: 'Path must be under project root' }); } // Write the new content await fsPromises.writeFile(resolved, content, 'utf8'); res.json({ success: true, path: resolved, message: 'File saved successfully' }); } catch (error) { console.error('Error saving file:', error); if (error.code === 'ENOENT') { res.status(404).json({ error: 'File or directory not found' }); } else if (error.code === 'EACCES') { res.status(403).json({ error: 'Permission denied' }); } else { res.status(500).json({ error: error.message }); } } }); app.get('/api/projects/:projectName/files', authenticateToken, async (req, res) => { try { // Using fsPromises from import // Use extractProjectDirectory to get the actual project path let actualPath; try { actualPath = await extractProjectDirectory(req.params.projectName); } catch (error) { console.error('Error extracting project directory:', error); // Fallback to simple dash replacement actualPath = req.params.projectName.replace(/-/g, '/'); } // Check if path exists try { await fsPromises.access(actualPath); } catch (e) { return res.status(404).json({ error: `Project path not found: ${actualPath}` }); } const files = await getFileTree(actualPath, 10, 0, true); res.json(files); } catch (error) { console.error('[ERROR] File tree error:', error.message); res.status(500).json({ error: error.message }); } }); // ============================================================================ // FILE OPERATIONS API ENDPOINTS // ============================================================================ /** * Validate that a path is within the project root * @param {string} projectRoot - The project root path * @param {string} targetPath - The path to validate * @returns {{ valid: boolean, resolved?: string, error?: string }} */ function validatePathInProject(projectRoot, targetPath) { const resolved = path.isAbsolute(targetPath) ? path.resolve(targetPath) : path.resolve(projectRoot, targetPath); const normalizedRoot = path.resolve(projectRoot) + path.sep; if (!resolved.startsWith(normalizedRoot)) { return { valid: false, error: 'Path must be under project root' }; } return { valid: true, resolved }; } /** * Validate filename - check for invalid characters * @param {string} name - The filename to validate * @returns {{ valid: boolean, error?: string }} */ function validateFilename(name) { if (!name || !name.trim()) { return { valid: false, error: 'Filename cannot be empty' }; } // Check for invalid characters (Windows + Unix) const invalidChars = /[<>:"/\\|?*\x00-\x1f]/; if (invalidChars.test(name)) { return { valid: false, error: 'Filename contains invalid characters' }; } // Check for reserved names (Windows) const reserved = /^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])$/i; if (reserved.test(name)) { return { valid: false, error: 'Filename is a reserved name' }; } // Check for dots only if (/^\.+$/.test(name)) { return { valid: false, error: 'Filename cannot be only dots' }; } return { valid: true }; } // POST /api/projects/:projectName/files/create - Create new file or directory app.post('/api/projects/:projectName/files/create', authenticateToken, async (req, res) => { try { const { projectName } = req.params; const { path: parentPath, type, name } = req.body; // Validate input if (!name || !type) { return res.status(400).json({ error: 'Name and type are required' }); } if (!['file', 'directory'].includes(type)) { return res.status(400).json({ error: 'Type must be "file" or "directory"' }); } const nameValidation = validateFilename(name); if (!nameValidation.valid) { return res.status(400).json({ error: nameValidation.error }); } // Get project root const projectRoot = await extractProjectDirectory(projectName).catch(() => null); if (!projectRoot) { return res.status(404).json({ error: 'Project not found' }); } // Build and validate target path const targetDir = parentPath || ''; const targetPath = targetDir ? path.join(targetDir, name) : name; const validation = validatePathInProject(projectRoot, targetPath); if (!validation.valid) { return res.status(403).json({ error: validation.error }); } const resolvedPath = validation.resolved; // Check if already exists try { await fsPromises.access(resolvedPath); return res.status(409).json({ error: `${type === 'file' ? 'File' : 'Directory'} already exists` }); } catch { // Doesn't exist, which is what we want } // Create file or directory if (type === 'directory') { await fsPromises.mkdir(resolvedPath, { recursive: false }); } else { // Ensure parent directory exists const parentDir = path.dirname(resolvedPath); try { await fsPromises.access(parentDir); } catch { await fsPromises.mkdir(parentDir, { recursive: true }); } await fsPromises.writeFile(resolvedPath, '', 'utf8'); } res.json({ success: true, path: resolvedPath, name, type, message: `${type === 'file' ? 'File' : 'Directory'} created successfully` }); } catch (error) { console.error('Error creating file/directory:', error); if (error.code === 'EACCES') { res.status(403).json({ error: 'Permission denied' }); } else if (error.code === 'ENOENT') { res.status(404).json({ error: 'Parent directory not found' }); } else { res.status(500).json({ error: error.message }); } } }); // PUT /api/projects/:projectName/files/rename - Rename file or directory app.put('/api/projects/:projectName/files/rename', authenticateToken, async (req, res) => { try { const { projectName } = req.params; const { oldPath, newName } = req.body; // Validate input if (!oldPath || !newName) { return res.status(400).json({ error: 'oldPath and newName are required' }); } const nameValidation = validateFilename(newName); if (!nameValidation.valid) { return res.status(400).json({ error: nameValidation.error }); } // Get project root const projectRoot = await extractProjectDirectory(projectName).catch(() => null); if (!projectRoot) { return res.status(404).json({ error: 'Project not found' }); } // Validate old path const oldValidation = validatePathInProject(projectRoot, oldPath); if (!oldValidation.valid) { return res.status(403).json({ error: oldValidation.error }); } const resolvedOldPath = oldValidation.resolved; // Check if old path exists try { await fsPromises.access(resolvedOldPath); } catch { return res.status(404).json({ error: 'File or directory not found' }); } // Build and validate new path const parentDir = path.dirname(resolvedOldPath); const resolvedNewPath = path.join(parentDir, newName); const newValidation = validatePathInProject(projectRoot, resolvedNewPath); if (!newValidation.valid) { return res.status(403).json({ error: newValidation.error }); } // Check if new path already exists try { await fsPromises.access(resolvedNewPath); return res.status(409).json({ error: 'A file or directory with this name already exists' }); } catch { // Doesn't exist, which is what we want } // Rename await fsPromises.rename(resolvedOldPath, resolvedNewPath); res.json({ success: true, oldPath: resolvedOldPath, newPath: resolvedNewPath, newName, message: 'Renamed successfully' }); } catch (error) { console.error('Error renaming file/directory:', error); if (error.code === 'EACCES') { res.status(403).json({ error: 'Permission denied' }); } else if (error.code === 'ENOENT') { res.status(404).json({ error: 'File or directory not found' }); } else if (error.code === 'EXDEV') { res.status(400).json({ error: 'Cannot move across different filesystems' }); } else { res.status(500).json({ error: error.message }); } } }); // DELETE /api/projects/:projectName/files - Delete file or directory app.delete('/api/projects/:projectName/files', authenticateToken, async (req, res) => { try { const { projectName } = req.params; const { path: targetPath, type } = req.body; // Validate input if (!targetPath) { return res.status(400).json({ error: 'Path is required' }); } // Get project root const projectRoot = await extractProjectDirectory(projectName).catch(() => null); if (!projectRoot) { return res.status(404).json({ error: 'Project not found' }); } // Validate path const validation = validatePathInProject(projectRoot, targetPath); if (!validation.valid) { return res.status(403).json({ error: validation.error }); } const resolvedPath = validation.resolved; // Check if path exists and get stats let stats; try { stats = await fsPromises.stat(resolvedPath); } catch { return res.status(404).json({ error: 'File or directory not found' }); } // Prevent deleting the project root itself if (resolvedPath === path.resolve(projectRoot)) { return res.status(403).json({ error: 'Cannot delete project root directory' }); } // Delete based on type if (stats.isDirectory()) { await fsPromises.rm(resolvedPath, { recursive: true, force: true }); } else { await fsPromises.unlink(resolvedPath); } res.json({ success: true, path: resolvedPath, type: stats.isDirectory() ? 'directory' : 'file', message: 'Deleted successfully' }); } catch (error) { console.error('Error deleting file/directory:', error); if (error.code === 'EACCES') { res.status(403).json({ error: 'Permission denied' }); } else if (error.code === 'ENOENT') { res.status(404).json({ error: 'File or directory not found' }); } else if (error.code === 'ENOTEMPTY') { res.status(400).json({ error: 'Directory is not empty' }); } else { res.status(500).json({ error: error.message }); } } }); // POST /api/projects/:projectName/files/upload - Upload files // Dynamic import of multer for file uploads const uploadFilesHandler = async (req, res) => { // Dynamic import of multer const multer = (await import('multer')).default; const uploadMiddleware = multer({ storage: multer.diskStorage({ destination: (req, file, cb) => { cb(null, os.tmpdir()); }, filename: (req, file, cb) => { // Use a unique temp name, but preserve original name in file.originalname // Note: file.originalname may contain path separators for folder uploads const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9); // For temp file, just use a safe unique name without the path cb(null, `upload-${uniqueSuffix}`); } }), limits: { fileSize: 50 * 1024 * 1024, // 50MB limit files: 20 // Max 20 files at once } }); // Use multer middleware uploadMiddleware.array('files', 20)(req, res, async (err) => { if (err) { console.error('Multer error:', err); if (err.code === 'LIMIT_FILE_SIZE') { return res.status(400).json({ error: 'File too large. Maximum size is 50MB.' }); } if (err.code === 'LIMIT_FILE_COUNT') { return res.status(400).json({ error: 'Too many files. Maximum is 20 files.' }); } return res.status(500).json({ error: err.message }); } try { const { projectName } = req.params; const { targetPath, relativePaths } = req.body; // Parse relative paths if provided (for folder uploads) let filePaths = []; if (relativePaths) { try { filePaths = JSON.parse(relativePaths); } catch (e) { console.log('[DEBUG] Failed to parse relativePaths:', relativePaths); } } console.log('[DEBUG] File upload request:', { projectName, targetPath: JSON.stringify(targetPath), targetPathType: typeof targetPath, filesCount: req.files?.length, relativePaths: filePaths }); if (!req.files || req.files.length === 0) { return res.status(400).json({ error: 'No files provided' }); } // Get project root const projectRoot = await extractProjectDirectory(projectName).catch(() => null); if (!projectRoot) { return res.status(404).json({ error: 'Project not found' }); } console.log('[DEBUG] Project root:', projectRoot); // Validate and resolve target path // If targetPath is empty or '.', use project root directly const targetDir = targetPath || ''; let resolvedTargetDir; console.log('[DEBUG] Target dir:', JSON.stringify(targetDir)); if (!targetDir || targetDir === '.' || targetDir === './') { // Empty path means upload to project root resolvedTargetDir = path.resolve(projectRoot); console.log('[DEBUG] Using project root as target:', resolvedTargetDir); } else { const validation = validatePathInProject(projectRoot, targetDir); if (!validation.valid) { console.log('[DEBUG] Path validation failed:', validation.error); return res.status(403).json({ error: validation.error }); } resolvedTargetDir = validation.resolved; console.log('[DEBUG] Resolved target dir:', resolvedTargetDir); } // Ensure target directory exists try { await fsPromises.access(resolvedTargetDir); } catch { await fsPromises.mkdir(resolvedTargetDir, { recursive: true }); } // Move uploaded files from temp to target directory const uploadedFiles = []; console.log('[DEBUG] Processing files:', req.files.map(f => ({ originalname: f.originalname, path: f.path }))); for (let i = 0; i < req.files.length; i++) { const file = req.files[i]; // Use relative path if provided (for folder uploads), otherwise use originalname const fileName = (filePaths && filePaths[i]) ? filePaths[i] : file.originalname; console.log('[DEBUG] Processing file:', fileName, '(originalname:', file.originalname + ')'); const destPath = path.join(resolvedTargetDir, fileName); // Validate destination path const destValidation = validatePathInProject(projectRoot, destPath); if (!destValidation.valid) { console.log('[DEBUG] Destination validation failed for:', destPath); // Clean up temp file await fsPromises.unlink(file.path).catch(() => {}); continue; } // Ensure parent directory exists (for nested files from folder upload) const parentDir = path.dirname(destPath); try { await fsPromises.access(parentDir); } catch { await fsPromises.mkdir(parentDir, { recursive: true }); } // Move file (copy + unlink to handle cross-device scenarios) await fsPromises.copyFile(file.path, destPath); await fsPromises.unlink(file.path); uploadedFiles.push({ name: fileName, path: destPath, size: file.size, mimeType: file.mimetype }); } res.json({ success: true, files: uploadedFiles, targetPath: resolvedTargetDir, message: `Uploaded ${uploadedFiles.length} file(s) successfully` }); } catch (error) { console.error('Error uploading files:', error); // Clean up any remaining temp files if (req.files) { for (const file of req.files) { await fsPromises.unlink(file.path).catch(() => {}); } } if (error.code === 'EACCES') { res.status(403).json({ error: 'Permission denied' }); } else { res.status(500).json({ error: error.message }); } } }); }; app.post('/api/projects/:projectName/files/upload', authenticateToken, uploadFilesHandler); /** * Proxy an authenticated client WebSocket to a plugin's internal WS server. * Auth is enforced by verifyClient before this function is reached. */ function handlePluginWsProxy(clientWs, pathname) { const pluginName = pathname.replace('/plugin-ws/', ''); if (!pluginName || /[^a-zA-Z0-9_-]/.test(pluginName)) { clientWs.close(4400, 'Invalid plugin name'); return; } const port = getPluginPort(pluginName); if (!port) { clientWs.close(4404, 'Plugin not running'); return; } const upstream = new WebSocket(`ws://127.0.0.1:${port}/ws`); upstream.on('open', () => { console.log(`[Plugins] WS proxy connected to "${pluginName}" on port ${port}`); }); // Relay messages bidirectionally upstream.on('message', (data) => { if (clientWs.readyState === WebSocket.OPEN) clientWs.send(data); }); clientWs.on('message', (data) => { if (upstream.readyState === WebSocket.OPEN) upstream.send(data); }); // Propagate close in both directions upstream.on('close', () => { if (clientWs.readyState === WebSocket.OPEN) clientWs.close(); }); clientWs.on('close', () => { if (upstream.readyState === WebSocket.OPEN) upstream.close(); }); upstream.on('error', (err) => { console.error(`[Plugins] WS proxy error for "${pluginName}":`, err.message); if (clientWs.readyState === WebSocket.OPEN) clientWs.close(4502, 'Upstream error'); }); clientWs.on('error', () => { if (upstream.readyState === WebSocket.OPEN) upstream.close(); }); } // WebSocket connection handler that routes based on URL path wss.on('connection', (ws, request) => { const url = request.url; console.log('[INFO] Client connected to:', url); // Parse URL to get pathname without query parameters const urlObj = new URL(url, 'http://localhost'); const pathname = urlObj.pathname; if (pathname === '/shell') { handleShellConnection(ws); } else if (pathname === '/ws') { handleChatConnection(ws, request); } else if (pathname.startsWith('/plugin-ws/')) { handlePluginWsProxy(ws, pathname); } else { console.log('[WARN] Unknown WebSocket path:', pathname); ws.close(); } }); /** * WebSocket Writer - Wrapper for WebSocket to match SSEStreamWriter interface * * Provider files use `createNormalizedMessage()` from `providers/types.js` and * adapter `normalizeMessage()` to produce unified NormalizedMessage events. * The writer simply serialises and sends. */ class WebSocketWriter { constructor(ws, userId = null) { this.ws = ws; this.sessionId = null; this.userId = userId; this.isWebSocketWriter = true; // Marker for transport detection } send(data) { if (this.ws.readyState === 1) { // WebSocket.OPEN this.ws.send(JSON.stringify(data)); } } updateWebSocket(newRawWs) { this.ws = newRawWs; } setSessionId(sessionId) { this.sessionId = sessionId; } getSessionId() { return this.sessionId; } } // Handle chat WebSocket connections function handleChatConnection(ws, request) { console.log('[INFO] Chat WebSocket connected'); // Add to connected clients for project updates connectedClients.add(ws); // Wrap WebSocket with writer for consistent interface with SSEStreamWriter const writer = new WebSocketWriter(ws, request?.user?.id ?? request?.user?.userId ?? null); ws.on('message', async (message) => { try { const data = JSON.parse(message); if (data.type === 'claude-command') { console.log('[DEBUG] User message:', data.command || '[Continue/Resume]'); console.log('📁 Project:', data.options?.projectPath || 'Unknown'); console.log('🔄 Session:', data.options?.sessionId ? 'Resume' : 'New'); // Use Claude Agents SDK await queryClaudeSDK(data.command, data.options, writer); } else if (data.type === 'cursor-command') { console.log('[DEBUG] Cursor message:', data.command || '[Continue/Resume]'); console.log('📁 Project:', data.options?.cwd || 'Unknown'); console.log('🔄 Session:', data.options?.sessionId ? 'Resume' : 'New'); console.log('🤖 Model:', data.options?.model || 'default'); await spawnCursor(data.command, data.options, writer); } else if (data.type === 'codex-command') { console.log('[DEBUG] Codex message:', data.command || '[Continue/Resume]'); console.log('📁 Project:', data.options?.projectPath || data.options?.cwd || 'Unknown'); console.log('🔄 Session:', data.options?.sessionId ? 'Resume' : 'New'); console.log('🤖 Model:', data.options?.model || 'default'); await queryCodex(data.command, data.options, writer); } else if (data.type === 'gemini-command') { console.log('[DEBUG] Gemini message:', data.command || '[Continue/Resume]'); console.log('📁 Project:', data.options?.projectPath || data.options?.cwd || 'Unknown'); console.log('🔄 Session:', data.options?.sessionId ? 'Resume' : 'New'); console.log('🤖 Model:', data.options?.model || 'default'); await spawnGemini(data.command, data.options, writer); } else if (data.type === 'cursor-resume') { // Backward compatibility: treat as cursor-command with resume and no prompt console.log('[DEBUG] Cursor resume session (compat):', data.sessionId); await spawnCursor('', { sessionId: data.sessionId, resume: true, cwd: data.options?.cwd }, writer); } else if (data.type === 'abort-session') { console.log('[DEBUG] Abort session request:', data.sessionId); const provider = data.provider || 'claude'; let success; if (provider === 'cursor') { success = abortCursorSession(data.sessionId); } else if (provider === 'codex') { success = abortCodexSession(data.sessionId); } else if (provider === 'gemini') { success = abortGeminiSession(data.sessionId); } else { // Use Claude Agents SDK success = await abortClaudeSDKSession(data.sessionId); } writer.send(createNormalizedMessage({ kind: 'complete', exitCode: success ? 0 : 1, aborted: true, success, sessionId: data.sessionId, provider })); } else if (data.type === 'claude-permission-response') { // Relay UI approval decisions back into the SDK control flow. // This does not persist permissions; it only resolves the in-flight request, // introduced so the SDK can resume once the user clicks Allow/Deny. if (data.requestId) { resolveToolApproval(data.requestId, { allow: Boolean(data.allow), updatedInput: data.updatedInput, message: data.message, rememberEntry: data.rememberEntry }); } } else if (data.type === 'cursor-abort') { console.log('[DEBUG] Abort Cursor session:', data.sessionId); const success = abortCursorSession(data.sessionId); writer.send(createNormalizedMessage({ kind: 'complete', exitCode: success ? 0 : 1, aborted: true, success, sessionId: data.sessionId, provider: 'cursor' })); } else if (data.type === 'check-session-status') { // Check if a specific session is currently processing const provider = data.provider || 'claude'; const sessionId = data.sessionId; let isActive; if (provider === 'cursor') { isActive = isCursorSessionActive(sessionId); } else if (provider === 'codex') { isActive = isCodexSessionActive(sessionId); } else if (provider === 'gemini') { isActive = isGeminiSessionActive(sessionId); } else { // Use Claude Agents SDK isActive = isClaudeSDKSessionActive(sessionId); if (isActive) { // Reconnect the session's writer to the new WebSocket so // subsequent SDK output flows to the refreshed client. reconnectSessionWriter(sessionId, ws); } } writer.send({ type: 'session-status', sessionId, provider, isProcessing: isActive }); } else if (data.type === 'get-pending-permissions') { // Return pending permission requests for a session const sessionId = data.sessionId; if (sessionId && isClaudeSDKSessionActive(sessionId)) { const pending = getPendingApprovalsForSession(sessionId); writer.send({ type: 'pending-permissions-response', sessionId, data: pending }); } } else if (data.type === 'get-active-sessions') { // Get all currently active sessions const activeSessions = { claude: getActiveClaudeSDKSessions(), cursor: getActiveCursorSessions(), codex: getActiveCodexSessions(), gemini: getActiveGeminiSessions() }; writer.send({ type: 'active-sessions', sessions: activeSessions }); } } catch (error) { console.error('[ERROR] Chat WebSocket error:', error.message); writer.send({ type: 'error', error: error.message }); } }); ws.on('close', () => { console.log('🔌 Chat client disconnected'); // Remove from connected clients connectedClients.delete(ws); }); } // Handle shell WebSocket connections function handleShellConnection(ws) { console.log('🐚 Shell client connected'); let shellProcess = null; let ptySessionKey = null; let urlDetectionBuffer = ''; const announcedAuthUrls = new Set(); ws.on('message', async (message) => { try { const data = JSON.parse(message); console.log('📨 Shell message received:', data.type); if (data.type === 'init') { const projectPath = data.projectPath || process.cwd(); const sessionId = data.sessionId; const hasSession = data.hasSession; const provider = data.provider || 'claude'; const initialCommand = data.initialCommand; const isPlainShell = data.isPlainShell || (!!initialCommand && !hasSession) || provider === 'plain-shell'; urlDetectionBuffer = ''; announcedAuthUrls.clear(); // Login commands (Claude/Cursor auth) should never reuse cached sessions const isLoginCommand = initialCommand && ( initialCommand.includes('setup-token') || initialCommand.includes('cursor-agent login') || initialCommand.includes('auth login') ); // Include command hash in session key so different commands get separate sessions const commandSuffix = isPlainShell && initialCommand ? `_cmd_${Buffer.from(initialCommand).toString('base64').slice(0, 16)}` : ''; ptySessionKey = `${projectPath}_${sessionId || 'default'}${commandSuffix}`; // Kill any existing login session before starting fresh if (isLoginCommand) { const oldSession = ptySessionsMap.get(ptySessionKey); if (oldSession) { console.log('🧹 Cleaning up existing login session:', ptySessionKey); if (oldSession.timeoutId) clearTimeout(oldSession.timeoutId); if (oldSession.pty && oldSession.pty.kill) oldSession.pty.kill(); ptySessionsMap.delete(ptySessionKey); } } const existingSession = isLoginCommand ? null : ptySessionsMap.get(ptySessionKey); if (existingSession) { console.log('♻️ Reconnecting to existing PTY session:', ptySessionKey); shellProcess = existingSession.pty; clearTimeout(existingSession.timeoutId); ws.send(JSON.stringify({ type: 'output', data: `\x1b[36m[Reconnected to existing session]\x1b[0m\r\n` })); if (existingSession.buffer && existingSession.buffer.length > 0) { console.log(`📜 Sending ${existingSession.buffer.length} buffered messages`); existingSession.buffer.forEach(bufferedData => { ws.send(JSON.stringify({ type: 'output', data: bufferedData })); }); } existingSession.ws = ws; return; } console.log('[INFO] Starting shell in:', projectPath); console.log('📋 Session info:', hasSession ? `Resume session ${sessionId}` : (isPlainShell ? 'Plain shell mode' : 'New session')); console.log('🤖 Provider:', isPlainShell ? 'plain-shell' : provider); if (initialCommand) { console.log('⚡ Initial command:', initialCommand); } // First send a welcome message let welcomeMsg; if (isPlainShell) { welcomeMsg = `\x1b[36mStarting terminal in: ${projectPath}\x1b[0m\r\n`; } else { const providerName = provider === 'cursor' ? 'Cursor' : (provider === 'codex' ? 'Codex' : (provider === 'gemini' ? 'Gemini' : 'Claude')); welcomeMsg = hasSession ? `\x1b[36mResuming ${providerName} session ${sessionId} in: ${projectPath}\x1b[0m\r\n` : `\x1b[36mStarting new ${providerName} session in: ${projectPath}\x1b[0m\r\n`; } ws.send(JSON.stringify({ type: 'output', data: welcomeMsg })); try { // Validate projectPath — resolve to absolute and verify it exists const resolvedProjectPath = path.resolve(projectPath); try { const stats = fs.statSync(resolvedProjectPath); if (!stats.isDirectory()) { throw new Error('Not a directory'); } } catch (pathErr) { ws.send(JSON.stringify({ type: 'error', message: 'Invalid project path' })); return; } // Validate sessionId — only allow safe characters const safeSessionIdPattern = /^[a-zA-Z0-9_.\-:]+$/; if (sessionId && !safeSessionIdPattern.test(sessionId)) { ws.send(JSON.stringify({ type: 'error', message: 'Invalid session ID' })); return; } // Build shell command — use cwd for project path (never interpolate into shell string) let shellCommand; if (isPlainShell) { // Plain shell mode - run the initial command in the project directory shellCommand = initialCommand; } else if (provider === 'cursor') { if (hasSession && sessionId) { shellCommand = `cursor-agent --resume="${sessionId}"`; } else { shellCommand = 'cursor-agent'; } } else if (provider === 'codex') { // Use codex command; attempt to resume and fall back to a new session when the resume fails. if (hasSession && sessionId) { if (os.platform() === 'win32') { // PowerShell syntax for fallback shellCommand = `codex resume "${sessionId}"; if ($LASTEXITCODE -ne 0) { codex }`; } else { shellCommand = `codex resume "${sessionId}" || codex`; } } else { shellCommand = 'codex'; } } else if (provider === 'gemini') { const command = initialCommand || 'gemini'; let resumeId = sessionId; if (hasSession && sessionId) { try { // Gemini CLI enforces its own native session IDs, unlike other agents that accept arbitrary string names. // The UI only knows about its internal generated `sessionId` (e.g. gemini_1234). // We must fetch the mapping from the backend session manager to pass the native `cliSessionId` to the shell. const sess = sessionManager.getSession(sessionId); if (sess && sess.cliSessionId) { resumeId = sess.cliSessionId; // Validate the looked-up CLI session ID too if (!safeSessionIdPattern.test(resumeId)) { resumeId = null; } } } catch (err) { console.error('Failed to get Gemini CLI session ID:', err); } } if (hasSession && resumeId) { shellCommand = `${command} --resume "${resumeId}"`; } else { shellCommand = command; } } else { // Claude (default provider) const command = initialCommand || 'claude'; if (hasSession && sessionId) { if (os.platform() === 'win32') { shellCommand = `claude --resume "${sessionId}"; if ($LASTEXITCODE -ne 0) { claude }`; } else { shellCommand = `claude --resume "${sessionId}" || claude`; } } else { shellCommand = command; } } console.log('🔧 Executing shell command:', shellCommand); // Use appropriate shell based on platform const shell = os.platform() === 'win32' ? 'powershell.exe' : 'bash'; const shellArgs = os.platform() === 'win32' ? ['-Command', shellCommand] : ['-c', shellCommand]; // Use terminal dimensions from client if provided, otherwise use defaults const termCols = data.cols || 80; const termRows = data.rows || 24; console.log('📐 Using terminal dimensions:', termCols, 'x', termRows); shellProcess = pty.spawn(shell, shellArgs, { name: 'xterm-256color', cols: termCols, rows: termRows, cwd: resolvedProjectPath, env: { ...process.env, TERM: 'xterm-256color', COLORTERM: 'truecolor', FORCE_COLOR: '3' } }); console.log('🟢 Shell process started with PTY, PID:', shellProcess.pid); ptySessionsMap.set(ptySessionKey, { pty: shellProcess, ws: ws, buffer: [], timeoutId: null, projectPath, sessionId }); // Handle data output shellProcess.onData((data) => { const session = ptySessionsMap.get(ptySessionKey); if (!session) return; if (session.buffer.length < 5000) { session.buffer.push(data); } else { session.buffer.shift(); session.buffer.push(data); } if (session.ws && session.ws.readyState === WebSocket.OPEN) { let outputData = data; const cleanChunk = stripAnsiSequences(data); urlDetectionBuffer = `${urlDetectionBuffer}${cleanChunk}`.slice(-SHELL_URL_PARSE_BUFFER_LIMIT); outputData = outputData.replace( /OPEN_URL:\s*(https?:\/\/[^\s\x1b\x07]+)/g, '[INFO] Opening in browser: $1' ); const emitAuthUrl = (detectedUrl, autoOpen = false) => { const normalizedUrl = normalizeDetectedUrl(detectedUrl); if (!normalizedUrl) return; const isNewUrl = !announcedAuthUrls.has(normalizedUrl); if (isNewUrl) { announcedAuthUrls.add(normalizedUrl); session.ws.send(JSON.stringify({ type: 'auth_url', url: normalizedUrl, autoOpen })); } }; const normalizedDetectedUrls = extractUrlsFromText(urlDetectionBuffer) .map((url) => normalizeDetectedUrl(url)) .filter(Boolean); // Prefer the most complete URL if shorter prefix variants are also present. const dedupedDetectedUrls = Array.from(new Set(normalizedDetectedUrls)).filter((url, _, urls) => !urls.some((otherUrl) => otherUrl !== url && otherUrl.startsWith(url)) ); dedupedDetectedUrls.forEach((url) => emitAuthUrl(url, false)); if (shouldAutoOpenUrlFromOutput(cleanChunk) && dedupedDetectedUrls.length > 0) { const bestUrl = dedupedDetectedUrls.reduce((longest, current) => current.length > longest.length ? current : longest ); emitAuthUrl(bestUrl, true); } // Send regular output session.ws.send(JSON.stringify({ type: 'output', data: outputData })); } }); // Handle process exit shellProcess.onExit((exitCode) => { console.log('🔚 Shell process exited with code:', exitCode.exitCode, 'signal:', exitCode.signal); const session = ptySessionsMap.get(ptySessionKey); if (session && session.ws && session.ws.readyState === WebSocket.OPEN) { session.ws.send(JSON.stringify({ type: 'output', data: `\r\n\x1b[33mProcess exited with code ${exitCode.exitCode}${exitCode.signal ? ` (${exitCode.signal})` : ''}\x1b[0m\r\n` })); } if (session && session.timeoutId) { clearTimeout(session.timeoutId); } ptySessionsMap.delete(ptySessionKey); shellProcess = null; }); } catch (spawnError) { console.error('[ERROR] Error spawning process:', spawnError); ws.send(JSON.stringify({ type: 'output', data: `\r\n\x1b[31mError: ${spawnError.message}\x1b[0m\r\n` })); } } else if (data.type === 'input') { // Send input to shell process if (shellProcess && shellProcess.write) { try { shellProcess.write(data.data); } catch (error) { console.error('Error writing to shell:', error); } } else { console.warn('No active shell process to send input to'); } } else if (data.type === 'resize') { // Handle terminal resize if (shellProcess && shellProcess.resize) { console.log('Terminal resize requested:', data.cols, 'x', data.rows); shellProcess.resize(data.cols, data.rows); } } } catch (error) { console.error('[ERROR] Shell WebSocket error:', error.message); if (ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify({ type: 'output', data: `\r\n\x1b[31mError: ${error.message}\x1b[0m\r\n` })); } } }); ws.on('close', () => { console.log('🔌 Shell client disconnected'); if (ptySessionKey) { const session = ptySessionsMap.get(ptySessionKey); if (session) { console.log('⏳ PTY session kept alive, will timeout in 30 minutes:', ptySessionKey); session.ws = null; session.timeoutId = setTimeout(() => { console.log('⏰ PTY session timeout, killing process:', ptySessionKey); if (session.pty && session.pty.kill) { session.pty.kill(); } ptySessionsMap.delete(ptySessionKey); }, PTY_SESSION_TIMEOUT); } } }); ws.on('error', (error) => { console.error('[ERROR] Shell WebSocket error:', error); }); } // Audio transcription endpoint app.post('/api/transcribe', authenticateToken, async (req, res) => { try { const multer = (await import('multer')).default; const upload = multer({ storage: multer.memoryStorage() }); // Handle multipart form data upload.single('audio')(req, res, async (err) => { if (err) { return res.status(400).json({ error: 'Failed to process audio file' }); } if (!req.file) { return res.status(400).json({ error: 'No audio file provided' }); } const apiKey = process.env.OPENAI_API_KEY; if (!apiKey) { return res.status(500).json({ error: 'OpenAI API key not configured. Please set OPENAI_API_KEY in server environment.' }); } try { // Create form data for OpenAI const FormData = (await import('form-data')).default; const formData = new FormData(); formData.append('file', req.file.buffer, { filename: req.file.originalname, contentType: req.file.mimetype }); formData.append('model', 'whisper-1'); formData.append('response_format', 'json'); formData.append('language', 'en'); // Make request to OpenAI const response = await fetch('https://api.openai.com/v1/audio/transcriptions', { method: 'POST', headers: { 'Authorization': `Bearer ${apiKey}`, ...formData.getHeaders() }, body: formData }); if (!response.ok) { const errorData = await response.json().catch(() => ({})); throw new Error(errorData.error?.message || `Whisper API error: ${response.status}`); } const data = await response.json(); let transcribedText = data.text || ''; // Check if enhancement mode is enabled const mode = req.body.mode || 'default'; // If no transcribed text, return empty if (!transcribedText) { return res.json({ text: '' }); } // If default mode, return transcribed text without enhancement if (mode === 'default') { return res.json({ text: transcribedText }); } // Handle different enhancement modes try { const OpenAI = (await import('openai')).default; const openai = new OpenAI({ apiKey }); let prompt, systemMessage, temperature = 0.7, maxTokens = 800; switch (mode) { case 'prompt': systemMessage = 'You are an expert prompt engineer who creates clear, detailed, and effective prompts.'; prompt = `You are an expert prompt engineer. Transform the following rough instruction into a clear, detailed, and context-aware AI prompt. Your enhanced prompt should: 1. Be specific and unambiguous 2. Include relevant context and constraints 3. Specify the desired output format 4. Use clear, actionable language 5. Include examples where helpful 6. Consider edge cases and potential ambiguities Transform this rough instruction into a well-crafted prompt: "${transcribedText}" Enhanced prompt:`; break; case 'vibe': case 'instructions': case 'architect': systemMessage = 'You are a helpful assistant that formats ideas into clear, actionable instructions for AI agents.'; temperature = 0.5; // Lower temperature for more controlled output prompt = `Transform the following idea into clear, well-structured instructions that an AI agent can easily understand and execute. IMPORTANT RULES: - Format as clear, step-by-step instructions - Add reasonable implementation details based on common patterns - Only include details directly related to what was asked - Do NOT add features or functionality not mentioned - Keep the original intent and scope intact - Use clear, actionable language an agent can follow Transform this idea into agent-friendly instructions: "${transcribedText}" Agent instructions:`; break; default: // No enhancement needed break; } // Only make GPT call if we have a prompt if (prompt) { const completion = await openai.chat.completions.create({ model: 'gpt-4o-mini', messages: [ { role: 'system', content: systemMessage }, { role: 'user', content: prompt } ], temperature: temperature, max_tokens: maxTokens }); transcribedText = completion.choices[0].message.content || transcribedText; } } catch (gptError) { console.error('GPT processing error:', gptError); // Fall back to original transcription if GPT fails } res.json({ text: transcribedText }); } catch (error) { console.error('Transcription error:', error); res.status(500).json({ error: error.message }); } }); } catch (error) { console.error('Endpoint error:', error); res.status(500).json({ error: 'Internal server error' }); } }); // Image upload endpoint app.post('/api/projects/:projectName/upload-images', authenticateToken, async (req, res) => { try { const multer = (await import('multer')).default; const path = (await import('path')).default; const fs = (await import('fs')).promises; const os = (await import('os')).default; // Configure multer for image uploads const storage = multer.diskStorage({ destination: async (req, file, cb) => { const uploadDir = path.join(os.tmpdir(), 'claude-ui-uploads', String(req.user.id)); await fs.mkdir(uploadDir, { recursive: true }); cb(null, uploadDir); }, filename: (req, file, cb) => { const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9); const sanitizedName = file.originalname.replace(/[^a-zA-Z0-9.-]/g, '_'); cb(null, uniqueSuffix + '-' + sanitizedName); } }); const fileFilter = (req, file, cb) => { const allowedMimes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/svg+xml']; if (allowedMimes.includes(file.mimetype)) { cb(null, true); } else { cb(new Error('Invalid file type. Only JPEG, PNG, GIF, WebP, and SVG are allowed.')); } }; const upload = multer({ storage, fileFilter, limits: { fileSize: 5 * 1024 * 1024, // 5MB files: 5 } }); // Handle multipart form data upload.array('images', 5)(req, res, async (err) => { if (err) { return res.status(400).json({ error: err.message }); } if (!req.files || req.files.length === 0) { return res.status(400).json({ error: 'No image files provided' }); } try { // Process uploaded images const processedImages = await Promise.all( req.files.map(async (file) => { // Read file and convert to base64 const buffer = await fs.readFile(file.path); const base64 = buffer.toString('base64'); const mimeType = file.mimetype; // Clean up temp file immediately await fs.unlink(file.path); return { name: file.originalname, data: `data:${mimeType};base64,${base64}`, size: file.size, mimeType: mimeType }; }) ); res.json({ images: processedImages }); } catch (error) { console.error('Error processing images:', error); // Clean up any remaining files await Promise.all(req.files.map(f => fs.unlink(f.path).catch(() => { }))); res.status(500).json({ error: 'Failed to process images' }); } }); } catch (error) { console.error('Error in image upload endpoint:', error); res.status(500).json({ error: 'Internal server error' }); } }); // Get token usage for a specific session app.get('/api/projects/:projectName/sessions/:sessionId/token-usage', authenticateToken, async (req, res) => { try { const { projectName, sessionId } = req.params; const { provider = 'claude' } = req.query; const homeDir = os.homedir(); // Allow only safe characters in sessionId const safeSessionId = String(sessionId).replace(/[^a-zA-Z0-9._-]/g, ''); if (!safeSessionId || safeSessionId !== String(sessionId)) { return res.status(400).json({ error: 'Invalid sessionId' }); } // Handle Cursor sessions - they use SQLite and don't have token usage info if (provider === 'cursor') { return res.json({ used: 0, total: 0, breakdown: { input: 0, cacheCreation: 0, cacheRead: 0 }, unsupported: true, message: 'Token usage tracking not available for Cursor sessions' }); } // Handle Gemini sessions - they are raw logs in our current setup if (provider === 'gemini') { return res.json({ used: 0, total: 0, breakdown: { input: 0, cacheCreation: 0, cacheRead: 0 }, unsupported: true, message: 'Token usage tracking not available for Gemini sessions' }); } // Handle Codex sessions if (provider === 'codex') { const codexSessionsDir = path.join(homeDir, '.codex', 'sessions'); // Find the session file by searching for the session ID const findSessionFile = async (dir) => { try { const entries = await fsPromises.readdir(dir, { withFileTypes: true }); for (const entry of entries) { const fullPath = path.join(dir, entry.name); if (entry.isDirectory()) { const found = await findSessionFile(fullPath); if (found) return found; } else if (entry.name.includes(safeSessionId) && entry.name.endsWith('.jsonl')) { return fullPath; } } } catch (error) { // Skip directories we can't read } return null; }; const sessionFilePath = await findSessionFile(codexSessionsDir); if (!sessionFilePath) { return res.status(404).json({ error: 'Codex session file not found', sessionId: safeSessionId }); } // Read and parse the Codex JSONL file let fileContent; try { fileContent = await fsPromises.readFile(sessionFilePath, 'utf8'); } catch (error) { if (error.code === 'ENOENT') { return res.status(404).json({ error: 'Session file not found', path: sessionFilePath }); } throw error; } const lines = fileContent.trim().split('\n'); let totalTokens = 0; let contextWindow = 200000; // Default for Codex/OpenAI // Find the latest token_count event with info (scan from end) for (let i = lines.length - 1; i >= 0; i--) { try { const entry = JSON.parse(lines[i]); // Codex stores token info in event_msg with type: "token_count" if (entry.type === 'event_msg' && entry.payload?.type === 'token_count' && entry.payload?.info) { const tokenInfo = entry.payload.info; if (tokenInfo.total_token_usage) { totalTokens = tokenInfo.total_token_usage.total_tokens || 0; } if (tokenInfo.model_context_window) { contextWindow = tokenInfo.model_context_window; } break; // Stop after finding the latest token count } } catch (parseError) { // Skip lines that can't be parsed continue; } } return res.json({ used: totalTokens, total: contextWindow }); } // Handle Claude sessions (default) // Extract actual project path let projectPath; try { projectPath = await extractProjectDirectory(projectName); } catch (error) { console.error('Error extracting project directory:', error); return res.status(500).json({ error: 'Failed to determine project path' }); } // Construct the JSONL file path // Claude stores session files in ~/.claude/projects/[encoded-project-path]/[session-id].jsonl // The encoding replaces any non-alphanumeric character (except -) with - const encodedPath = projectPath.replace(/[^a-zA-Z0-9-]/g, '-'); const projectDir = path.join(homeDir, '.claude', 'projects', encodedPath); const jsonlPath = path.join(projectDir, `${safeSessionId}.jsonl`); // Constrain to projectDir const rel = path.relative(path.resolve(projectDir), path.resolve(jsonlPath)); if (rel.startsWith('..') || path.isAbsolute(rel)) { return res.status(400).json({ error: 'Invalid path' }); } // Read and parse the JSONL file let fileContent; try { fileContent = await fsPromises.readFile(jsonlPath, 'utf8'); } catch (error) { if (error.code === 'ENOENT') { return res.status(404).json({ error: 'Session file not found', path: jsonlPath }); } throw error; // Re-throw other errors to be caught by outer try-catch } const lines = fileContent.trim().split('\n'); const parsedContextWindow = parseInt(process.env.CONTEXT_WINDOW, 10); const contextWindow = Number.isFinite(parsedContextWindow) ? parsedContextWindow : 160000; let inputTokens = 0; let cacheCreationTokens = 0; let cacheReadTokens = 0; // Find the latest assistant message with usage data (scan from end) for (let i = lines.length - 1; i >= 0; i--) { try { const entry = JSON.parse(lines[i]); // Only count assistant messages which have usage data if (entry.type === 'assistant' && entry.message?.usage) { const usage = entry.message.usage; // Use token counts from latest assistant message only inputTokens = usage.input_tokens || 0; cacheCreationTokens = usage.cache_creation_input_tokens || 0; cacheReadTokens = usage.cache_read_input_tokens || 0; break; // Stop after finding the latest assistant message } } catch (parseError) { // Skip lines that can't be parsed continue; } } // Calculate total context usage (excluding output_tokens, as per ccusage) const totalUsed = inputTokens + cacheCreationTokens + cacheReadTokens; res.json({ used: totalUsed, total: contextWindow, breakdown: { input: inputTokens, cacheCreation: cacheCreationTokens, cacheRead: cacheReadTokens } }); } catch (error) { console.error('Error reading session token usage:', error); res.status(500).json({ error: 'Failed to read session token usage' }); } }); // Serve React app for all other routes (excluding static files) app.get('*', (req, res) => { // Skip requests for static assets (files with extensions) if (path.extname(req.path)) { return res.status(404).send('Not found'); } // Only serve index.html for HTML routes, not for static assets // Static assets should already be handled by express.static middleware above const indexPath = path.join(__dirname, '../dist/index.html'); // Check if dist/index.html exists (production build available) if (fs.existsSync(indexPath)) { // Set no-cache headers for HTML to prevent service worker issues res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate'); res.setHeader('Pragma', 'no-cache'); res.setHeader('Expires', '0'); res.sendFile(indexPath); } else { // In development, redirect to Vite dev server only if dist doesn't exist const redirectHost = getConnectableHost(req.hostname); res.redirect(`${req.protocol}://${redirectHost}:${VITE_PORT}`); } }); // Helper function to convert permissions to rwx format function permToRwx(perm) { const r = perm & 4 ? 'r' : '-'; const w = perm & 2 ? 'w' : '-'; const x = perm & 1 ? 'x' : '-'; return r + w + x; } async function getFileTree(dirPath, maxDepth = 3, currentDepth = 0, showHidden = true) { // Using fsPromises from import const items = []; try { const entries = await fsPromises.readdir(dirPath, { withFileTypes: true }); for (const entry of entries) { // Debug: log all entries including hidden files // Skip heavy build directories and VCS directories if (entry.name === 'node_modules' || entry.name === 'dist' || entry.name === 'build' || entry.name === '.git' || entry.name === '.svn' || entry.name === '.hg') continue; const itemPath = path.join(dirPath, entry.name); const item = { name: entry.name, path: itemPath, type: entry.isDirectory() ? 'directory' : 'file' }; // Get file stats for additional metadata try { const stats = await fsPromises.stat(itemPath); item.size = stats.size; item.modified = stats.mtime.toISOString(); // Convert permissions to rwx format const mode = stats.mode; const ownerPerm = (mode >> 6) & 7; const groupPerm = (mode >> 3) & 7; const otherPerm = mode & 7; item.permissions = ((mode >> 6) & 7).toString() + ((mode >> 3) & 7).toString() + (mode & 7).toString(); item.permissionsRwx = permToRwx(ownerPerm) + permToRwx(groupPerm) + permToRwx(otherPerm); } catch (statError) { // If stat fails, provide default values item.size = 0; item.modified = null; item.permissions = '000'; item.permissionsRwx = '---------'; } if (entry.isDirectory() && currentDepth < maxDepth) { // Recursively get subdirectories but limit depth try { // Check if we can access the directory before trying to read it await fsPromises.access(item.path, fs.constants.R_OK); item.children = await getFileTree(item.path, maxDepth, currentDepth + 1, showHidden); } catch (e) { // Silently skip directories we can't access (permission denied, etc.) item.children = []; } } items.push(item); } } catch (error) { // Only log non-permission errors to avoid spam if (error.code !== 'EACCES' && error.code !== 'EPERM') { console.error('Error reading directory:', error); } } return items.sort((a, b) => { if (a.type !== b.type) { return a.type === 'directory' ? -1 : 1; } return a.name.localeCompare(b.name); }); } const SERVER_PORT = process.env.SERVER_PORT || 3001; const HOST = process.env.HOST || '0.0.0.0'; const DISPLAY_HOST = getConnectableHost(HOST); const VITE_PORT = process.env.VITE_PORT || 5173; // Initialize database and start server async function startServer() { try { // Initialize authentication database await initializeDatabase(); // Configure Web Push (VAPID keys) configureWebPush(); // Check if running in production mode (dist folder exists) const distIndexPath = path.join(__dirname, '../dist/index.html'); const isProduction = fs.existsSync(distIndexPath); // Log Claude implementation mode console.log(`${c.info('[INFO]')} Using Claude Agents SDK for Claude integration`); console.log(''); if (isProduction) { console.log(`${c.info('[INFO]')} To run in production mode, go to http://${DISPLAY_HOST}:${SERVER_PORT}`); } console.log(`${c.info('[INFO]')} To run in development mode with hot-module replacement, go to http://${DISPLAY_HOST}:${VITE_PORT}`); server.listen(SERVER_PORT, HOST, async () => { const appInstallPath = path.join(__dirname, '..'); console.log(''); console.log(c.dim('═'.repeat(63))); console.log(` ${c.bright('Claude Code UI Server - Ready')}`); console.log(c.dim('═'.repeat(63))); console.log(''); console.log(`${c.info('[INFO]')} Server URL: ${c.bright('http://' + DISPLAY_HOST + ':' + SERVER_PORT)}`); console.log(`${c.info('[INFO]')} Installed at: ${c.dim(appInstallPath)}`); console.log(`${c.tip('[TIP]')} Run "cloudcli status" for full configuration details`); console.log(''); // Start watching the projects folder for changes await setupProjectsWatcher(); // Start server-side plugin processes for enabled plugins startEnabledPluginServers().catch(err => { console.error('[Plugins] Error during startup:', err.message); }); }); // Clean up plugin processes on shutdown const shutdownPlugins = async () => { await stopAllPlugins(); process.exit(0); }; process.on('SIGTERM', () => void shutdownPlugins()); process.on('SIGINT', () => void shutdownPlugins()); } catch (error) { console.error('[ERROR] Failed to start server:', error); process.exit(1); } } startServer(); ================================================ FILE: server/load-env.js ================================================ // Load environment variables from .env before other imports execute. import fs from 'fs'; import os from 'os'; import path from 'path'; import { fileURLToPath } from 'url'; import { dirname } from 'path'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); try { const envPath = path.join(__dirname, '../.env'); const envFile = fs.readFileSync(envPath, 'utf8'); envFile.split('\n').forEach(line => { const trimmedLine = line.trim(); if (trimmedLine && !trimmedLine.startsWith('#')) { const [key, ...valueParts] = trimmedLine.split('='); if (key && valueParts.length > 0 && !process.env[key]) { process.env[key] = valueParts.join('=').trim(); } } }); } catch (e) { console.log('No .env file found or error reading it:', e.message); } if (!process.env.DATABASE_PATH) { process.env.DATABASE_PATH = path.join(os.homedir(), '.cloudcli', 'auth.db'); } ================================================ FILE: server/middleware/auth.js ================================================ import jwt from 'jsonwebtoken'; import { userDb, appConfigDb } from '../database/db.js'; import { IS_PLATFORM } from '../constants/config.js'; // Use env var if set, otherwise auto-generate a unique secret per installation const JWT_SECRET = process.env.JWT_SECRET || appConfigDb.getOrCreateJwtSecret(); // Optional API key middleware const validateApiKey = (req, res, next) => { // Skip API key validation if not configured if (!process.env.API_KEY) { return next(); } const apiKey = req.headers['x-api-key']; if (apiKey !== process.env.API_KEY) { return res.status(401).json({ error: 'Invalid API key' }); } next(); }; // JWT authentication middleware const authenticateToken = async (req, res, next) => { // Platform mode: use single database user if (IS_PLATFORM) { try { const user = userDb.getFirstUser(); if (!user) { return res.status(500).json({ error: 'Platform mode: No user found in database' }); } req.user = user; return next(); } catch (error) { console.error('Platform mode error:', error); return res.status(500).json({ error: 'Platform mode: Failed to fetch user' }); } } // Normal OSS JWT validation const authHeader = req.headers['authorization']; let token = authHeader && authHeader.split(' ')[1]; // Bearer TOKEN // Also check query param for SSE endpoints (EventSource can't set headers) if (!token && req.query.token) { token = req.query.token; } if (!token) { return res.status(401).json({ error: 'Access denied. No token provided.' }); } try { const decoded = jwt.verify(token, JWT_SECRET); // Verify user still exists and is active const user = userDb.getUserById(decoded.userId); if (!user) { return res.status(401).json({ error: 'Invalid token. User not found.' }); } // Auto-refresh: if token is past halfway through its lifetime, issue a new one if (decoded.exp && decoded.iat) { const now = Math.floor(Date.now() / 1000); const halfLife = (decoded.exp - decoded.iat) / 2; if (now > decoded.iat + halfLife) { const newToken = generateToken(user); res.setHeader('X-Refreshed-Token', newToken); } } req.user = user; next(); } catch (error) { console.error('Token verification error:', error); return res.status(403).json({ error: 'Invalid token' }); } }; // Generate JWT token const generateToken = (user) => { return jwt.sign( { userId: user.id, username: user.username }, JWT_SECRET, { expiresIn: '7d' } ); }; // WebSocket authentication function const authenticateWebSocket = (token) => { // Platform mode: bypass token validation, return first user if (IS_PLATFORM) { try { const user = userDb.getFirstUser(); if (user) { return { id: user.id, userId: user.id, username: user.username }; } return null; } catch (error) { console.error('Platform mode WebSocket error:', error); return null; } } // Normal OSS JWT validation if (!token) { return null; } try { const decoded = jwt.verify(token, JWT_SECRET); // Verify user actually exists in database (matches REST authenticateToken behavior) const user = userDb.getUserById(decoded.userId); if (!user) { return null; } return { userId: user.id, username: user.username }; } catch (error) { console.error('WebSocket token verification error:', error); return null; } }; export { validateApiKey, authenticateToken, generateToken, authenticateWebSocket, JWT_SECRET }; ================================================ FILE: server/openai-codex.js ================================================ /** * OpenAI Codex SDK Integration * ============================= * * This module provides integration with the OpenAI Codex SDK for non-interactive * chat sessions. It mirrors the pattern used in claude-sdk.js for consistency. * * ## Usage * * - queryCodex(command, options, ws) - Execute a prompt with streaming via WebSocket * - abortCodexSession(sessionId) - Cancel an active session * - isCodexSessionActive(sessionId) - Check if a session is running * - getActiveCodexSessions() - List all active sessions */ 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'; // Track active sessions const activeCodexSessions = new Map(); /** * Transform Codex SDK event to WebSocket message format * @param {object} event - SDK event * @returns {object} - Transformed event for WebSocket */ function transformCodexEvent(event) { // Map SDK event types to a consistent format switch (event.type) { case 'item.started': case 'item.updated': case 'item.completed': const item = event.item; if (!item) { return { type: event.type, item: null }; } // Transform based on item type switch (item.type) { case 'agent_message': return { type: 'item', itemType: 'agent_message', message: { role: 'assistant', content: item.text } }; case 'reasoning': return { type: 'item', itemType: 'reasoning', message: { role: 'assistant', content: item.text, isReasoning: true } }; case 'command_execution': return { type: 'item', itemType: 'command_execution', command: item.command, output: item.aggregated_output, exitCode: item.exit_code, status: item.status }; case 'file_change': return { type: 'item', itemType: 'file_change', changes: item.changes, status: item.status }; case 'mcp_tool_call': return { type: 'item', itemType: 'mcp_tool_call', server: item.server, tool: item.tool, arguments: item.arguments, result: item.result, error: item.error, status: item.status }; case 'web_search': return { type: 'item', itemType: 'web_search', query: item.query }; case 'todo_list': return { type: 'item', itemType: 'todo_list', items: item.items }; case 'error': return { type: 'item', itemType: 'error', message: { role: 'error', content: item.message } }; default: return { type: 'item', itemType: item.type, item: item }; } case 'turn.started': return { type: 'turn_started' }; case 'turn.completed': return { type: 'turn_complete', usage: event.usage }; case 'turn.failed': return { type: 'turn_failed', error: event.error }; case 'thread.started': return { type: 'thread_started', threadId: event.id }; case 'error': return { type: 'error', message: event.message }; default: return { type: event.type, data: event }; } } /** * Map permission mode to Codex SDK options * @param {string} permissionMode - 'default', 'acceptEdits', or 'bypassPermissions' * @returns {object} - { sandboxMode, approvalPolicy } */ function mapPermissionModeToCodexOptions(permissionMode) { switch (permissionMode) { case 'acceptEdits': return { sandboxMode: 'workspace-write', approvalPolicy: 'never' }; case 'bypassPermissions': return { sandboxMode: 'danger-full-access', approvalPolicy: 'never' }; case 'default': default: return { sandboxMode: 'workspace-write', approvalPolicy: 'untrusted' }; } } /** * Execute a Codex query with streaming * @param {string} command - The prompt to send * @param {object} options - Options including cwd, sessionId, model, permissionMode * @param {WebSocket|object} ws - WebSocket connection or response writer */ export async function queryCodex(command, options = {}, ws) { const { sessionId, sessionSummary, cwd, projectPath, model, permissionMode = 'default' } = options; const workingDirectory = cwd || projectPath || process.cwd(); const { sandboxMode, approvalPolicy } = mapPermissionModeToCodexOptions(permissionMode); let codex; let thread; let currentSessionId = sessionId; let terminalFailure = null; const abortController = new AbortController(); try { // Initialize Codex SDK codex = new Codex(); // Thread options with sandbox and approval settings const threadOptions = { workingDirectory, skipGitRepoCheck: true, sandboxMode, approvalPolicy, model }; // Start or resume thread if (sessionId) { thread = codex.resumeThread(sessionId, threadOptions); } else { thread = codex.startThread(threadOptions); } // Get the thread ID currentSessionId = thread.id || sessionId || `codex-${Date.now()}`; // Track the session activeCodexSessions.set(currentSessionId, { thread, codex, status: 'running', abortController, startedAt: new Date().toISOString() }); // Send session created event sendMessage(ws, createNormalizedMessage({ kind: 'session_created', newSessionId: currentSessionId, sessionId: currentSessionId, provider: 'codex' })); // Execute with streaming const streamedTurn = await thread.runStreamed(command, { signal: abortController.signal }); for await (const event of streamedTurn.events) { // Check if session was aborted const session = activeCodexSessions.get(currentSessionId); if (!session || session.status === 'aborted') { break; } if (event.type === 'item.started' || event.type === 'item.updated') { continue; } const transformed = transformCodexEvent(event); // Normalize the transformed event into NormalizedMessage(s) via adapter const normalizedMsgs = codexAdapter.normalizeMessage(transformed, currentSessionId); for (const msg of normalizedMsgs) { sendMessage(ws, msg); } if (event.type === 'turn.failed' && !terminalFailure) { terminalFailure = event.error || new Error('Turn failed'); notifyRunFailed({ userId: ws?.userId || null, provider: 'codex', sessionId: currentSessionId, sessionName: sessionSummary, error: terminalFailure }); } // Extract and send token usage if available (normalized to match Claude format) if (event.type === 'turn.completed' && event.usage) { const totalTokens = (event.usage.input_tokens || 0) + (event.usage.output_tokens || 0); sendMessage(ws, createNormalizedMessage({ kind: 'status', text: 'token_budget', tokenBudget: { used: totalTokens, total: 200000 }, sessionId: currentSessionId, provider: 'codex' })); } } // Send completion event if (!terminalFailure) { sendMessage(ws, createNormalizedMessage({ kind: 'complete', actualSessionId: thread.id, sessionId: currentSessionId, provider: 'codex' })); notifyRunStopped({ userId: ws?.userId || null, provider: 'codex', sessionId: currentSessionId, sessionName: sessionSummary, stopReason: 'completed' }); } } catch (error) { const session = currentSessionId ? activeCodexSessions.get(currentSessionId) : null; const wasAborted = session?.status === 'aborted' || error?.name === 'AbortError' || String(error?.message || '').toLowerCase().includes('aborted'); if (!wasAborted) { console.error('[Codex] Error:', error); sendMessage(ws, createNormalizedMessage({ kind: 'error', content: error.message, sessionId: currentSessionId, provider: 'codex' })); if (!terminalFailure) { notifyRunFailed({ userId: ws?.userId || null, provider: 'codex', sessionId: currentSessionId, sessionName: sessionSummary, error }); } } } finally { // Update session status if (currentSessionId) { const session = activeCodexSessions.get(currentSessionId); if (session) { session.status = session.status === 'aborted' ? 'aborted' : 'completed'; } } } } /** * Abort an active Codex session * @param {string} sessionId - Session ID to abort * @returns {boolean} - Whether abort was successful */ export function abortCodexSession(sessionId) { const session = activeCodexSessions.get(sessionId); if (!session) { return false; } session.status = 'aborted'; try { session.abortController?.abort(); } catch (error) { console.warn(`[Codex] Failed to abort session ${sessionId}:`, error); } return true; } /** * Check if a session is active * @param {string} sessionId - Session ID to check * @returns {boolean} - Whether session is active */ export function isCodexSessionActive(sessionId) { const session = activeCodexSessions.get(sessionId); return session?.status === 'running'; } /** * Get all active sessions * @returns {Array} - Array of active session info */ export function getActiveCodexSessions() { const sessions = []; for (const [id, session] of activeCodexSessions.entries()) { if (session.status === 'running') { sessions.push({ id, status: session.status, startedAt: session.startedAt }); } } return sessions; } /** * Helper to send message via WebSocket or writer * @param {WebSocket|object} ws - WebSocket or response writer * @param {object} data - Data to send */ function sendMessage(ws, data) { try { if (ws.isSSEStreamWriter || ws.isWebSocketWriter) { // Writer handles stringification (SSEStreamWriter or WebSocketWriter) ws.send(data); } else if (typeof ws.send === 'function') { // Raw WebSocket - stringify here ws.send(JSON.stringify(data)); } } catch (error) { console.error('[Codex] Error sending message:', error); } } // Clean up old completed sessions periodically setInterval(() => { const now = Date.now(); const maxAge = 30 * 60 * 1000; // 30 minutes for (const [id, session] of activeCodexSessions.entries()) { if (session.status !== 'running') { const startedAt = new Date(session.startedAt).getTime(); if (now - startedAt > maxAge) { activeCodexSessions.delete(id); } } } }, 5 * 60 * 1000); // Every 5 minutes ================================================ FILE: server/projects.js ================================================ /** * PROJECT DISCOVERY AND MANAGEMENT SYSTEM * ======================================== * * This module manages project discovery for both Claude CLI and Cursor CLI sessions. * * ## Architecture Overview * * 1. **Claude Projects** (stored in ~/.claude/projects/) * - Each project is a directory named with the project path encoded (/ replaced with -) * - Contains .jsonl files with conversation history including 'cwd' field * - Project metadata stored in ~/.claude/project-config.json * * 2. **Cursor Projects** (stored in ~/.cursor/chats/) * - Each project directory is named with MD5 hash of the absolute project path * - Example: /Users/john/myproject -> MD5 -> a1b2c3d4e5f6... * - Contains session directories with SQLite databases (store.db) * - Project path is NOT stored in the database - only in the MD5 hash * * ## Project Discovery Strategy * * 1. **Claude Projects Discovery**: * - Scan ~/.claude/projects/ directory for Claude project folders * - Extract actual project path from .jsonl files (cwd field) * - Fall back to decoded directory name if no sessions exist * * 2. **Cursor Sessions Discovery**: * - For each KNOWN project (from Claude or manually added) * - Compute MD5 hash of the project's absolute path * - Check if ~/.cursor/chats/{md5_hash}/ directory exists * - Read session metadata from SQLite store.db files * * 3. **Manual Project Addition**: * - Users can manually add project paths via UI * - Stored in ~/.claude/project-config.json with 'manuallyAdded' flag * - Allows discovering Cursor sessions for projects without Claude sessions * * ## Critical Limitations * * - **CANNOT discover Cursor-only projects**: From a quick check, there was no mention of * the cwd of each project. if someone has the time, you can try to reverse engineer it. * * - **Project relocation breaks history**: If a project directory is moved or renamed, * the MD5 hash changes, making old Cursor sessions inaccessible unless the old * path is known and manually added. * * ## Error Handling * * - Missing ~/.claude directory is handled gracefully with automatic creation * - ENOENT errors are caught and handled without crashing * - Empty arrays returned when no projects/sessions exist * * ## Caching Strategy * * - Project directory extraction is cached to minimize file I/O * - Cache is cleared when project configuration changes * - Session data is fetched on-demand, not cached */ import { promises as fs } from 'fs'; import fsSync from 'fs'; import path from 'path'; import readline from 'readline'; import crypto from 'crypto'; import sqlite3 from 'sqlite3'; import { open } from 'sqlite'; import os from 'os'; import sessionManager from './sessionManager.js'; import { applyCustomSessionNames } from './database/db.js'; // Import TaskMaster detection functions async function detectTaskMasterFolder(projectPath) { try { const taskMasterPath = path.join(projectPath, '.taskmaster'); // Check if .taskmaster directory exists try { const stats = await fs.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 fs.access(filePath); 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 fs.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 fs.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}` }; } } // Cache for extracted project directories const projectDirectoryCache = new Map(); // Clear cache when needed (called when project files change) function clearProjectDirectoryCache() { projectDirectoryCache.clear(); } // Load project configuration file async function loadProjectConfig() { const configPath = path.join(os.homedir(), '.claude', 'project-config.json'); try { const configData = await fs.readFile(configPath, 'utf8'); return JSON.parse(configData); } catch (error) { // Return empty config if file doesn't exist return {}; } } // Save project configuration file async function saveProjectConfig(config) { const claudeDir = path.join(os.homedir(), '.claude'); const configPath = path.join(claudeDir, 'project-config.json'); // Ensure the .claude directory exists try { await fs.mkdir(claudeDir, { recursive: true }); } catch (error) { if (error.code !== 'EEXIST') { throw error; } } await fs.writeFile(configPath, JSON.stringify(config, null, 2), 'utf8'); } // Generate better display name from path async function generateDisplayName(projectName, actualProjectDir = null) { // Use actual project directory if provided, otherwise decode from project name let projectPath = actualProjectDir || projectName.replace(/-/g, '/'); // Try to read package.json from the project path try { const packageJsonPath = path.join(projectPath, 'package.json'); const packageData = await fs.readFile(packageJsonPath, 'utf8'); const packageJson = JSON.parse(packageData); // Return the name from package.json if it exists if (packageJson.name) { return packageJson.name; } } catch (error) { // Fall back to path-based naming if package.json doesn't exist or can't be read } // If it starts with /, it's an absolute path if (projectPath.startsWith('/')) { const parts = projectPath.split('/').filter(Boolean); // Return only the last folder name return parts[parts.length - 1] || projectPath; } return projectPath; } // Extract the actual project directory from JSONL sessions (with caching) async function extractProjectDirectory(projectName) { // Check cache first if (projectDirectoryCache.has(projectName)) { return projectDirectoryCache.get(projectName); } // Check project config for originalPath (manually added projects via UI or platform) // This handles projects with dashes in their directory names correctly const config = await loadProjectConfig(); if (config[projectName]?.originalPath) { const originalPath = config[projectName].originalPath; projectDirectoryCache.set(projectName, originalPath); return originalPath; } const projectDir = path.join(os.homedir(), '.claude', 'projects', projectName); const cwdCounts = new Map(); let latestTimestamp = 0; let latestCwd = null; let extractedPath; try { // Check if the project directory exists await fs.access(projectDir); const files = await fs.readdir(projectDir); const jsonlFiles = files.filter(file => file.endsWith('.jsonl')); if (jsonlFiles.length === 0) { // Fall back to decoded project name if no sessions extractedPath = projectName.replace(/-/g, '/'); } else { // Process all JSONL files to collect cwd values for (const file of jsonlFiles) { const jsonlFile = path.join(projectDir, file); const fileStream = fsSync.createReadStream(jsonlFile); const rl = readline.createInterface({ input: fileStream, crlfDelay: Infinity }); for await (const line of rl) { if (line.trim()) { try { const entry = JSON.parse(line); if (entry.cwd) { // Count occurrences of each cwd cwdCounts.set(entry.cwd, (cwdCounts.get(entry.cwd) || 0) + 1); // Track the most recent cwd const timestamp = new Date(entry.timestamp || 0).getTime(); if (timestamp > latestTimestamp) { latestTimestamp = timestamp; latestCwd = entry.cwd; } } } catch (parseError) { // Skip malformed lines } } } } // Determine the best cwd to use if (cwdCounts.size === 0) { // No cwd found, fall back to decoded project name extractedPath = projectName.replace(/-/g, '/'); } else if (cwdCounts.size === 1) { // Only one cwd, use it extractedPath = Array.from(cwdCounts.keys())[0]; } else { // Multiple cwd values - prefer the most recent one if it has reasonable usage const mostRecentCount = cwdCounts.get(latestCwd) || 0; const maxCount = Math.max(...cwdCounts.values()); // Use most recent if it has at least 25% of the max count if (mostRecentCount >= maxCount * 0.25) { extractedPath = latestCwd; } else { // Otherwise use the most frequently used cwd for (const [cwd, count] of cwdCounts.entries()) { if (count === maxCount) { extractedPath = cwd; break; } } } // Fallback (shouldn't reach here) if (!extractedPath) { extractedPath = latestCwd || projectName.replace(/-/g, '/'); } } } // Cache the result projectDirectoryCache.set(projectName, extractedPath); return extractedPath; } catch (error) { // If the directory doesn't exist, just use the decoded project name if (error.code === 'ENOENT') { extractedPath = projectName.replace(/-/g, '/'); } else { console.error(`Error extracting project directory for ${projectName}:`, error); // Fall back to decoded project name for other errors extractedPath = projectName.replace(/-/g, '/'); } // Cache the fallback result too projectDirectoryCache.set(projectName, extractedPath); return extractedPath; } } async function getProjects(progressCallback = null) { const claudeDir = path.join(os.homedir(), '.claude', 'projects'); const config = await loadProjectConfig(); const projects = []; const existingProjects = new Set(); const codexSessionsIndexRef = { sessionsByProject: null }; let totalProjects = 0; let processedProjects = 0; let directories = []; try { // Check if the .claude/projects directory exists await fs.access(claudeDir); // First, get existing Claude projects from the file system const entries = await fs.readdir(claudeDir, { withFileTypes: true }); directories = entries.filter(e => e.isDirectory()); // Build set of existing project names for later directories.forEach(e => existingProjects.add(e.name)); // Count manual projects not already in directories const manualProjectsCount = Object.entries(config) .filter(([name, cfg]) => cfg.manuallyAdded && !existingProjects.has(name)) .length; totalProjects = directories.length + manualProjectsCount; for (const entry of directories) { processedProjects++; // Emit progress if (progressCallback) { progressCallback({ phase: 'loading', current: processedProjects, total: totalProjects, currentProject: entry.name }); } // Extract actual project directory from JSONL sessions const actualProjectDir = await extractProjectDirectory(entry.name); // Get display name from config or generate one const customName = config[entry.name]?.displayName; const autoDisplayName = await generateDisplayName(entry.name, actualProjectDir); const fullPath = actualProjectDir; const project = { name: entry.name, path: actualProjectDir, displayName: customName || autoDisplayName, fullPath: fullPath, isCustomName: !!customName, sessions: [], geminiSessions: [], sessionMeta: { hasMore: false, total: 0 } }; // Try to get sessions for this project (just first 5 for performance) try { const sessionResult = await getSessions(entry.name, 5, 0); project.sessions = sessionResult.sessions || []; project.sessionMeta = { hasMore: sessionResult.hasMore, total: sessionResult.total }; } catch (e) { console.warn(`Could not load sessions for project ${entry.name}:`, e.message); project.sessionMeta = { hasMore: false, total: 0 }; } applyCustomSessionNames(project.sessions, 'claude'); // Also fetch Cursor sessions for this project try { project.cursorSessions = await getCursorSessions(actualProjectDir); } catch (e) { console.warn(`Could not load Cursor sessions for project ${entry.name}:`, e.message); project.cursorSessions = []; } applyCustomSessionNames(project.cursorSessions, 'cursor'); // Also fetch Codex sessions for this project try { project.codexSessions = await getCodexSessions(actualProjectDir, { indexRef: codexSessionsIndexRef, }); } catch (e) { console.warn(`Could not load Codex sessions for project ${entry.name}:`, e.message); project.codexSessions = []; } applyCustomSessionNames(project.codexSessions, 'codex'); // Also fetch Gemini sessions for this project (UI + CLI) try { const uiSessions = sessionManager.getProjectSessions(actualProjectDir) || []; const cliSessions = await getGeminiCliSessions(actualProjectDir); const uiIds = new Set(uiSessions.map(s => s.id)); const mergedGemini = [...uiSessions, ...cliSessions.filter(s => !uiIds.has(s.id))]; project.geminiSessions = mergedGemini; } catch (e) { console.warn(`Could not load Gemini sessions for project ${entry.name}:`, e.message); project.geminiSessions = []; } applyCustomSessionNames(project.geminiSessions, 'gemini'); // Add TaskMaster detection try { const taskMasterResult = await detectTaskMasterFolder(actualProjectDir); project.taskmaster = { hasTaskmaster: taskMasterResult.hasTaskmaster, hasEssentialFiles: taskMasterResult.hasEssentialFiles, metadata: taskMasterResult.metadata, status: taskMasterResult.hasTaskmaster && taskMasterResult.hasEssentialFiles ? 'configured' : 'not-configured' }; } catch (e) { console.warn(`Could not detect TaskMaster for project ${entry.name}:`, e.message); project.taskmaster = { hasTaskmaster: false, hasEssentialFiles: false, metadata: null, status: 'error' }; } projects.push(project); } } catch (error) { // If the directory doesn't exist (ENOENT), that's okay - just continue with empty projects if (error.code !== 'ENOENT') { console.error('Error reading projects directory:', error); } // Calculate total for manual projects only (no directories exist) totalProjects = Object.entries(config) .filter(([name, cfg]) => cfg.manuallyAdded) .length; } // Add manually configured projects that don't exist as folders yet for (const [projectName, projectConfig] of Object.entries(config)) { if (!existingProjects.has(projectName) && projectConfig.manuallyAdded) { processedProjects++; // Emit progress for manual projects if (progressCallback) { progressCallback({ phase: 'loading', current: processedProjects, total: totalProjects, currentProject: projectName }); } // Use the original path if available, otherwise extract from potential sessions let actualProjectDir = projectConfig.originalPath; if (!actualProjectDir) { try { actualProjectDir = await extractProjectDirectory(projectName); } catch (error) { // Fall back to decoded project name actualProjectDir = projectName.replace(/-/g, '/'); } } const project = { name: projectName, path: actualProjectDir, displayName: projectConfig.displayName || await generateDisplayName(projectName, actualProjectDir), fullPath: actualProjectDir, isCustomName: !!projectConfig.displayName, isManuallyAdded: true, sessions: [], geminiSessions: [], sessionMeta: { hasMore: false, total: 0 }, cursorSessions: [], codexSessions: [] }; // Try to fetch Cursor sessions for manual projects too try { project.cursorSessions = await getCursorSessions(actualProjectDir); } catch (e) { console.warn(`Could not load Cursor sessions for manual project ${projectName}:`, e.message); } applyCustomSessionNames(project.cursorSessions, 'cursor'); // Try to fetch Codex sessions for manual projects too try { project.codexSessions = await getCodexSessions(actualProjectDir, { indexRef: codexSessionsIndexRef, }); } catch (e) { console.warn(`Could not load Codex sessions for manual project ${projectName}:`, e.message); } applyCustomSessionNames(project.codexSessions, 'codex'); // Try to fetch Gemini sessions for manual projects too (UI + CLI) try { const uiSessions = sessionManager.getProjectSessions(actualProjectDir) || []; const cliSessions = await getGeminiCliSessions(actualProjectDir); const uiIds = new Set(uiSessions.map(s => s.id)); project.geminiSessions = [...uiSessions, ...cliSessions.filter(s => !uiIds.has(s.id))]; } catch (e) { console.warn(`Could not load Gemini sessions for manual project ${projectName}:`, e.message); } applyCustomSessionNames(project.geminiSessions, 'gemini'); // Add TaskMaster detection for manual projects try { const taskMasterResult = await detectTaskMasterFolder(actualProjectDir); // Determine TaskMaster status let taskMasterStatus = 'not-configured'; if (taskMasterResult.hasTaskmaster && taskMasterResult.hasEssentialFiles) { taskMasterStatus = 'taskmaster-only'; // We don't check MCP for manual projects in bulk } project.taskmaster = { status: taskMasterStatus, hasTaskmaster: taskMasterResult.hasTaskmaster, hasEssentialFiles: taskMasterResult.hasEssentialFiles, metadata: taskMasterResult.metadata }; } catch (error) { console.warn(`TaskMaster detection failed for manual project ${projectName}:`, error.message); project.taskmaster = { status: 'error', hasTaskmaster: false, hasEssentialFiles: false, error: error.message }; } projects.push(project); } } // Emit completion after all projects (including manual) are processed if (progressCallback) { progressCallback({ phase: 'complete', current: totalProjects, total: totalProjects }); } return projects; } async function getSessions(projectName, limit = 5, offset = 0) { const projectDir = path.join(os.homedir(), '.claude', 'projects', projectName); try { const files = await fs.readdir(projectDir); // agent-*.jsonl files contain session start data at this point. This needs to be revisited // periodically to make sure only accurate data is there and no new functionality is added there const jsonlFiles = files.filter(file => file.endsWith('.jsonl') && !file.startsWith('agent-')); if (jsonlFiles.length === 0) { return { sessions: [], hasMore: false, total: 0 }; } // Sort files by modification time (newest first) const filesWithStats = await Promise.all( jsonlFiles.map(async (file) => { const filePath = path.join(projectDir, file); const stats = await fs.stat(filePath); return { file, mtime: stats.mtime }; }) ); filesWithStats.sort((a, b) => b.mtime - a.mtime); const allSessions = new Map(); const allEntries = []; const uuidToSessionMap = new Map(); // Collect all sessions and entries from all files for (const { file } of filesWithStats) { const jsonlFile = path.join(projectDir, file); const result = await parseJsonlSessions(jsonlFile); result.sessions.forEach(session => { if (!allSessions.has(session.id)) { allSessions.set(session.id, session); } }); allEntries.push(...result.entries); // Early exit optimization for large projects if (allSessions.size >= (limit + offset) * 2 && allEntries.length >= Math.min(3, filesWithStats.length)) { break; } } // Build UUID-to-session mapping for timeline detection allEntries.forEach(entry => { if (entry.uuid && entry.sessionId) { uuidToSessionMap.set(entry.uuid, entry.sessionId); } }); // Group sessions by first user message ID const sessionGroups = new Map(); // firstUserMsgId -> { latestSession, allSessions[] } const sessionToFirstUserMsgId = new Map(); // sessionId -> firstUserMsgId // Find the first user message for each session allEntries.forEach(entry => { if (entry.sessionId && entry.type === 'user' && entry.parentUuid === null && entry.uuid) { // This is a first user message in a session (parentUuid is null) const firstUserMsgId = entry.uuid; if (!sessionToFirstUserMsgId.has(entry.sessionId)) { sessionToFirstUserMsgId.set(entry.sessionId, firstUserMsgId); const session = allSessions.get(entry.sessionId); if (session) { if (!sessionGroups.has(firstUserMsgId)) { sessionGroups.set(firstUserMsgId, { latestSession: session, allSessions: [session] }); } else { const group = sessionGroups.get(firstUserMsgId); group.allSessions.push(session); // Update latest session if this one is more recent if (new Date(session.lastActivity) > new Date(group.latestSession.lastActivity)) { group.latestSession = session; } } } } } }); // Collect all sessions that don't belong to any group (standalone sessions) const groupedSessionIds = new Set(); sessionGroups.forEach(group => { group.allSessions.forEach(session => groupedSessionIds.add(session.id)); }); const standaloneSessionsArray = Array.from(allSessions.values()) .filter(session => !groupedSessionIds.has(session.id)); // Combine grouped sessions (only show latest from each group) + standalone sessions const latestFromGroups = Array.from(sessionGroups.values()).map(group => { const session = { ...group.latestSession }; // Add metadata about grouping if (group.allSessions.length > 1) { session.isGrouped = true; session.groupSize = group.allSessions.length; session.groupSessions = group.allSessions.map(s => s.id); } return session; }); const visibleSessions = [...latestFromGroups, ...standaloneSessionsArray] .filter(session => !session.summary.startsWith('{ "')) .sort((a, b) => new Date(b.lastActivity) - new Date(a.lastActivity)); const total = visibleSessions.length; const paginatedSessions = visibleSessions.slice(offset, offset + limit); const hasMore = offset + limit < total; return { sessions: paginatedSessions, hasMore, total, offset, limit }; } catch (error) { console.error(`Error reading sessions for project ${projectName}:`, error); return { sessions: [], hasMore: false, total: 0 }; } } async function parseJsonlSessions(filePath) { const sessions = new Map(); const entries = []; const pendingSummaries = new Map(); // leafUuid -> summary for entries without sessionId try { const fileStream = fsSync.createReadStream(filePath); const rl = readline.createInterface({ input: fileStream, crlfDelay: Infinity }); for await (const line of rl) { if (line.trim()) { try { const entry = JSON.parse(line); entries.push(entry); // Handle summary entries that don't have sessionId yet if (entry.type === 'summary' && entry.summary && !entry.sessionId && entry.leafUuid) { pendingSummaries.set(entry.leafUuid, entry.summary); } if (entry.sessionId) { if (!sessions.has(entry.sessionId)) { sessions.set(entry.sessionId, { id: entry.sessionId, summary: 'New Session', messageCount: 0, lastActivity: new Date(), cwd: entry.cwd || '', lastUserMessage: null, lastAssistantMessage: null }); } const session = sessions.get(entry.sessionId); // Apply pending summary if this entry has a parentUuid that matches a pending summary if (session.summary === 'New Session' && entry.parentUuid && pendingSummaries.has(entry.parentUuid)) { session.summary = pendingSummaries.get(entry.parentUuid); } // Update summary from summary entries with sessionId if (entry.type === 'summary' && entry.summary) { session.summary = entry.summary; } // Track last user and assistant messages (skip system messages) if (entry.message?.role === 'user' && entry.message?.content) { const content = entry.message.content; // Extract text from array format if needed let textContent = content; if (Array.isArray(content) && content.length > 0 && content[0].type === 'text') { textContent = content[0].text; } const isSystemMessage = typeof textContent === 'string' && ( textContent.startsWith('') || textContent.startsWith('') || textContent.startsWith('') || textContent.startsWith('') || textContent.startsWith('') || textContent.startsWith('Caveat:') || textContent.startsWith('This session is being continued from a previous') || textContent.startsWith('Invalid API key') || textContent.includes('{"subtasks":') || // Filter Task Master prompts textContent.includes('CRITICAL: You MUST respond with ONLY a JSON') || // Filter Task Master system prompts textContent === 'Warmup' // Explicitly filter out "Warmup" ); if (typeof textContent === 'string' && textContent.length > 0 && !isSystemMessage) { session.lastUserMessage = textContent; } } else if (entry.message?.role === 'assistant' && entry.message?.content) { // Skip API error messages using the isApiErrorMessage flag if (entry.isApiErrorMessage === true) { // Skip this message entirely } else { // Track last assistant text message let assistantText = null; if (Array.isArray(entry.message.content)) { for (const part of entry.message.content) { if (part.type === 'text' && part.text) { assistantText = part.text; } } } else if (typeof entry.message.content === 'string') { assistantText = entry.message.content; } // Additional filter for assistant messages with system content const isSystemAssistantMessage = typeof assistantText === 'string' && ( assistantText.startsWith('Invalid API key') || assistantText.includes('{"subtasks":') || assistantText.includes('CRITICAL: You MUST respond with ONLY a JSON') ); if (assistantText && !isSystemAssistantMessage) { session.lastAssistantMessage = assistantText; } } } session.messageCount++; if (entry.timestamp) { session.lastActivity = new Date(entry.timestamp); } } } catch (parseError) { // Skip malformed lines silently } } } // After processing all entries, set final summary based on last message if no summary exists for (const session of sessions.values()) { if (session.summary === 'New Session') { // Prefer last user message, fall back to last assistant message const lastMessage = session.lastUserMessage || session.lastAssistantMessage; if (lastMessage) { session.summary = lastMessage.length > 50 ? lastMessage.substring(0, 50) + '...' : lastMessage; } } } // Filter out sessions that contain JSON responses (Task Master errors) const allSessions = Array.from(sessions.values()); const filteredSessions = allSessions.filter(session => { const shouldFilter = session.summary.startsWith('{ "'); if (shouldFilter) { } // Log a sample of summaries to debug if (Math.random() < 0.01) { // Log 1% of sessions } return !shouldFilter; }); return { sessions: filteredSessions, entries: entries }; } catch (error) { console.error('Error reading JSONL file:', error); return { sessions: [], entries: [] }; } } // Parse an agent JSONL file and extract tool uses async function parseAgentTools(filePath) { const tools = []; try { const fileStream = fsSync.createReadStream(filePath); const rl = readline.createInterface({ input: fileStream, crlfDelay: Infinity }); for await (const line of rl) { if (line.trim()) { try { const entry = JSON.parse(line); // Look for assistant messages with tool_use if (entry.message?.role === 'assistant' && Array.isArray(entry.message?.content)) { for (const part of entry.message.content) { if (part.type === 'tool_use') { tools.push({ toolId: part.id, toolName: part.name, toolInput: part.input, timestamp: entry.timestamp }); } } } // Look for tool results if (entry.message?.role === 'user' && Array.isArray(entry.message?.content)) { for (const part of entry.message.content) { if (part.type === 'tool_result') { // Find the matching tool and add result const tool = tools.find(t => t.toolId === part.tool_use_id); if (tool) { tool.toolResult = { content: typeof part.content === 'string' ? part.content : Array.isArray(part.content) ? part.content.map(c => c.text || '').join('\n') : JSON.stringify(part.content), isError: Boolean(part.is_error) }; } } } } } catch (parseError) { // Skip malformed lines } } } } catch (error) { console.warn(`Error parsing agent file ${filePath}:`, error.message); } return tools; } // Get messages for a specific session with pagination support async function getSessionMessages(projectName, sessionId, limit = null, offset = 0) { const projectDir = path.join(os.homedir(), '.claude', 'projects', projectName); try { const files = await fs.readdir(projectDir); // agent-*.jsonl files contain subagent tool history - we'll process them separately const jsonlFiles = files.filter(file => file.endsWith('.jsonl') && !file.startsWith('agent-')); const agentFiles = files.filter(file => file.endsWith('.jsonl') && file.startsWith('agent-')); if (jsonlFiles.length === 0) { return { messages: [], total: 0, hasMore: false }; } const messages = []; // Map of agentId -> tools for subagent tool grouping const agentToolsCache = new Map(); // Process all JSONL files to find messages for this session for (const file of jsonlFiles) { const jsonlFile = path.join(projectDir, file); const fileStream = fsSync.createReadStream(jsonlFile); const rl = readline.createInterface({ input: fileStream, crlfDelay: Infinity }); for await (const line of rl) { if (line.trim()) { try { const entry = JSON.parse(line); if (entry.sessionId === sessionId) { messages.push(entry); } } catch (parseError) { // Silently skip malformed JSONL lines (common with concurrent writes) } } } } // Collect agentIds from Task tool results const agentIds = new Set(); for (const message of messages) { if (message.toolUseResult?.agentId) { agentIds.add(message.toolUseResult.agentId); } } // Load agent tools for each agentId found for (const agentId of agentIds) { const agentFileName = `agent-${agentId}.jsonl`; if (agentFiles.includes(agentFileName)) { const agentFilePath = path.join(projectDir, agentFileName); const tools = await parseAgentTools(agentFilePath); agentToolsCache.set(agentId, tools); } } // Attach agent tools to their parent Task messages for (const message of messages) { if (message.toolUseResult?.agentId) { const agentId = message.toolUseResult.agentId; const agentTools = agentToolsCache.get(agentId); if (agentTools && agentTools.length > 0) { message.subagentTools = agentTools; } } } // Sort messages by timestamp const sortedMessages = messages.sort((a, b) => new Date(a.timestamp || 0) - new Date(b.timestamp || 0) ); const total = sortedMessages.length; // If no limit is specified, return all messages (backward compatibility) if (limit === null) { return sortedMessages; } // Apply pagination - for recent messages, we need to slice from the end // offset 0 should give us the most recent messages const startIndex = Math.max(0, total - offset - limit); const endIndex = total - offset; const paginatedMessages = sortedMessages.slice(startIndex, endIndex); const hasMore = startIndex > 0; return { messages: paginatedMessages, total, hasMore, offset, limit }; } catch (error) { console.error(`Error reading messages for session ${sessionId}:`, error); return limit === null ? [] : { messages: [], total: 0, hasMore: false }; } } // Rename a project's display name async function renameProject(projectName, newDisplayName) { const config = await loadProjectConfig(); if (!newDisplayName || newDisplayName.trim() === '') { // Remove custom name if empty, will fall back to auto-generated if (config[projectName]) { delete config[projectName].displayName; } } else { // Set custom display name, preserving other properties (manuallyAdded, originalPath) config[projectName] = { ...config[projectName], displayName: newDisplayName.trim() }; } await saveProjectConfig(config); return true; } // Delete a session from a project async function deleteSession(projectName, sessionId) { const projectDir = path.join(os.homedir(), '.claude', 'projects', projectName); try { const files = await fs.readdir(projectDir); const jsonlFiles = files.filter(file => file.endsWith('.jsonl')); if (jsonlFiles.length === 0) { throw new Error('No session files found for this project'); } // Check all JSONL files to find which one contains the session for (const file of jsonlFiles) { const jsonlFile = path.join(projectDir, file); const content = await fs.readFile(jsonlFile, 'utf8'); const lines = content.split('\n').filter(line => line.trim()); // Check if this file contains the session const hasSession = lines.some(line => { try { const data = JSON.parse(line); return data.sessionId === sessionId; } catch { return false; } }); if (hasSession) { // Filter out all entries for this session const filteredLines = lines.filter(line => { try { const data = JSON.parse(line); return data.sessionId !== sessionId; } catch { return true; // Keep malformed lines } }); // Write back the filtered content await fs.writeFile(jsonlFile, filteredLines.join('\n') + (filteredLines.length > 0 ? '\n' : '')); return true; } } throw new Error(`Session ${sessionId} not found in any files`); } catch (error) { console.error(`Error deleting session ${sessionId} from project ${projectName}:`, error); throw error; } } // Check if a project is empty (has no sessions) async function isProjectEmpty(projectName) { try { const sessionsResult = await getSessions(projectName, 1, 0); return sessionsResult.total === 0; } catch (error) { console.error(`Error checking if project ${projectName} is empty:`, error); return 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 { const isEmpty = await isProjectEmpty(projectName); if (!isEmpty && !force) { throw new Error('Cannot delete project with existing sessions'); } const config = await loadProjectConfig(); let projectPath = config[projectName]?.path || config[projectName]?.originalPath; // 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); } // 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 } } // Remove from project config delete config[projectName]; await saveProjectConfig(config); return true; } catch (error) { console.error(`Error deleting project ${projectName}:`, error); throw error; } } // Add a project manually to the config (without creating folders) async function addProjectManually(projectPath, displayName = null) { const absolutePath = path.resolve(projectPath); try { // Check if the path exists await fs.access(absolutePath); } catch (error) { throw new Error(`Path does not exist: ${absolutePath}`); } // Generate project name (encode path for use as directory name) const projectName = absolutePath.replace(/[\\/:\s~_]/g, '-'); // Check if project already exists in config const config = await loadProjectConfig(); const projectDir = path.join(os.homedir(), '.claude', 'projects', projectName); if (config[projectName]) { throw new Error(`Project already configured for path: ${absolutePath}`); } // Allow adding projects even if the directory exists - this enables tracking // existing Claude Code or Cursor projects in the UI // Add to config as manually added project config[projectName] = { manuallyAdded: true, originalPath: absolutePath }; if (displayName) { config[projectName].displayName = displayName; } await saveProjectConfig(config); return { name: projectName, path: absolutePath, fullPath: absolutePath, displayName: displayName || await generateDisplayName(projectName, absolutePath), isManuallyAdded: true, sessions: [], cursorSessions: [] }; } // Fetch Cursor sessions for a given project path async function getCursorSessions(projectPath) { try { // Calculate cwdID hash for the project path (Cursor uses MD5 hash) const cwdId = crypto.createHash('md5').update(projectPath).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 []; } // 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'); try { // Check if store.db exists await fs.access(storeDbPath); // Capture store.db mtime as a reliable fallback timestamp let dbStatMtimeMs = null; try { const stat = await fs.stat(storeDbPath); dbStatMtimeMs = stat.mtimeMs; } catch (_) { } // Open SQLite database const db = await open({ filename: storeDbPath, driver: sqlite3.Database, mode: sqlite3.OPEN_READONLY }); // Get metadata from meta table const metaRows = await db.all(` SELECT key, value FROM meta `); // Parse metadata let metadata = {}; 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'); metadata[row.key] = JSON.parse(jsonStr); } else { metadata[row.key] = row.value.toString(); } } catch (e) { metadata[row.key] = row.value.toString(); } } } // Get message count const messageCountResult = await db.get(` SELECT COUNT(*) as count FROM blobs `); await db.close(); // Extract session info const sessionName = metadata.title || metadata.sessionTitle || 'Untitled Session'; // Determine timestamp - prefer createdAt from metadata, fall back to db file mtime let createdAt = null; if (metadata.createdAt) { createdAt = new Date(metadata.createdAt).toISOString(); } else if (dbStatMtimeMs) { createdAt = new Date(dbStatMtimeMs).toISOString(); } else { createdAt = new Date().toISOString(); } sessions.push({ id: sessionId, name: sessionName, createdAt: createdAt, lastActivity: createdAt, // For compatibility with Claude sessions messageCount: messageCountResult.count || 0, projectPath: projectPath }); } catch (error) { console.warn(`Could not read Cursor session ${sessionId}:`, error.message); } } // Sort sessions by creation time (newest first) sessions.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt)); // Return only the first 5 sessions for performance return sessions.slice(0, 5); } catch (error) { console.error('Error fetching Cursor sessions:', error); return []; } } function normalizeComparablePath(inputPath) { if (!inputPath || typeof inputPath !== 'string') { return ''; } const withoutLongPathPrefix = inputPath.startsWith('\\\\?\\') ? inputPath.slice(4) : inputPath; const normalized = path.normalize(withoutLongPathPrefix.trim()); if (!normalized) { return ''; } const resolved = path.resolve(normalized); return process.platform === 'win32' ? resolved.toLowerCase() : resolved; } async function findCodexJsonlFiles(dir) { const files = []; try { const entries = await fs.readdir(dir, { withFileTypes: true }); for (const entry of entries) { const fullPath = path.join(dir, entry.name); if (entry.isDirectory()) { files.push(...await findCodexJsonlFiles(fullPath)); } else if (entry.name.endsWith('.jsonl')) { files.push(fullPath); } } } catch (error) { // Skip directories we can't read } return files; } async function buildCodexSessionsIndex() { const codexSessionsDir = path.join(os.homedir(), '.codex', 'sessions'); const sessionsByProject = new Map(); try { await fs.access(codexSessionsDir); } catch (error) { return sessionsByProject; } const jsonlFiles = await findCodexJsonlFiles(codexSessionsDir); for (const filePath of jsonlFiles) { try { const sessionData = await parseCodexSessionFile(filePath); if (!sessionData || !sessionData.id) { continue; } const normalizedProjectPath = normalizeComparablePath(sessionData.cwd); if (!normalizedProjectPath) { continue; } const session = { id: sessionData.id, summary: sessionData.summary || 'Codex Session', messageCount: sessionData.messageCount || 0, lastActivity: sessionData.timestamp ? new Date(sessionData.timestamp) : new Date(), cwd: sessionData.cwd, model: sessionData.model, filePath, provider: 'codex', }; if (!sessionsByProject.has(normalizedProjectPath)) { sessionsByProject.set(normalizedProjectPath, []); } sessionsByProject.get(normalizedProjectPath).push(session); } catch (error) { console.warn(`Could not parse Codex session file ${filePath}:`, error.message); } } for (const sessions of sessionsByProject.values()) { sessions.sort((a, b) => new Date(b.lastActivity) - new Date(a.lastActivity)); } return sessionsByProject; } // Fetch Codex sessions for a given project path async function getCodexSessions(projectPath, options = {}) { const { limit = 5, indexRef = null } = options; try { const normalizedProjectPath = normalizeComparablePath(projectPath); if (!normalizedProjectPath) { return []; } if (indexRef && !indexRef.sessionsByProject) { indexRef.sessionsByProject = await buildCodexSessionsIndex(); } const sessionsByProject = indexRef?.sessionsByProject || await buildCodexSessionsIndex(); const sessions = sessionsByProject.get(normalizedProjectPath) || []; // Return limited sessions for performance (0 = unlimited for deletion) return limit > 0 ? sessions.slice(0, limit) : [...sessions]; } catch (error) { console.error('Error fetching Codex sessions:', error); return []; } } function isVisibleCodexUserMessage(payload) { if (!payload || payload.type !== 'user_message') { return false; } // Codex logs internal context (environment, instructions) as non-plain user_message kinds. if (payload.kind && payload.kind !== 'plain') { return false; } if (typeof payload.message !== 'string' || payload.message.trim().length === 0) { return false; } return true; } // Parse a Codex session JSONL file to extract metadata async function parseCodexSessionFile(filePath) { try { const fileStream = fsSync.createReadStream(filePath); const rl = readline.createInterface({ input: fileStream, crlfDelay: Infinity }); let sessionMeta = null; let lastTimestamp = null; let lastUserMessage = null; let messageCount = 0; for await (const line of rl) { if (line.trim()) { try { const entry = JSON.parse(line); // Track timestamp if (entry.timestamp) { lastTimestamp = entry.timestamp; } // Extract session metadata if (entry.type === 'session_meta' && entry.payload) { sessionMeta = { id: entry.payload.id, cwd: entry.payload.cwd, model: entry.payload.model || entry.payload.model_provider, timestamp: entry.timestamp, git: entry.payload.git }; } // Count visible user messages and extract summary from the latest plain user input. if (entry.type === 'event_msg' && isVisibleCodexUserMessage(entry.payload)) { messageCount++; if (entry.payload.message) { lastUserMessage = entry.payload.message; } } if (entry.type === 'response_item' && entry.payload?.type === 'message' && entry.payload.role === 'assistant') { messageCount++; } } catch (parseError) { // Skip malformed lines } } } if (sessionMeta) { return { ...sessionMeta, timestamp: lastTimestamp || sessionMeta.timestamp, summary: lastUserMessage ? (lastUserMessage.length > 50 ? lastUserMessage.substring(0, 50) + '...' : lastUserMessage) : 'Codex Session', messageCount }; } return null; } catch (error) { console.error('Error parsing Codex session file:', error); return null; } } // Get messages for a specific Codex session async function getCodexSessionMessages(sessionId, limit = null, offset = 0) { try { const codexSessionsDir = path.join(os.homedir(), '.codex', 'sessions'); // Find the session file by searching for the session ID const findSessionFile = async (dir) => { try { const entries = await fs.readdir(dir, { withFileTypes: true }); for (const entry of entries) { const fullPath = path.join(dir, entry.name); if (entry.isDirectory()) { const found = await findSessionFile(fullPath); if (found) return found; } else if (entry.name.includes(sessionId) && entry.name.endsWith('.jsonl')) { return fullPath; } } } catch (error) { // Skip directories we can't read } return null; }; const sessionFilePath = await findSessionFile(codexSessionsDir); if (!sessionFilePath) { console.warn(`Codex session file not found for session ${sessionId}`); return { messages: [], total: 0, hasMore: false }; } const messages = []; let tokenUsage = null; const fileStream = fsSync.createReadStream(sessionFilePath); const rl = readline.createInterface({ input: fileStream, crlfDelay: Infinity }); // Helper to extract text from Codex content array const extractText = (content) => { if (!Array.isArray(content)) return content; return content .map(item => { if (item.type === 'input_text' || item.type === 'output_text') { return item.text; } if (item.type === 'text') { return item.text; } return ''; }) .filter(Boolean) .join('\n'); }; for await (const line of rl) { if (line.trim()) { try { const entry = JSON.parse(line); // Extract token usage from token_count events (keep latest) if (entry.type === 'event_msg' && entry.payload?.type === 'token_count' && entry.payload?.info) { const info = entry.payload.info; if (info.total_token_usage) { tokenUsage = { used: info.total_token_usage.total_tokens || 0, total: info.model_context_window || 200000 }; } } // Use event_msg.user_message for user-visible inputs. if (entry.type === 'event_msg' && isVisibleCodexUserMessage(entry.payload)) { messages.push({ type: 'user', timestamp: entry.timestamp, message: { role: 'user', content: entry.payload.message } }); } // response_item.message may include internal prompts for non-assistant roles. // Keep only assistant output from response_item. if ( entry.type === 'response_item' && entry.payload?.type === 'message' && entry.payload.role === 'assistant' ) { const content = entry.payload.content; const textContent = extractText(content); // Only add if there's actual content if (textContent?.trim()) { messages.push({ type: 'assistant', timestamp: entry.timestamp, message: { role: 'assistant', content: textContent } }); } } if (entry.type === 'response_item' && entry.payload?.type === 'reasoning') { const summaryText = entry.payload.summary ?.map(s => s.text) .filter(Boolean) .join('\n'); if (summaryText?.trim()) { messages.push({ type: 'thinking', timestamp: entry.timestamp, message: { role: 'assistant', content: summaryText } }); } } if (entry.type === 'response_item' && entry.payload?.type === 'function_call') { let toolName = entry.payload.name; let toolInput = entry.payload.arguments; // Map Codex tool names to Claude equivalents if (toolName === 'shell_command') { toolName = 'Bash'; try { const args = JSON.parse(entry.payload.arguments); toolInput = JSON.stringify({ command: args.command }); } catch (e) { // Keep original if parsing fails } } messages.push({ type: 'tool_use', timestamp: entry.timestamp, toolName: toolName, toolInput: toolInput, toolCallId: entry.payload.call_id }); } if (entry.type === 'response_item' && entry.payload?.type === 'function_call_output') { messages.push({ type: 'tool_result', timestamp: entry.timestamp, toolCallId: entry.payload.call_id, output: entry.payload.output }); } if (entry.type === 'response_item' && entry.payload?.type === 'custom_tool_call') { const toolName = entry.payload.name || 'custom_tool'; const input = entry.payload.input || ''; if (toolName === 'apply_patch') { // Parse Codex patch format and convert to Claude Edit format const fileMatch = input.match(/\*\*\* Update File: (.+)/); const filePath = fileMatch ? fileMatch[1].trim() : 'unknown'; // Extract old and new content from patch const lines = input.split('\n'); const oldLines = []; const newLines = []; for (const line of lines) { if (line.startsWith('-') && !line.startsWith('---')) { oldLines.push(line.substring(1)); } else if (line.startsWith('+') && !line.startsWith('+++')) { newLines.push(line.substring(1)); } } messages.push({ type: 'tool_use', timestamp: entry.timestamp, toolName: 'Edit', toolInput: JSON.stringify({ file_path: filePath, old_string: oldLines.join('\n'), new_string: newLines.join('\n') }), toolCallId: entry.payload.call_id }); } else { messages.push({ type: 'tool_use', timestamp: entry.timestamp, toolName: toolName, toolInput: input, toolCallId: entry.payload.call_id }); } } if (entry.type === 'response_item' && entry.payload?.type === 'custom_tool_call_output') { messages.push({ type: 'tool_result', timestamp: entry.timestamp, toolCallId: entry.payload.call_id, output: entry.payload.output || '' }); } } catch (parseError) { // Skip malformed lines } } } // Sort by timestamp messages.sort((a, b) => new Date(a.timestamp || 0) - new Date(b.timestamp || 0)); const total = messages.length; // Apply pagination if limit is specified if (limit !== null) { const startIndex = Math.max(0, total - offset - limit); const endIndex = total - offset; const paginatedMessages = messages.slice(startIndex, endIndex); const hasMore = startIndex > 0; return { messages: paginatedMessages, total, hasMore, offset, limit, tokenUsage }; } return { messages, tokenUsage }; } catch (error) { console.error(`Error reading Codex session messages for ${sessionId}:`, error); return { messages: [], total: 0, hasMore: false }; } } async function deleteCodexSession(sessionId) { try { const codexSessionsDir = path.join(os.homedir(), '.codex', 'sessions'); const findJsonlFiles = async (dir) => { const files = []; try { const entries = await fs.readdir(dir, { withFileTypes: true }); for (const entry of entries) { const fullPath = path.join(dir, entry.name); if (entry.isDirectory()) { files.push(...await findJsonlFiles(fullPath)); } else if (entry.name.endsWith('.jsonl')) { files.push(fullPath); } } } catch (error) { } return files; }; const jsonlFiles = await findJsonlFiles(codexSessionsDir); for (const filePath of jsonlFiles) { const sessionData = await parseCodexSessionFile(filePath); if (sessionData && sessionData.id === sessionId) { await fs.unlink(filePath); return true; } } throw new Error(`Codex session file not found for session ${sessionId}`); } catch (error) { console.error(`Error deleting Codex session ${sessionId}:`, error); throw error; } } async function searchConversations(query, limit = 50, onProjectResult = null, signal = null) { const safeQuery = typeof query === 'string' ? query.trim() : ''; const safeLimit = Math.max(1, Math.min(Number.isFinite(limit) ? limit : 50, 200)); const claudeDir = path.join(os.homedir(), '.claude', 'projects'); const config = await loadProjectConfig(); const results = []; let totalMatches = 0; const words = safeQuery.toLowerCase().split(/\s+/).filter(w => w.length > 0); if (words.length === 0) return { results: [], totalMatches: 0, query: safeQuery }; const isAborted = () => signal?.aborted === true; const isSystemMessage = (textContent) => { return typeof textContent === 'string' && ( textContent.startsWith('') || textContent.startsWith('') || textContent.startsWith('') || textContent.startsWith('') || textContent.startsWith('') || textContent.startsWith('Caveat:') || textContent.startsWith('This session is being continued from a previous') || textContent.startsWith('Invalid API key') || textContent.includes('{"subtasks":') || textContent.includes('CRITICAL: You MUST respond with ONLY a JSON') || textContent === 'Warmup' ); }; const extractText = (content) => { if (typeof content === 'string') return content; if (Array.isArray(content)) { return content .filter(part => part.type === 'text' && part.text) .map(part => part.text) .join(' '); } return ''; }; const escapeRegex = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const wordPatterns = words.map(w => new RegExp(`(? { return wordPatterns.every(p => p.test(textLower)); }; const buildSnippet = (text, textLower, snippetLen = 150) => { let firstIndex = -1; let firstWordLen = 0; for (const w of words) { const re = new RegExp(`(? 0 ? '...' : ''; const suffix = end < text.length ? '...' : ''; snippet = prefix + snippet + suffix; const snippetLower = snippet.toLowerCase(); const highlights = []; for (const word of words) { const re = new RegExp(`(? a.start - b.start); const merged = []; for (const h of highlights) { const last = merged[merged.length - 1]; if (last && h.start <= last.end) { last.end = Math.max(last.end, h.end); } else { merged.push({ ...h }); } } return { snippet, highlights: merged }; }; try { await fs.access(claudeDir); const entries = await fs.readdir(claudeDir, { withFileTypes: true }); const projectDirs = entries.filter(e => e.isDirectory()); let scannedProjects = 0; const totalProjects = projectDirs.length; for (const projectEntry of projectDirs) { if (totalMatches >= safeLimit || isAborted()) break; const projectName = projectEntry.name; const projectDir = path.join(claudeDir, projectName); const displayName = config[projectName]?.displayName || await generateDisplayName(projectName); let files; try { files = await fs.readdir(projectDir); } catch { continue; } const jsonlFiles = files.filter( file => file.endsWith('.jsonl') && !file.startsWith('agent-') ); const projectResult = { projectName, projectDisplayName: displayName, sessions: [] }; for (const file of jsonlFiles) { if (totalMatches >= safeLimit || isAborted()) break; const filePath = path.join(projectDir, file); const sessionMatches = new Map(); const sessionSummaries = new Map(); const pendingSummaries = new Map(); const sessionLastMessages = new Map(); let currentSessionId = null; try { const fileStream = fsSync.createReadStream(filePath); const rl = readline.createInterface({ input: fileStream, crlfDelay: Infinity }); for await (const line of rl) { if (totalMatches >= safeLimit || isAborted()) break; if (!line.trim()) continue; let entry; try { entry = JSON.parse(line); } catch { continue; } if (entry.sessionId) { currentSessionId = entry.sessionId; } if (entry.type === 'summary' && entry.summary) { const sid = entry.sessionId || currentSessionId; if (sid) { sessionSummaries.set(sid, entry.summary); } else if (entry.leafUuid) { pendingSummaries.set(entry.leafUuid, entry.summary); } } // Apply pending summary via parentUuid if (entry.parentUuid && currentSessionId && !sessionSummaries.has(currentSessionId)) { const pending = pendingSummaries.get(entry.parentUuid); if (pending) sessionSummaries.set(currentSessionId, pending); } // Track last user/assistant message for fallback title if (entry.message?.content && currentSessionId && !entry.isApiErrorMessage) { const role = entry.message.role; if (role === 'user' || role === 'assistant') { const text = extractText(entry.message.content); if (text && !isSystemMessage(text)) { if (!sessionLastMessages.has(currentSessionId)) { sessionLastMessages.set(currentSessionId, {}); } const msgs = sessionLastMessages.get(currentSessionId); if (role === 'user') msgs.user = text; else msgs.assistant = text; } } } if (!entry.message?.content) continue; if (entry.message.role !== 'user' && entry.message.role !== 'assistant') continue; if (entry.isApiErrorMessage) continue; const text = extractText(entry.message.content); if (!text || isSystemMessage(text)) continue; const textLower = text.toLowerCase(); if (!allWordsMatch(textLower)) continue; const sessionId = entry.sessionId || currentSessionId || file.replace('.jsonl', ''); if (!sessionMatches.has(sessionId)) { sessionMatches.set(sessionId, []); } const matches = sessionMatches.get(sessionId); if (matches.length < 2) { const { snippet, highlights } = buildSnippet(text, textLower); matches.push({ role: entry.message.role, snippet, highlights, timestamp: entry.timestamp || null, provider: 'claude', messageUuid: entry.uuid || null }); totalMatches++; } } } catch { continue; } for (const [sessionId, matches] of sessionMatches) { projectResult.sessions.push({ sessionId, provider: 'claude', sessionSummary: sessionSummaries.get(sessionId) || (() => { const msgs = sessionLastMessages.get(sessionId); const lastMsg = msgs?.user || msgs?.assistant; return lastMsg ? (lastMsg.length > 50 ? lastMsg.substring(0, 50) + '...' : lastMsg) : 'New Session'; })(), matches }); } } // Search Codex sessions for this project try { const actualProjectDir = await extractProjectDirectory(projectName); if (actualProjectDir && !isAborted() && totalMatches < safeLimit) { await searchCodexSessionsForProject( actualProjectDir, projectResult, words, allWordsMatch, extractText, isSystemMessage, buildSnippet, safeLimit, () => totalMatches, (n) => { totalMatches += n; }, isAborted ); } } catch { // Skip codex search errors } // Search Gemini sessions for this project try { const actualProjectDir = await extractProjectDirectory(projectName); if (actualProjectDir && !isAborted() && totalMatches < safeLimit) { await searchGeminiSessionsForProject( actualProjectDir, projectResult, words, allWordsMatch, buildSnippet, safeLimit, () => totalMatches, (n) => { totalMatches += n; } ); } } catch { // Skip gemini search errors } scannedProjects++; if (projectResult.sessions.length > 0) { results.push(projectResult); if (onProjectResult) { onProjectResult({ projectResult, totalMatches, scannedProjects, totalProjects }); } } else if (onProjectResult && scannedProjects % 10 === 0) { onProjectResult({ projectResult: null, totalMatches, scannedProjects, totalProjects }); } } } catch { // claudeDir doesn't exist } return { results, totalMatches, query: safeQuery }; } async function searchCodexSessionsForProject( projectPath, projectResult, words, allWordsMatch, extractText, isSystemMessage, buildSnippet, limit, getTotalMatches, addMatches, isAborted ) { const normalizedProjectPath = normalizeComparablePath(projectPath); if (!normalizedProjectPath) return; const codexSessionsDir = path.join(os.homedir(), '.codex', 'sessions'); try { await fs.access(codexSessionsDir); } catch { return; } const jsonlFiles = await findCodexJsonlFiles(codexSessionsDir); for (const filePath of jsonlFiles) { if (getTotalMatches() >= limit || isAborted()) break; try { const fileStream = fsSync.createReadStream(filePath); const rl = readline.createInterface({ input: fileStream, crlfDelay: Infinity }); // First pass: read session_meta to check project path match let sessionMeta = null; for await (const line of rl) { if (!line.trim()) continue; try { const entry = JSON.parse(line); if (entry.type === 'session_meta' && entry.payload) { sessionMeta = entry.payload; break; } } catch { continue; } } // Skip sessions that don't belong to this project if (!sessionMeta) continue; const sessionProjectPath = normalizeComparablePath(sessionMeta.cwd); if (sessionProjectPath !== normalizedProjectPath) continue; // Second pass: re-read file to find matching messages const fileStream2 = fsSync.createReadStream(filePath); const rl2 = readline.createInterface({ input: fileStream2, crlfDelay: Infinity }); let lastUserMessage = null; const matches = []; for await (const line of rl2) { if (getTotalMatches() >= limit || isAborted()) break; if (!line.trim()) continue; let entry; try { entry = JSON.parse(line); } catch { continue; } let text = null; let role = null; if (entry.type === 'event_msg' && entry.payload?.type === 'user_message' && entry.payload.message) { text = entry.payload.message; role = 'user'; lastUserMessage = text; } else if (entry.type === 'response_item' && entry.payload?.type === 'message') { const contentParts = entry.payload.content || []; if (entry.payload.role === 'user') { text = contentParts .filter(p => p.type === 'input_text' && p.text) .map(p => p.text) .join(' '); role = 'user'; if (text) lastUserMessage = text; } else if (entry.payload.role === 'assistant') { text = contentParts .filter(p => p.type === 'output_text' && p.text) .map(p => p.text) .join(' '); role = 'assistant'; } } if (!text || !role) continue; const textLower = text.toLowerCase(); if (!allWordsMatch(textLower)) continue; if (matches.length < 2) { const { snippet, highlights } = buildSnippet(text, textLower); matches.push({ role, snippet, highlights, timestamp: entry.timestamp || null, provider: 'codex' }); addMatches(1); } } if (matches.length > 0) { projectResult.sessions.push({ sessionId: sessionMeta.id, provider: 'codex', sessionSummary: lastUserMessage ? (lastUserMessage.length > 50 ? lastUserMessage.substring(0, 50) + '...' : lastUserMessage) : 'Codex Session', matches }); } } catch { continue; } } } async function searchGeminiSessionsForProject( projectPath, projectResult, words, allWordsMatch, buildSnippet, limit, getTotalMatches, addMatches ) { // 1) Search in-memory sessions (created via UI) for (const [sessionId, session] of sessionManager.sessions) { if (getTotalMatches() >= limit) break; if (session.projectPath !== projectPath) continue; const matches = []; for (const msg of session.messages) { if (getTotalMatches() >= limit) break; if (msg.role !== 'user' && msg.role !== 'assistant') continue; const text = typeof msg.content === 'string' ? msg.content : Array.isArray(msg.content) ? msg.content.filter(p => p.type === 'text').map(p => p.text).join(' ') : ''; if (!text) continue; const textLower = text.toLowerCase(); if (!allWordsMatch(textLower)) continue; if (matches.length < 2) { const { snippet, highlights } = buildSnippet(text, textLower); matches.push({ role: msg.role, snippet, highlights, timestamp: msg.timestamp ? msg.timestamp.toISOString() : null, provider: 'gemini' }); addMatches(1); } } if (matches.length > 0) { const firstUserMsg = session.messages.find(m => m.role === 'user'); const summary = firstUserMsg?.content ? (typeof firstUserMsg.content === 'string' ? (firstUserMsg.content.length > 50 ? firstUserMsg.content.substring(0, 50) + '...' : firstUserMsg.content) : 'Gemini Session') : 'Gemini Session'; projectResult.sessions.push({ sessionId, provider: 'gemini', sessionSummary: summary, matches }); } } // 2) Search Gemini CLI sessions on disk (~/.gemini/tmp//chats/*.json) const normalizedProjectPath = normalizeComparablePath(projectPath); if (!normalizedProjectPath) return; const geminiTmpDir = path.join(os.homedir(), '.gemini', 'tmp'); try { await fs.access(geminiTmpDir); } catch { return; } const trackedSessionIds = new Set(); for (const [sid] of sessionManager.sessions) { trackedSessionIds.add(sid); } let projectDirs; try { projectDirs = await fs.readdir(geminiTmpDir); } catch { return; } for (const projectDir of projectDirs) { if (getTotalMatches() >= limit) break; const projectRootFile = path.join(geminiTmpDir, projectDir, '.project_root'); let projectRoot; try { projectRoot = (await fs.readFile(projectRootFile, 'utf8')).trim(); } catch { continue; } if (normalizeComparablePath(projectRoot) !== normalizedProjectPath) continue; const chatsDir = path.join(geminiTmpDir, projectDir, 'chats'); let chatFiles; try { chatFiles = await fs.readdir(chatsDir); } catch { continue; } for (const chatFile of chatFiles) { if (getTotalMatches() >= limit) break; if (!chatFile.endsWith('.json')) continue; try { const filePath = path.join(chatsDir, chatFile); const data = await fs.readFile(filePath, 'utf8'); const session = JSON.parse(data); if (!session.messages || !Array.isArray(session.messages)) continue; const cliSessionId = session.sessionId || chatFile.replace('.json', ''); if (trackedSessionIds.has(cliSessionId)) continue; const matches = []; let firstUserText = null; for (const msg of session.messages) { if (getTotalMatches() >= limit) break; const role = msg.type === 'user' ? 'user' : (msg.type === 'gemini' || msg.type === 'assistant') ? 'assistant' : null; if (!role) continue; let text = ''; if (typeof msg.content === 'string') { text = msg.content; } else if (Array.isArray(msg.content)) { text = msg.content .filter(p => p.text) .map(p => p.text) .join(' '); } if (!text) continue; if (role === 'user' && !firstUserText) firstUserText = text; const textLower = text.toLowerCase(); if (!allWordsMatch(textLower)) continue; if (matches.length < 2) { const { snippet, highlights } = buildSnippet(text, textLower); matches.push({ role, snippet, highlights, timestamp: msg.timestamp || null, provider: 'gemini' }); addMatches(1); } } if (matches.length > 0) { const summary = firstUserText ? (firstUserText.length > 50 ? firstUserText.substring(0, 50) + '...' : firstUserText) : 'Gemini CLI Session'; projectResult.sessions.push({ sessionId: cliSessionId, provider: 'gemini', sessionSummary: summary, matches }); } } catch { continue; } } } } async function getGeminiCliSessions(projectPath) { const normalizedProjectPath = normalizeComparablePath(projectPath); if (!normalizedProjectPath) return []; const geminiTmpDir = path.join(os.homedir(), '.gemini', 'tmp'); try { await fs.access(geminiTmpDir); } catch { return []; } const sessions = []; let projectDirs; try { projectDirs = await fs.readdir(geminiTmpDir); } catch { return []; } for (const projectDir of projectDirs) { const projectRootFile = path.join(geminiTmpDir, projectDir, '.project_root'); let projectRoot; try { projectRoot = (await fs.readFile(projectRootFile, 'utf8')).trim(); } catch { continue; } if (normalizeComparablePath(projectRoot) !== normalizedProjectPath) continue; const chatsDir = path.join(geminiTmpDir, projectDir, 'chats'); let chatFiles; try { chatFiles = await fs.readdir(chatsDir); } catch { continue; } for (const chatFile of chatFiles) { if (!chatFile.endsWith('.json')) continue; try { const filePath = path.join(chatsDir, chatFile); const data = await fs.readFile(filePath, 'utf8'); const session = JSON.parse(data); if (!session.messages || !Array.isArray(session.messages)) continue; const sessionId = session.sessionId || chatFile.replace('.json', ''); const firstUserMsg = session.messages.find(m => m.type === 'user'); let summary = 'Gemini CLI Session'; if (firstUserMsg) { const text = Array.isArray(firstUserMsg.content) ? firstUserMsg.content.filter(p => p.text).map(p => p.text).join(' ') : (typeof firstUserMsg.content === 'string' ? firstUserMsg.content : ''); if (text) { summary = text.length > 50 ? text.substring(0, 50) + '...' : text; } } sessions.push({ id: sessionId, summary, messageCount: session.messages.length, lastActivity: session.lastUpdated || session.startTime || null, provider: 'gemini' }); } catch { continue; } } } return sessions.sort((a, b) => new Date(b.lastActivity || 0) - new Date(a.lastActivity || 0) ); } async function getGeminiCliSessionMessages(sessionId) { const geminiTmpDir = path.join(os.homedir(), '.gemini', 'tmp'); let projectDirs; try { projectDirs = await fs.readdir(geminiTmpDir); } catch { return []; } for (const projectDir of projectDirs) { const chatsDir = path.join(geminiTmpDir, projectDir, 'chats'); let chatFiles; try { chatFiles = await fs.readdir(chatsDir); } catch { continue; } for (const chatFile of chatFiles) { if (!chatFile.endsWith('.json')) continue; try { const filePath = path.join(chatsDir, chatFile); const data = await fs.readFile(filePath, 'utf8'); const session = JSON.parse(data); const fileSessionId = session.sessionId || chatFile.replace('.json', ''); if (fileSessionId !== sessionId) continue; return (session.messages || []).map(msg => { const role = msg.type === 'user' ? 'user' : (msg.type === 'gemini' || msg.type === 'assistant') ? 'assistant' : msg.type; let content = ''; if (typeof msg.content === 'string') { content = msg.content; } else if (Array.isArray(msg.content)) { content = msg.content.filter(p => p.text).map(p => p.text).join('\n'); } return { type: 'message', message: { role, content }, timestamp: msg.timestamp || null }; }); } catch { continue; } } } return []; } export { getProjects, getSessions, getSessionMessages, parseJsonlSessions, renameProject, deleteSession, isProjectEmpty, deleteProject, addProjectManually, loadProjectConfig, saveProjectConfig, extractProjectDirectory, clearProjectDirectoryCache, getCodexSessions, getCodexSessionMessages, deleteCodexSession, getGeminiCliSessions, getGeminiCliSessionMessages, searchConversations }; ================================================ FILE: server/providers/claude/adapter.js ================================================ /** * 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, }; }, }; ================================================ FILE: server/providers/codex/adapter.js ================================================ /** * 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, }; }, }; ================================================ FILE: server/providers/cursor/adapter.js ================================================ /** * 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>} */ async function loadCursorBlobs(sessionId, projectPath) { // Lazy-import sqlite so the module doesn't fail if sqlite3 is unavailable const { default: sqlite3 } = await import('sqlite3'); 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'); 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 { await 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; }, }; ================================================ FILE: server/providers/gemini/adapter.js ================================================ /** * 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, }; }, }; ================================================ FILE: server/providers/registry.js ================================================ /** * Provider Registry * * Centralizes provider adapter lookup. All code that needs a provider adapter * should go through this registry instead of importing individual adapters 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'; /** * @typedef {import('./types.js').ProviderAdapter} ProviderAdapter * @typedef {import('./types.js').SessionProvider} SessionProvider */ /** @type {Map} */ const providers = new Map(); // Register built-in providers providers.set('claude', claudeAdapter); providers.set('cursor', cursorAdapter); providers.set('codex', codexAdapter); providers.set('gemini', geminiAdapter); /** * 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 all registered provider names. * @returns {string[]} */ export function getAllProviders() { return Array.from(providers.keys()); } ================================================ FILE: server/providers/types.js ================================================ /** * 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 Adapter Interface ────────────────────────────────────────────── /** * Every provider adapter MUST implement this interface. * * @typedef {Object} ProviderAdapter * * @property {(sessionId: string, opts?: FetchHistoryOptions) => Promise} 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 & {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, }; } ================================================ FILE: server/providers/utils.js ================================================ /** * 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([ '', '', '', '', '', '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)); } ================================================ FILE: server/routes/agent.js ================================================ import express from 'express'; import { spawn } from 'child_process'; import path from 'path'; import os from 'os'; import { promises as fs } from 'fs'; import crypto from 'crypto'; import { userDb, apiKeysDb, githubTokensDb } from '../database/db.js'; import { addProjectManually } from '../projects.js'; import { queryClaudeSDK } from '../claude-sdk.js'; import { spawnCursor } from '../cursor-cli.js'; import { queryCodex } from '../openai-codex.js'; import { spawnGemini } from '../gemini-cli.js'; import { Octokit } from '@octokit/rest'; import { CLAUDE_MODELS, CURSOR_MODELS, CODEX_MODELS } from '../../shared/modelConstants.js'; import { IS_PLATFORM } from '../constants/config.js'; const router = express.Router(); /** * Middleware to authenticate agent API requests. * * Supports two authentication modes: * 1. Platform mode (IS_PLATFORM=true): For managed/hosted deployments where * authentication is handled by an external proxy. Requests are trusted and * the default user context is used. * * 2. API key mode (default): For self-hosted deployments where users authenticate * via API keys created in the UI. Keys are validated against the local database. */ const validateExternalApiKey = (req, res, next) => { // Platform mode: Authentication is handled externally (e.g., by a proxy layer). // Trust the request and use the default user context. if (IS_PLATFORM) { try { const user = userDb.getFirstUser(); if (!user) { return res.status(500).json({ error: 'Platform mode: No user found in database' }); } req.user = user; return next(); } catch (error) { console.error('Platform mode error:', error); return res.status(500).json({ error: 'Platform mode: Failed to fetch user' }); } } // Self-hosted mode: Validate API key from header or query parameter const apiKey = req.headers['x-api-key'] || req.query.apiKey; if (!apiKey) { return res.status(401).json({ error: 'API key required' }); } const user = apiKeysDb.validateApiKey(apiKey); if (!user) { return res.status(401).json({ error: 'Invalid or inactive API key' }); } req.user = user; next(); }; /** * Get the remote URL of a git repository * @param {string} repoPath - Path to the git repository * @returns {Promise} - Remote URL of the repository */ async function getGitRemoteUrl(repoPath) { return new Promise((resolve, reject) => { const gitProcess = spawn('git', ['config', '--get', 'remote.origin.url'], { cwd: repoPath, stdio: ['pipe', 'pipe', 'pipe'] }); let stdout = ''; let stderr = ''; gitProcess.stdout.on('data', (data) => { stdout += data.toString(); }); gitProcess.stderr.on('data', (data) => { stderr += data.toString(); }); gitProcess.on('close', (code) => { if (code === 0) { resolve(stdout.trim()); } else { reject(new Error(`Failed to get git remote: ${stderr}`)); } }); gitProcess.on('error', (error) => { reject(new Error(`Failed to execute git: ${error.message}`)); }); }); } /** * Normalize GitHub URLs for comparison * @param {string} url - GitHub URL * @returns {string} - Normalized URL */ function normalizeGitHubUrl(url) { // Remove .git suffix let normalized = url.replace(/\.git$/, ''); // Convert SSH to HTTPS format for comparison normalized = normalized.replace(/^git@github\.com:/, 'https://github.com/'); // Remove trailing slash normalized = normalized.replace(/\/$/, ''); return normalized.toLowerCase(); } /** * Parse GitHub URL to extract owner and repo * @param {string} url - GitHub URL (HTTPS or SSH) * @returns {{owner: string, repo: string}} - Parsed owner and repo */ function parseGitHubUrl(url) { // Handle HTTPS URLs: https://github.com/owner/repo or https://github.com/owner/repo.git // Handle SSH URLs: git@github.com:owner/repo or git@github.com:owner/repo.git const match = url.match(/github\.com[:/]([^/]+)\/([^/]+?)(?:\.git)?$/); if (!match) { throw new Error('Invalid GitHub URL format'); } return { owner: match[1], repo: match[2].replace(/\.git$/, '') }; } /** * Auto-generate a branch name from a message * @param {string} message - The agent message * @returns {string} - Generated branch name */ function autogenerateBranchName(message) { // Convert to lowercase, replace spaces/special chars with hyphens let branchName = message .toLowerCase() .replace(/[^a-z0-9\s-]/g, '') // Remove special characters .replace(/\s+/g, '-') // Replace spaces with hyphens .replace(/-+/g, '-') // Replace multiple hyphens with single .replace(/^-|-$/g, ''); // Remove leading/trailing hyphens // Ensure non-empty fallback if (!branchName) { branchName = 'task'; } // Generate timestamp suffix (last 6 chars of base36 timestamp) const timestamp = Date.now().toString(36).slice(-6); const suffix = `-${timestamp}`; // Limit length to ensure total length including suffix fits within 50 characters const maxBaseLength = 50 - suffix.length; if (branchName.length > maxBaseLength) { branchName = branchName.substring(0, maxBaseLength); } // Remove any trailing hyphen after truncation and ensure no leading hyphen branchName = branchName.replace(/-$/, '').replace(/^-+/, ''); // If still empty or starts with hyphen after cleanup, use fallback if (!branchName || branchName.startsWith('-')) { branchName = 'task'; } // Combine base name with timestamp suffix branchName = `${branchName}${suffix}`; // Final validation: ensure it matches safe pattern if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(branchName)) { // Fallback to deterministic safe name return `branch-${timestamp}`; } return branchName; } /** * Validate a Git branch name * @param {string} branchName - Branch name to validate * @returns {{valid: boolean, error?: string}} - Validation result */ function validateBranchName(branchName) { if (!branchName || branchName.trim() === '') { return { valid: false, error: 'Branch name cannot be empty' }; } // Git branch name rules const invalidPatterns = [ { pattern: /^\./, message: 'Branch name cannot start with a dot' }, { pattern: /\.$/, message: 'Branch name cannot end with a dot' }, { pattern: /\.\./, message: 'Branch name cannot contain consecutive dots (..)' }, { pattern: /\s/, message: 'Branch name cannot contain spaces' }, { pattern: /[~^:?*\[\\]/, message: 'Branch name cannot contain special characters: ~ ^ : ? * [ \\' }, { pattern: /@{/, message: 'Branch name cannot contain @{' }, { pattern: /\/$/, message: 'Branch name cannot end with a slash' }, { pattern: /^\//, message: 'Branch name cannot start with a slash' }, { pattern: /\/\//, message: 'Branch name cannot contain consecutive slashes' }, { pattern: /\.lock$/, message: 'Branch name cannot end with .lock' } ]; for (const { pattern, message } of invalidPatterns) { if (pattern.test(branchName)) { return { valid: false, error: message }; } } // Check for ASCII control characters if (/[\x00-\x1F\x7F]/.test(branchName)) { return { valid: false, error: 'Branch name cannot contain control characters' }; } return { valid: true }; } /** * Get recent commit messages from a repository * @param {string} projectPath - Path to the git repository * @param {number} limit - Number of commits to retrieve (default: 5) * @returns {Promise} - Array of commit messages */ async function getCommitMessages(projectPath, limit = 5) { return new Promise((resolve, reject) => { const gitProcess = spawn('git', ['log', `-${limit}`, '--pretty=format:%s'], { cwd: projectPath, stdio: ['pipe', 'pipe', 'pipe'] }); let stdout = ''; let stderr = ''; gitProcess.stdout.on('data', (data) => { stdout += data.toString(); }); gitProcess.stderr.on('data', (data) => { stderr += data.toString(); }); gitProcess.on('close', (code) => { if (code === 0) { const messages = stdout.trim().split('\n').filter(msg => msg.length > 0); resolve(messages); } else { reject(new Error(`Failed to get commit messages: ${stderr}`)); } }); gitProcess.on('error', (error) => { reject(new Error(`Failed to execute git: ${error.message}`)); }); }); } /** * Create a new branch on GitHub using the API * @param {Octokit} octokit - Octokit instance * @param {string} owner - Repository owner * @param {string} repo - Repository name * @param {string} branchName - Name of the new branch * @param {string} baseBranch - Base branch to branch from (default: 'main') * @returns {Promise} */ async function createGitHubBranch(octokit, owner, repo, branchName, baseBranch = 'main') { try { // Get the SHA of the base branch const { data: ref } = await octokit.git.getRef({ owner, repo, ref: `heads/${baseBranch}` }); const baseSha = ref.object.sha; // Create the new branch await octokit.git.createRef({ owner, repo, ref: `refs/heads/${branchName}`, sha: baseSha }); console.log(`✅ Created branch '${branchName}' on GitHub`); } catch (error) { if (error.status === 422 && error.message.includes('Reference already exists')) { console.log(`ℹ️ Branch '${branchName}' already exists on GitHub`); } else { throw error; } } } /** * Create a pull request on GitHub * @param {Octokit} octokit - Octokit instance * @param {string} owner - Repository owner * @param {string} repo - Repository name * @param {string} branchName - Head branch name * @param {string} title - PR title * @param {string} body - PR body/description * @param {string} baseBranch - Base branch (default: 'main') * @returns {Promise<{number: number, url: string}>} - PR number and URL */ async function createGitHubPR(octokit, owner, repo, branchName, title, body, baseBranch = 'main') { const { data: pr } = await octokit.pulls.create({ owner, repo, title, head: branchName, base: baseBranch, body }); console.log(`✅ Created pull request #${pr.number}: ${pr.html_url}`); return { number: pr.number, url: pr.html_url }; } /** * Clone a GitHub repository to a directory * @param {string} githubUrl - GitHub repository URL * @param {string} githubToken - Optional GitHub token for private repos * @param {string} projectPath - Path for cloning the repository * @returns {Promise} - Path to the cloned repository */ async function cloneGitHubRepo(githubUrl, githubToken = null, projectPath) { return new Promise(async (resolve, reject) => { try { // Validate GitHub URL if (!githubUrl || !githubUrl.includes('github.com')) { throw new Error('Invalid GitHub URL'); } const cloneDir = path.resolve(projectPath); // Check if directory already exists try { await fs.access(cloneDir); // Directory exists - check if it's a git repo with the same URL try { const existingUrl = await getGitRemoteUrl(cloneDir); const normalizedExisting = normalizeGitHubUrl(existingUrl); const normalizedRequested = normalizeGitHubUrl(githubUrl); if (normalizedExisting === normalizedRequested) { console.log('✅ Repository already exists at path with correct URL'); return resolve(cloneDir); } else { throw new Error(`Directory ${cloneDir} already exists with a different repository (${existingUrl}). Expected: ${githubUrl}`); } } catch (gitError) { throw new Error(`Directory ${cloneDir} already exists but is not a valid git repository or git command failed`); } } catch (accessError) { // Directory doesn't exist - proceed with clone } // Ensure parent directory exists await fs.mkdir(path.dirname(cloneDir), { recursive: true }); // Prepare the git clone URL with authentication if token is provided let cloneUrl = githubUrl; if (githubToken) { // Convert HTTPS URL to authenticated URL // Example: https://github.com/user/repo -> https://token@github.com/user/repo cloneUrl = githubUrl.replace('https://github.com', `https://${githubToken}@github.com`); } console.log('🔄 Cloning repository:', githubUrl); console.log('📁 Destination:', cloneDir); // Execute git clone const gitProcess = spawn('git', ['clone', '--depth', '1', cloneUrl, cloneDir], { stdio: ['pipe', 'pipe', 'pipe'] }); let stdout = ''; let stderr = ''; gitProcess.stdout.on('data', (data) => { stdout += data.toString(); }); gitProcess.stderr.on('data', (data) => { stderr += data.toString(); console.log('Git stderr:', data.toString()); }); gitProcess.on('close', (code) => { if (code === 0) { console.log('✅ Repository cloned successfully'); resolve(cloneDir); } else { console.error('❌ Git clone failed:', stderr); reject(new Error(`Git clone failed: ${stderr}`)); } }); gitProcess.on('error', (error) => { reject(new Error(`Failed to execute git: ${error.message}`)); }); } catch (error) { reject(error); } }); } /** * Clean up a temporary project directory and its Claude session * @param {string} projectPath - Path to the project directory * @param {string} sessionId - Session ID to clean up */ async function cleanupProject(projectPath, sessionId = null) { try { // Only clean up projects in the external-projects directory if (!projectPath.includes('.claude/external-projects')) { console.warn('⚠️ Refusing to clean up non-external project:', projectPath); return; } console.log('🧹 Cleaning up project:', projectPath); await fs.rm(projectPath, { recursive: true, force: true }); console.log('✅ Project cleaned up'); // Also clean up the Claude session directory if sessionId provided if (sessionId) { try { const sessionPath = path.join(os.homedir(), '.claude', 'sessions', sessionId); console.log('🧹 Cleaning up session directory:', sessionPath); await fs.rm(sessionPath, { recursive: true, force: true }); console.log('✅ Session directory cleaned up'); } catch (error) { console.error('⚠️ Failed to clean up session directory:', error.message); } } } catch (error) { console.error('❌ Failed to clean up project:', error); } } /** * SSE Stream Writer - Adapts SDK/CLI output to Server-Sent Events */ class SSEStreamWriter { constructor(res, userId = null) { this.res = res; this.sessionId = null; this.userId = userId; this.isSSEStreamWriter = true; // Marker for transport detection } send(data) { if (this.res.writableEnded) { return; } // Format as SSE - providers send raw objects, we stringify this.res.write(`data: ${JSON.stringify(data)}\n\n`); } end() { if (!this.res.writableEnded) { this.res.write('data: {"type":"done"}\n\n'); this.res.end(); } } setSessionId(sessionId) { this.sessionId = sessionId; } getSessionId() { return this.sessionId; } } /** * Non-streaming response collector */ class ResponseCollector { constructor(userId = null) { this.messages = []; this.sessionId = null; this.userId = userId; } send(data) { // Store ALL messages for now - we'll filter when returning this.messages.push(data); // Extract sessionId if present if (typeof data === 'string') { try { const parsed = JSON.parse(data); if (parsed.sessionId) { this.sessionId = parsed.sessionId; } } catch (e) { // Not JSON, ignore } } else if (data && data.sessionId) { this.sessionId = data.sessionId; } } end() { // Do nothing - we'll collect all messages } setSessionId(sessionId) { this.sessionId = sessionId; } getSessionId() { return this.sessionId; } getMessages() { return this.messages; } /** * Get filtered assistant messages only */ getAssistantMessages() { const assistantMessages = []; for (const msg of this.messages) { // Skip initial status message if (msg && msg.type === 'status') { continue; } // Handle JSON strings if (typeof msg === 'string') { try { const parsed = JSON.parse(msg); // Only include claude-response messages with assistant type if (parsed.type === 'claude-response' && parsed.data && parsed.data.type === 'assistant') { assistantMessages.push(parsed.data); } } catch (e) { // Not JSON, skip } } } return assistantMessages; } /** * Calculate total tokens from all messages */ getTotalTokens() { let totalInput = 0; let totalOutput = 0; let totalCacheRead = 0; let totalCacheCreation = 0; for (const msg of this.messages) { let data = msg; // Parse if string if (typeof msg === 'string') { try { data = JSON.parse(msg); } catch (e) { continue; } } // Extract usage from claude-response messages if (data && data.type === 'claude-response' && data.data) { const msgData = data.data; if (msgData.message && msgData.message.usage) { const usage = msgData.message.usage; totalInput += usage.input_tokens || 0; totalOutput += usage.output_tokens || 0; totalCacheRead += usage.cache_read_input_tokens || 0; totalCacheCreation += usage.cache_creation_input_tokens || 0; } } } return { inputTokens: totalInput, outputTokens: totalOutput, cacheReadTokens: totalCacheRead, cacheCreationTokens: totalCacheCreation, totalTokens: totalInput + totalOutput + totalCacheRead + totalCacheCreation }; } } // =============================== // External API Endpoint // =============================== /** * POST /api/agent * * Trigger an AI agent (Claude or Cursor) to work on a project. * Supports automatic GitHub branch and pull request creation after successful completion. * * ================================================================================================ * REQUEST BODY PARAMETERS * ================================================================================================ * * @param {string} githubUrl - (Conditionally Required) GitHub repository URL to clone. * Supported formats: * - HTTPS: https://github.com/owner/repo * - HTTPS with .git: https://github.com/owner/repo.git * - SSH: git@github.com:owner/repo * - SSH with .git: git@github.com:owner/repo.git * * @param {string} projectPath - (Conditionally Required) Path to existing project OR destination for cloning. * Behavior depends on usage: * - If used alone: Must point to existing project directory * - If used with githubUrl: Target location for cloning * - If omitted with githubUrl: Auto-generates temporary path in ~/.claude/external-projects/ * * @param {string} message - (Required) Task description for the AI agent. Used as: * - Instructions for the agent * - Source for auto-generated branch names (if createBranch=true and no branchName) * - Fallback for PR title if no commits are made * * @param {string} provider - (Optional) AI provider to use. Options: 'claude' | 'cursor' | 'codex' | 'gemini' * Default: 'claude' * * @param {boolean} stream - (Optional) Enable Server-Sent Events (SSE) streaming for real-time updates. * Default: true * - true: Returns text/event-stream with incremental updates * - false: Returns complete JSON response after completion * * @param {string} model - (Optional) Model identifier for providers. * * Claude models: 'sonnet' (default), 'opus', 'haiku', 'opusplan', 'sonnet[1m]' * Cursor models: 'gpt-5' (default), 'gpt-5.2', 'gpt-5.2-high', 'sonnet-4.5', 'opus-4.5', * 'gemini-3-pro', 'composer-1', 'auto', 'gpt-5.1', 'gpt-5.1-high', * 'gpt-5.1-codex', 'gpt-5.1-codex-high', 'gpt-5.1-codex-max', * 'gpt-5.1-codex-max-high', 'opus-4.1', 'grok', and thinking variants * Codex models: 'gpt-5.2' (default), 'gpt-5.1-codex-max', 'o3', 'o4-mini' * * @param {boolean} cleanup - (Optional) Auto-cleanup project directory after completion. * Default: true * Behavior: * - Only applies when cloning via githubUrl (not for existing projectPath) * - Deletes cloned repository after 5 seconds * - Also deletes associated Claude session directory * - Remote branch and PR remain on GitHub if created * * @param {string} githubToken - (Optional) GitHub Personal Access Token for authentication. * Overrides stored token from user settings. * Required for: * - Private repositories * - Branch/PR creation features * Token must have 'repo' scope for full functionality. * * @param {string} branchName - (Optional) Custom name for the Git branch. * If provided, createBranch is automatically set to true. * Validation rules (errors returned if violated): * - Cannot be empty or whitespace only * - Cannot start or end with dot (.) * - Cannot contain consecutive dots (..) * - Cannot contain spaces * - Cannot contain special characters: ~ ^ : ? * [ \ * - Cannot contain @{ * - Cannot start or end with forward slash (/) * - Cannot contain consecutive slashes (//) * - Cannot end with .lock * - Cannot contain ASCII control characters * Examples: 'feature/user-auth', 'bugfix/login-error', 'refactor/db-optimization' * * @param {boolean} createBranch - (Optional) Create a new Git branch after successful agent completion. * Default: false (or true if branchName is provided) * Behavior: * - Creates branch locally and pushes to remote * - If branch exists locally: Checks out existing branch (no error) * - If branch exists on remote: Uses existing branch (no error) * - Branch name: Custom (if branchName provided) or auto-generated from message * - Requires either githubUrl OR projectPath with GitHub remote * * @param {boolean} createPR - (Optional) Create a GitHub Pull Request after successful completion. * Default: false * Behavior: * - PR title: First commit message (or fallback to message parameter) * - PR description: Auto-generated from all commit messages * - Base branch: Always 'main' (currently hardcoded) * - If PR already exists: GitHub returns error with details * - Requires either githubUrl OR projectPath with GitHub remote * * ================================================================================================ * PATH HANDLING BEHAVIOR * ================================================================================================ * * Scenario 1: Only githubUrl provided * Input: { githubUrl: "https://github.com/owner/repo" } * Action: Clones to auto-generated temporary path: ~/.claude/external-projects// * Cleanup: Yes (if cleanup=true) * * Scenario 2: Only projectPath provided * Input: { projectPath: "/home/user/my-project" } * Action: Uses existing project at specified path * Validation: Path must exist and be accessible * Cleanup: No (never cleanup existing projects) * * Scenario 3: Both githubUrl and projectPath provided * Input: { githubUrl: "https://github.com/owner/repo", projectPath: "/custom/path" } * Action: Clones githubUrl to projectPath location * Validation: * - If projectPath exists with git repo: * - Compares remote URL with githubUrl * - If URLs match: Reuses existing repo * - If URLs differ: Returns error * Cleanup: Yes (if cleanup=true) * * ================================================================================================ * GITHUB BRANCH/PR CREATION REQUIREMENTS * ================================================================================================ * * For createBranch or createPR to work, one of the following must be true: * * Option A: githubUrl provided * - Repository URL directly specified * - Works with both cloning and existing paths * * Option B: projectPath with GitHub remote * - Project must be a Git repository * - Must have 'origin' remote configured * - Remote URL must point to github.com * - System auto-detects GitHub URL via: git remote get-url origin * * Additional Requirements: * - Valid GitHub token (from settings or githubToken parameter) * - Token must have 'repo' scope for private repos * - Project must have commits (for PR creation) * * ================================================================================================ * VALIDATION & ERROR HANDLING * ================================================================================================ * * Input Validations (400 Bad Request): * - Either githubUrl OR projectPath must be provided (not neither) * - message must be non-empty string * - provider must be 'claude', 'cursor', 'codex', or 'gemini' * - createBranch/createPR requires githubUrl OR projectPath (not neither) * - branchName must pass Git naming rules (if provided) * * Runtime Validations (500 Internal Server Error or specific error in response): * - projectPath must exist (if used alone) * - GitHub URL format must be valid * - Git remote URL must include github.com (for projectPath + branch/PR) * - GitHub token must be available (for private repos and branch/PR) * - Directory conflicts handled (existing path with different repo) * * Branch Name Validation Errors (returned in response, not HTTP error): * Invalid names return: { branch: { error: "Invalid branch name: " } } * Examples: * - "my branch" → "Branch name cannot contain spaces" * - ".feature" → "Branch name cannot start with a dot" * - "feature.lock" → "Branch name cannot end with .lock" * * ================================================================================================ * RESPONSE FORMATS * ================================================================================================ * * Streaming Response (stream=true): * Content-Type: text/event-stream * Events: * - { type: "status", message: "...", projectPath: "..." } * - { type: "claude-response", data: {...} } * - { type: "github-branch", branch: { name: "...", url: "..." } } * - { type: "github-pr", pullRequest: { number: 42, url: "..." } } * - { type: "github-error", error: "..." } * - { type: "done" } * * Non-Streaming Response (stream=false): * Content-Type: application/json * { * success: true, * sessionId: "session-123", * messages: [...], // Assistant messages only (filtered) * tokens: { * inputTokens: 150, * outputTokens: 50, * cacheReadTokens: 0, * cacheCreationTokens: 0, * totalTokens: 200 * }, * projectPath: "/path/to/project", * branch: { // Only if createBranch=true * name: "feature/xyz", * url: "https://github.com/owner/repo/tree/feature/xyz" * } | { error: "..." }, * pullRequest: { // Only if createPR=true * number: 42, * url: "https://github.com/owner/repo/pull/42" * } | { error: "..." } * } * * Error Response: * HTTP Status: 400, 401, 500 * Content-Type: application/json * { success: false, error: "Error description" } * * ================================================================================================ * EXAMPLES * ================================================================================================ * * Example 1: Clone and process with auto-cleanup * POST /api/agent * { "githubUrl": "https://github.com/user/repo", "message": "Fix bug" } * * Example 2: Use existing project with custom branch and PR * POST /api/agent * { * "projectPath": "/home/user/project", * "message": "Add feature", * "branchName": "feature/new-feature", * "createPR": true * } * * Example 3: Clone to specific path with auto-generated branch * POST /api/agent * { * "githubUrl": "https://github.com/user/repo", * "projectPath": "/tmp/work", * "message": "Refactor code", * "createBranch": true, * "cleanup": false * } */ router.post('/', validateExternalApiKey, async (req, res) => { const { githubUrl, projectPath, message, provider = 'claude', model, githubToken, branchName } = req.body; // Parse stream and cleanup as booleans (handle string "true"/"false" from curl) const stream = req.body.stream === undefined ? true : (req.body.stream === true || req.body.stream === 'true'); const cleanup = req.body.cleanup === undefined ? true : (req.body.cleanup === true || req.body.cleanup === 'true'); // If branchName is provided, automatically enable createBranch const createBranch = branchName ? true : (req.body.createBranch === true || req.body.createBranch === 'true'); const createPR = req.body.createPR === true || req.body.createPR === 'true'; // Validate inputs if (!githubUrl && !projectPath) { return res.status(400).json({ error: 'Either githubUrl or projectPath is required' }); } if (!message || !message.trim()) { return res.status(400).json({ error: 'message is required' }); } if (!['claude', 'cursor', 'codex', 'gemini'].includes(provider)) { return res.status(400).json({ error: 'provider must be "claude", "cursor", "codex", or "gemini"' }); } // Validate GitHub branch/PR creation requirements // Allow branch/PR creation with projectPath as long as it has a GitHub remote if ((createBranch || createPR) && !githubUrl && !projectPath) { return res.status(400).json({ error: 'createBranch and createPR require either githubUrl or projectPath with a GitHub remote' }); } let finalProjectPath = null; let writer = null; try { // Determine the final project path if (githubUrl) { // Clone repository (to projectPath if provided, otherwise generate path) const tokenToUse = githubToken || githubTokensDb.getActiveGithubToken(req.user.id); let targetPath; if (projectPath) { targetPath = projectPath; } else { // Generate a unique path for cloning const repoHash = crypto.createHash('md5').update(githubUrl + Date.now()).digest('hex'); targetPath = path.join(os.homedir(), '.claude', 'external-projects', repoHash); } finalProjectPath = await cloneGitHubRepo(githubUrl.trim(), tokenToUse, targetPath); } else { // Use existing project path finalProjectPath = path.resolve(projectPath); // Verify the path exists try { await fs.access(finalProjectPath); } catch (error) { throw new Error(`Project path does not exist: ${finalProjectPath}`); } } // Register the project (or use existing registration) let project; try { project = await addProjectManually(finalProjectPath); console.log('📦 Project registered:', project); } catch (error) { // If project already exists, that's fine - continue with the existing registration if (error.message && error.message.includes('Project already configured')) { console.log('📦 Using existing project registration for:', finalProjectPath); project = { path: finalProjectPath }; } else { throw error; } } // Set up writer based on streaming mode if (stream) { // Set up SSE headers for streaming res.setHeader('Content-Type', 'text/event-stream'); res.setHeader('Cache-Control', 'no-cache'); res.setHeader('Connection', 'keep-alive'); res.setHeader('X-Accel-Buffering', 'no'); // Disable nginx buffering writer = new SSEStreamWriter(res, req.user.id); // Send initial status writer.send({ type: 'status', message: githubUrl ? 'Repository cloned and session started' : 'Session started', projectPath: finalProjectPath }); } else { // Non-streaming mode: collect messages writer = new ResponseCollector(req.user.id); // Collect initial status message writer.send({ type: 'status', message: githubUrl ? 'Repository cloned and session started' : 'Session started', projectPath: finalProjectPath }); } // Start the appropriate session if (provider === 'claude') { console.log('🤖 Starting Claude SDK session'); await queryClaudeSDK(message.trim(), { projectPath: finalProjectPath, cwd: finalProjectPath, sessionId: null, // New session model: model, permissionMode: 'bypassPermissions' // Bypass all permissions for API calls }, writer); } else if (provider === 'cursor') { console.log('🖱️ Starting Cursor CLI session'); await spawnCursor(message.trim(), { projectPath: finalProjectPath, cwd: finalProjectPath, sessionId: null, // New session model: model || undefined, skipPermissions: true // Bypass permissions for Cursor }, writer); } else if (provider === 'codex') { console.log('🤖 Starting Codex SDK session'); await queryCodex(message.trim(), { projectPath: finalProjectPath, cwd: finalProjectPath, sessionId: null, model: model || CODEX_MODELS.DEFAULT, permissionMode: 'bypassPermissions' }, writer); } else if (provider === 'gemini') { console.log('✨ Starting Gemini CLI session'); await spawnGemini(message.trim(), { projectPath: finalProjectPath, cwd: finalProjectPath, sessionId: null, model: model, skipPermissions: true // CLI mode bypasses permissions }, writer); } // Handle GitHub branch and PR creation after successful agent completion let branchInfo = null; let prInfo = null; if (createBranch || createPR) { try { console.log('🔄 Starting GitHub branch/PR creation workflow...'); // Get GitHub token const tokenToUse = githubToken || githubTokensDb.getActiveGithubToken(req.user.id); if (!tokenToUse) { throw new Error('GitHub token required for branch/PR creation. Please configure a GitHub token in settings.'); } // Initialize Octokit const octokit = new Octokit({ auth: tokenToUse }); // Get GitHub URL - either from parameter or from git remote let repoUrl = githubUrl; if (!repoUrl) { console.log('🔍 Getting GitHub URL from git remote...'); try { repoUrl = await getGitRemoteUrl(finalProjectPath); if (!repoUrl.includes('github.com')) { throw new Error('Project does not have a GitHub remote configured'); } console.log(`✅ Found GitHub remote: ${repoUrl}`); } catch (error) { throw new Error(`Failed to get GitHub remote URL: ${error.message}`); } } // Parse GitHub URL to get owner and repo const { owner, repo } = parseGitHubUrl(repoUrl); console.log(`📦 Repository: ${owner}/${repo}`); // Use provided branch name or auto-generate from message const finalBranchName = branchName || autogenerateBranchName(message); if (branchName) { console.log(`🌿 Using provided branch name: ${finalBranchName}`); // Validate custom branch name const validation = validateBranchName(finalBranchName); if (!validation.valid) { throw new Error(`Invalid branch name: ${validation.error}`); } } else { console.log(`🌿 Auto-generated branch name: ${finalBranchName}`); } if (createBranch) { // Create and checkout the new branch locally console.log('🔄 Creating local branch...'); const checkoutProcess = spawn('git', ['checkout', '-b', finalBranchName], { cwd: finalProjectPath, stdio: 'pipe' }); await new Promise((resolve, reject) => { let stderr = ''; checkoutProcess.stderr.on('data', (data) => { stderr += data.toString(); }); checkoutProcess.on('close', (code) => { if (code === 0) { console.log(`✅ Created and checked out local branch '${finalBranchName}'`); resolve(); } else { // Branch might already exist locally, try to checkout if (stderr.includes('already exists')) { console.log(`ℹ️ Branch '${finalBranchName}' already exists locally, checking out...`); const checkoutExisting = spawn('git', ['checkout', finalBranchName], { cwd: finalProjectPath, stdio: 'pipe' }); checkoutExisting.on('close', (checkoutCode) => { if (checkoutCode === 0) { console.log(`✅ Checked out existing branch '${finalBranchName}'`); resolve(); } else { reject(new Error(`Failed to checkout existing branch: ${stderr}`)); } }); } else { reject(new Error(`Failed to create branch: ${stderr}`)); } } }); }); // Push the branch to remote console.log('🔄 Pushing branch to remote...'); const pushProcess = spawn('git', ['push', '-u', 'origin', finalBranchName], { cwd: finalProjectPath, stdio: 'pipe' }); await new Promise((resolve, reject) => { let stderr = ''; let stdout = ''; pushProcess.stdout.on('data', (data) => { stdout += data.toString(); }); pushProcess.stderr.on('data', (data) => { stderr += data.toString(); }); pushProcess.on('close', (code) => { if (code === 0) { console.log(`✅ Pushed branch '${finalBranchName}' to remote`); resolve(); } else { // Check if branch exists on remote but has different commits if (stderr.includes('already exists') || stderr.includes('up-to-date')) { console.log(`ℹ️ Branch '${finalBranchName}' already exists on remote, using existing branch`); resolve(); } else { reject(new Error(`Failed to push branch: ${stderr}`)); } } }); }); branchInfo = { name: finalBranchName, url: `https://github.com/${owner}/${repo}/tree/${finalBranchName}` }; } if (createPR) { // Get commit messages to generate PR description console.log('🔄 Generating PR title and description...'); const commitMessages = await getCommitMessages(finalProjectPath, 5); // Use the first commit message as the PR title, or fallback to the agent message const prTitle = commitMessages.length > 0 ? commitMessages[0] : message; // Generate PR body from commit messages let prBody = '## Changes\n\n'; if (commitMessages.length > 0) { prBody += commitMessages.map(msg => `- ${msg}`).join('\n'); } else { prBody += `Agent task: ${message}`; } prBody += '\n\n---\n*This pull request was automatically created by Claude Code UI Agent.*'; console.log(`📝 PR Title: ${prTitle}`); // Create the pull request console.log('🔄 Creating pull request...'); prInfo = await createGitHubPR(octokit, owner, repo, finalBranchName, prTitle, prBody, 'main'); } // Send branch/PR info in response if (stream) { if (branchInfo) { writer.send({ type: 'github-branch', branch: branchInfo }); } if (prInfo) { writer.send({ type: 'github-pr', pullRequest: prInfo }); } } } catch (error) { console.error('❌ GitHub branch/PR creation error:', error); // Send error but don't fail the entire request if (stream) { writer.send({ type: 'github-error', error: error.message }); } // Store error info for non-streaming response if (!stream) { branchInfo = { error: error.message }; prInfo = { error: error.message }; } } } // Handle response based on streaming mode if (stream) { // Streaming mode: end the SSE stream writer.end(); } else { // Non-streaming mode: send filtered messages and token summary as JSON const assistantMessages = writer.getAssistantMessages(); const tokenSummary = writer.getTotalTokens(); const response = { success: true, sessionId: writer.getSessionId(), messages: assistantMessages, tokens: tokenSummary, projectPath: finalProjectPath }; // Add branch/PR info if created if (branchInfo) { response.branch = branchInfo; } if (prInfo) { response.pullRequest = prInfo; } res.json(response); } // Clean up if requested if (cleanup && githubUrl) { // Only cleanup if we cloned a repo (not for existing project paths) const sessionIdForCleanup = writer.getSessionId(); setTimeout(() => { cleanupProject(finalProjectPath, sessionIdForCleanup); }, 5000); } } catch (error) { console.error('❌ External session error:', error); // Clean up on error if (finalProjectPath && cleanup && githubUrl) { const sessionIdForCleanup = writer ? writer.getSessionId() : null; cleanupProject(finalProjectPath, sessionIdForCleanup); } if (stream) { // For streaming, send error event and stop if (!writer) { // Set up SSE headers if not already done res.setHeader('Content-Type', 'text/event-stream'); res.setHeader('Cache-Control', 'no-cache'); res.setHeader('Connection', 'keep-alive'); res.setHeader('X-Accel-Buffering', 'no'); writer = new SSEStreamWriter(res, req.user.id); } if (!res.writableEnded) { writer.send({ type: 'error', error: error.message, message: `Failed: ${error.message}` }); writer.end(); } } else if (!res.headersSent) { res.status(500).json({ success: false, error: error.message }); } } }); export default router; ================================================ FILE: server/routes/auth.js ================================================ import express from 'express'; import bcrypt from 'bcrypt'; import { userDb, db } from '../database/db.js'; import { generateToken, authenticateToken } from '../middleware/auth.js'; const router = express.Router(); // Check auth status and setup requirements router.get('/status', async (req, res) => { try { const hasUsers = await userDb.hasUsers(); res.json({ needsSetup: !hasUsers, isAuthenticated: false // Will be overridden by frontend if token exists }); } catch (error) { console.error('Auth status error:', error); res.status(500).json({ error: 'Internal server error' }); } }); // User registration (setup) - only allowed if no users exist router.post('/register', async (req, res) => { try { const { username, password } = req.body; // Validate input if (!username || !password) { return res.status(400).json({ error: 'Username and password are required' }); } if (username.length < 3 || password.length < 6) { return res.status(400).json({ error: 'Username must be at least 3 characters, password at least 6 characters' }); } // Use a transaction to prevent race conditions db.prepare('BEGIN').run(); try { // Check if users already exist (only allow one user) const hasUsers = userDb.hasUsers(); if (hasUsers) { db.prepare('ROLLBACK').run(); return res.status(403).json({ error: 'User already exists. This is a single-user system.' }); } // Hash password const saltRounds = 12; const passwordHash = await bcrypt.hash(password, saltRounds); // Create user const user = userDb.createUser(username, passwordHash); // Generate token const token = generateToken(user); db.prepare('COMMIT').run(); // Update last login (non-fatal, outside transaction) userDb.updateLastLogin(user.id); res.json({ success: true, user: { id: user.id, username: user.username }, token }); } catch (error) { db.prepare('ROLLBACK').run(); throw error; } } catch (error) { console.error('Registration error:', error); if (error.code === 'SQLITE_CONSTRAINT_UNIQUE') { res.status(409).json({ error: 'Username already exists' }); } else { res.status(500).json({ error: 'Internal server error' }); } } }); // User login router.post('/login', async (req, res) => { try { const { username, password } = req.body; // Validate input if (!username || !password) { return res.status(400).json({ error: 'Username and password are required' }); } // Get user from database const user = userDb.getUserByUsername(username); if (!user) { return res.status(401).json({ error: 'Invalid username or password' }); } // Verify password const isValidPassword = await bcrypt.compare(password, user.password_hash); if (!isValidPassword) { return res.status(401).json({ error: 'Invalid username or password' }); } // Generate token const token = generateToken(user); // Update last login userDb.updateLastLogin(user.id); res.json({ success: true, user: { id: user.id, username: user.username }, token }); } catch (error) { console.error('Login error:', error); res.status(500).json({ error: 'Internal server error' }); } }); // Get current user (protected route) router.get('/user', authenticateToken, (req, res) => { res.json({ user: req.user }); }); // Logout (client-side token removal, but this endpoint can be used for logging) router.post('/logout', authenticateToken, (req, res) => { // In a simple JWT system, logout is mainly client-side // This endpoint exists for consistency and potential future logging res.json({ success: true, message: 'Logged out successfully' }); }); export default router; ================================================ FILE: server/routes/cli-auth.js ================================================ import express from 'express'; import { spawn } from 'child_process'; import fs from 'fs/promises'; import path from 'path'; import os from 'os'; const router = express.Router(); router.get('/claude/status', async (req, res) => { try { const credentialsResult = await checkClaudeCredentials(); if (credentialsResult.authenticated) { return res.json({ authenticated: true, email: credentialsResult.email || 'Authenticated', method: credentialsResult.method // 'api_key' or 'credentials_file' }); } return res.json({ authenticated: false, email: null, method: null, error: credentialsResult.error || 'Not authenticated' }); } catch (error) { console.error('Error checking Claude auth status:', error); res.status(500).json({ authenticated: false, email: null, method: null, error: error.message }); } }); router.get('/cursor/status', async (req, res) => { try { const result = await checkCursorStatus(); res.json({ authenticated: result.authenticated, email: result.email, error: result.error }); } catch (error) { console.error('Error checking Cursor auth status:', error); res.status(500).json({ authenticated: false, email: null, error: error.message }); } }); router.get('/codex/status', async (req, res) => { try { const result = await checkCodexCredentials(); res.json({ authenticated: result.authenticated, email: result.email, error: result.error }); } catch (error) { console.error('Error checking Codex auth status:', error); res.status(500).json({ authenticated: false, email: null, error: error.message }); } }); router.get('/gemini/status', async (req, res) => { try { const result = await checkGeminiCredentials(); res.json({ authenticated: result.authenticated, email: result.email, error: result.error }); } catch (error) { console.error('Error checking Gemini auth status:', error); res.status(500).json({ authenticated: false, email: null, error: error.message }); } }); async function loadClaudeSettingsEnv() { 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 (error) { // Ignore missing or malformed settings and fall back to other auth sources. } return {}; } /** * Checks Claude authentication credentials using two methods with priority order: * * Priority 1: ANTHROPIC_API_KEY environment variable * Priority 1b: ~/.claude/settings.json env values * Priority 2: ~/.claude/.credentials.json OAuth tokens * * The Claude Agent SDK prioritizes environment variables over authenticated subscriptions. * This matching behavior ensures consistency with how the SDK authenticates. * * References: * - https://support.claude.com/en/articles/12304248-managing-api-key-environment-variables-in-claude-code * "Claude Code prioritizes environment variable API keys over authenticated subscriptions" * - https://platform.claude.com/docs/en/agent-sdk/overview * SDK authentication documentation * * @returns {Promise} Authentication status with { authenticated, email, method } * - authenticated: boolean indicating if valid credentials exist * - email: user email or auth method identifier * - method: 'api_key' for env var, 'credentials_file' for OAuth tokens */ async function checkClaudeCredentials() { // Priority 1: Check for ANTHROPIC_API_KEY environment variable // The SDK checks this first and uses it if present, even if OAuth tokens exist. // When set, API calls are charged via pay-as-you-go rates instead of subscription. if (process.env.ANTHROPIC_API_KEY && process.env.ANTHROPIC_API_KEY.trim()) { return { authenticated: true, email: 'API Key Auth', method: 'api_key' }; } // Priority 1b: Check ~/.claude/settings.json env values. // Claude Code can read proxy/auth values from settings.json even when the // CloudCLI server process itself was not started with those env vars exported. const settingsEnv = await loadClaudeSettingsEnv(); 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' }; } // Priority 2: Check ~/.claude/.credentials.json for OAuth tokens // This is the standard authentication method used by Claude CLI after running // 'claude /login' or 'claude setup-token' commands. 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: null, method: null }; } catch (error) { return { authenticated: false, email: null, method: null }; } } function checkCursorStatus() { 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 (err) { 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], output: stdout }); } else if (stdout.includes('Logged in')) { resolve({ authenticated: true, email: 'Logged in', output: stdout }); } else { resolve({ authenticated: false, email: null, error: 'Not logged in' }); } } else { resolve({ authenticated: false, email: null, error: stderr || 'Not logged in' }); } }); childProcess.on('error', (err) => { if (processCompleted) return; processCompleted = true; clearTimeout(timeout); resolve({ authenticated: false, email: null, error: 'Cursor CLI not found or not installed' }); }); }); } async function checkCodexCredentials() { try { const authPath = path.join(os.homedir(), '.codex', 'auth.json'); const content = await fs.readFile(authPath, 'utf8'); const auth = JSON.parse(content); // Tokens are nested under 'tokens' key const tokens = auth.tokens || {}; // Check for valid tokens (id_token or access_token) if (tokens.id_token || tokens.access_token) { // Try to extract email from id_token JWT payload let email = 'Authenticated'; if (tokens.id_token) { try { // JWT is base64url encoded: header.payload.signature const parts = tokens.id_token.split('.'); if (parts.length >= 2) { // Decode the payload (second part) const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8')); email = payload.email || payload.user || 'Authenticated'; } } catch { // If JWT decoding fails, use fallback email = 'Authenticated'; } } return { authenticated: true, email }; } // Also check for OPENAI_API_KEY as fallback auth method 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 }; } } async function checkGeminiCredentials() { 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 { // Validate token against Google API 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) { // Token invalid and no refresh token available 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 try { const accPath = path.join(os.homedir(), '.gemini', 'google_accounts.json'); const accContent = await fs.readFile(accPath, 'utf8'); const accounts = JSON.parse(accContent); if (accounts.active) { email = accounts.active; } } catch (e) { } } } catch (e) { // Network error, fallback to checking local accounts file try { const accPath = path.join(os.homedir(), '.gemini', 'google_accounts.json'); const accContent = await fs.readFile(accPath, 'utf8'); const accounts = JSON.parse(accContent); if (accounts.active) { email = accounts.active; } } catch (err) { } } return { authenticated: true, email: email }; } return { authenticated: false, email: null, error: 'No valid tokens found in oauth_creds' }; } catch (error) { return { authenticated: false, email: null, error: 'Gemini CLI not configured' }; } } export default router; ================================================ FILE: server/routes/codex.js ================================================ 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'; 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; await deleteCodexSession(sessionId); sessionNamesDb.deleteName(sessionId, 'codex'); res.json({ success: true }); } catch (error) { console.error(`Error deleting Codex session ${req.params.sessionId}:`, error); res.status(500).json({ success: false, error: error.message }); } }); // 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 [-e KEY=VAL]... -- [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; ================================================ FILE: server/routes/commands.js ================================================ import express from 'express'; import { promises as fs } from 'fs'; import path from 'path'; import { fileURLToPath } from 'url'; import os from 'os'; import { CLAUDE_MODELS, CURSOR_MODELS, CODEX_MODELS } from '../../shared/modelConstants.js'; import { parseFrontmatter } from '../utils/frontmatter.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const router = express.Router(); /** * Recursively scan directory for command files (.md) * @param {string} dir - Directory to scan * @param {string} baseDir - Base directory for relative paths * @param {string} namespace - Namespace for commands (e.g., 'project', 'user') * @returns {Promise} Array of command objects */ async function scanCommandsDirectory(dir, baseDir, namespace) { const commands = []; try { // Check if directory exists await fs.access(dir); const entries = await fs.readdir(dir, { withFileTypes: true }); for (const entry of entries) { const fullPath = path.join(dir, entry.name); if (entry.isDirectory()) { // Recursively scan subdirectories const subCommands = await scanCommandsDirectory(fullPath, baseDir, namespace); commands.push(...subCommands); } else if (entry.isFile() && entry.name.endsWith('.md')) { // Parse markdown file for metadata try { const content = await fs.readFile(fullPath, 'utf8'); const { data: frontmatter, content: commandContent } = parseFrontmatter(content); // Calculate relative path from baseDir for command name const relativePath = path.relative(baseDir, fullPath); // Remove .md extension and convert to command name const commandName = '/' + relativePath.replace(/\.md$/, '').replace(/\\/g, '/'); // Extract description from frontmatter or first line of content let description = frontmatter.description || ''; if (!description) { const firstLine = commandContent.trim().split('\n')[0]; description = firstLine.replace(/^#+\s*/, '').trim(); } commands.push({ name: commandName, path: fullPath, relativePath, description, namespace, metadata: frontmatter }); } catch (err) { console.error(`Error parsing command file ${fullPath}:`, err.message); } } } } catch (err) { // Directory doesn't exist or can't be accessed - this is okay if (err.code !== 'ENOENT' && err.code !== 'EACCES') { console.error(`Error scanning directory ${dir}:`, err.message); } } return commands; } /** * Built-in commands that are always available */ const builtInCommands = [ { name: '/help', description: 'Show help documentation for Claude Code', namespace: 'builtin', metadata: { type: 'builtin' } }, { name: '/clear', description: 'Clear the conversation history', namespace: 'builtin', metadata: { type: 'builtin' } }, { name: '/model', description: 'Switch or view the current AI model', namespace: 'builtin', metadata: { type: 'builtin' } }, { name: '/cost', description: 'Display token usage and cost information', namespace: 'builtin', metadata: { type: 'builtin' } }, { name: '/memory', description: 'Open CLAUDE.md memory file for editing', namespace: 'builtin', metadata: { type: 'builtin' } }, { name: '/config', description: 'Open settings and configuration', namespace: 'builtin', metadata: { type: 'builtin' } }, { name: '/status', description: 'Show system status and version information', namespace: 'builtin', metadata: { type: 'builtin' } }, { name: '/rewind', description: 'Rewind the conversation to a previous state', namespace: 'builtin', metadata: { type: 'builtin' } } ]; /** * Built-in command handlers * Each handler returns { type: 'builtin', action: string, data: any } */ const builtInHandlers = { '/help': async (args, context) => { const helpText = `# Claude Code Commands ## Built-in Commands ${builtInCommands.map(cmd => `### ${cmd.name} ${cmd.description} `).join('\n')} ## Custom Commands Custom commands can be created in: - Project: \`.claude/commands/\` (project-specific) - User: \`~/.claude/commands/\` (available in all projects) ### Command Syntax - **Arguments**: Use \`$ARGUMENTS\` for all args or \`$1\`, \`$2\`, etc. for positional - **File Includes**: Use \`@filename\` to include file contents - **Bash Commands**: Use \`!command\` to execute bash commands ### Examples \`\`\`markdown /mycommand arg1 arg2 \`\`\` `; return { type: 'builtin', action: 'help', data: { content: helpText, format: 'markdown' } }; }, '/clear': async (args, context) => { return { type: 'builtin', action: 'clear', data: { message: 'Conversation history cleared' } }; }, '/model': async (args, context) => { // Read available models from centralized constants const availableModels = { claude: CLAUDE_MODELS.OPTIONS.map(o => o.value), cursor: CURSOR_MODELS.OPTIONS.map(o => o.value), codex: CODEX_MODELS.OPTIONS.map(o => o.value) }; const currentProvider = context?.provider || 'claude'; const currentModel = context?.model || CLAUDE_MODELS.DEFAULT; return { type: 'builtin', action: 'model', data: { current: { provider: currentProvider, model: currentModel }, available: availableModels, message: args.length > 0 ? `Switching to model: ${args[0]}` : `Current model: ${currentModel}` } }; }, '/cost': async (args, context) => { const tokenUsage = context?.tokenUsage || {}; const provider = context?.provider || 'claude'; const model = context?.model || (provider === 'cursor' ? CURSOR_MODELS.DEFAULT : provider === 'codex' ? CODEX_MODELS.DEFAULT : CLAUDE_MODELS.DEFAULT); const used = Number(tokenUsage.used ?? tokenUsage.totalUsed ?? tokenUsage.total_tokens ?? 0) || 0; const total = Number( tokenUsage.total ?? tokenUsage.contextWindow ?? parseInt(process.env.CONTEXT_WINDOW || '160000', 10), ) || 160000; const percentage = total > 0 ? Number(((used / total) * 100).toFixed(1)) : 0; const inputTokensRaw = Number( tokenUsage.inputTokens ?? tokenUsage.input ?? tokenUsage.cumulativeInputTokens ?? tokenUsage.promptTokens ?? 0, ) || 0; const outputTokens = Number( tokenUsage.outputTokens ?? tokenUsage.output ?? tokenUsage.cumulativeOutputTokens ?? tokenUsage.completionTokens ?? 0, ) || 0; const cacheTokens = Number( tokenUsage.cacheReadTokens ?? tokenUsage.cacheCreationTokens ?? tokenUsage.cacheTokens ?? tokenUsage.cachedTokens ?? 0, ) || 0; // If we only have total used tokens, treat them as input for display/estimation. const inputTokens = inputTokensRaw > 0 || outputTokens > 0 || cacheTokens > 0 ? inputTokensRaw + cacheTokens : used; // Rough default rates by provider (USD / 1M tokens). const pricingByProvider = { claude: { input: 3, output: 15 }, cursor: { input: 3, output: 15 }, codex: { input: 1.5, output: 6 }, }; const rates = pricingByProvider[provider] || pricingByProvider.claude; const inputCost = (inputTokens / 1_000_000) * rates.input; const outputCost = (outputTokens / 1_000_000) * rates.output; const totalCost = inputCost + outputCost; return { type: 'builtin', action: 'cost', data: { tokenUsage: { used, total, percentage, }, cost: { input: inputCost.toFixed(4), output: outputCost.toFixed(4), total: totalCost.toFixed(4), }, model, }, }; }, '/status': async (args, context) => { // Read version from package.json const packageJsonPath = path.join(path.dirname(__dirname), '..', 'package.json'); let version = 'unknown'; let packageName = 'claude-code-ui'; try { const packageJson = JSON.parse(await fs.readFile(packageJsonPath, 'utf8')); version = packageJson.version; packageName = packageJson.name; } catch (err) { console.error('Error reading package.json:', err); } const uptime = process.uptime(); const uptimeMinutes = Math.floor(uptime / 60); const uptimeHours = Math.floor(uptimeMinutes / 60); const uptimeFormatted = uptimeHours > 0 ? `${uptimeHours}h ${uptimeMinutes % 60}m` : `${uptimeMinutes}m`; return { type: 'builtin', action: 'status', data: { version, packageName, uptime: uptimeFormatted, uptimeSeconds: Math.floor(uptime), model: context?.model || 'claude-sonnet-4.5', provider: context?.provider || 'claude', nodeVersion: process.version, platform: process.platform } }; }, '/memory': async (args, context) => { const projectPath = context?.projectPath; if (!projectPath) { return { type: 'builtin', action: 'memory', data: { error: 'No project selected', message: 'Please select a project to access its CLAUDE.md file' } }; } const claudeMdPath = path.join(projectPath, 'CLAUDE.md'); // Check if CLAUDE.md exists let exists = false; try { await fs.access(claudeMdPath); exists = true; } catch (err) { // File doesn't exist } return { type: 'builtin', action: 'memory', data: { path: claudeMdPath, exists, message: exists ? `Opening CLAUDE.md at ${claudeMdPath}` : `CLAUDE.md not found at ${claudeMdPath}. Create it to store project-specific instructions.` } }; }, '/config': async (args, context) => { return { type: 'builtin', action: 'config', data: { message: 'Opening settings...' } }; }, '/rewind': async (args, context) => { const steps = args[0] ? parseInt(args[0]) : 1; if (isNaN(steps) || steps < 1) { return { type: 'builtin', action: 'rewind', data: { error: 'Invalid steps parameter', message: 'Usage: /rewind [number] - Rewind conversation by N steps (default: 1)' } }; } return { type: 'builtin', action: 'rewind', data: { steps, message: `Rewinding conversation by ${steps} step${steps > 1 ? 's' : ''}...` } }; } }; /** * POST /api/commands/list * List all available commands from project and user directories */ router.post('/list', async (req, res) => { try { const { projectPath } = req.body; const allCommands = [...builtInCommands]; // Scan project-level commands (.claude/commands/) if (projectPath) { const projectCommandsDir = path.join(projectPath, '.claude', 'commands'); const projectCommands = await scanCommandsDirectory( projectCommandsDir, projectCommandsDir, 'project' ); allCommands.push(...projectCommands); } // Scan user-level commands (~/.claude/commands/) const homeDir = os.homedir(); const userCommandsDir = path.join(homeDir, '.claude', 'commands'); const userCommands = await scanCommandsDirectory( userCommandsDir, userCommandsDir, 'user' ); allCommands.push(...userCommands); // Separate built-in and custom commands const customCommands = allCommands.filter(cmd => cmd.namespace !== 'builtin'); // Sort commands alphabetically by name customCommands.sort((a, b) => a.name.localeCompare(b.name)); res.json({ builtIn: builtInCommands, custom: customCommands, count: allCommands.length }); } catch (error) { console.error('Error listing commands:', error); res.status(500).json({ error: 'Failed to list commands', message: error.message }); } }); /** * 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 * This endpoint prepares the command content but doesn't execute bash commands yet * (that will be handled in the command parser utility) */ router.post('/execute', async (req, res) => { try { const { commandName, commandPath, args = [], context = {} } = req.body; if (!commandName) { return res.status(400).json({ error: 'Command name is required' }); } // Handle built-in commands const handler = builtInHandlers[commandName]; if (handler) { try { const result = await handler(args, context); return res.json({ ...result, command: commandName }); } catch (error) { console.error(`Error executing built-in command ${commandName}:`, error); return res.status(500).json({ error: 'Command execution failed', message: error.message, command: commandName }); } } // Handle custom commands if (!commandPath) { return res.status(400).json({ error: 'Command path is required for custom commands' }); } // Load command content // Security: validate commandPath is within allowed directories { const resolvedPath = path.resolve(commandPath); const userBase = path.resolve(path.join(os.homedir(), '.claude', 'commands')); const projectBase = context?.projectPath ? path.resolve(path.join(context.projectPath, '.claude', 'commands')) : null; const isUnder = (base) => { const rel = path.relative(base, resolvedPath); return rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel); }; if (!(isUnder(userBase) || (projectBase && isUnder(projectBase)))) { return res.status(403).json({ error: 'Access denied', message: 'Command must be in .claude/commands directory' }); } } const content = await fs.readFile(commandPath, 'utf8'); const { data: metadata, content: commandContent } = parseFrontmatter(content); // Basic argument replacement (will be enhanced in command parser utility) let processedContent = commandContent; // Replace $ARGUMENTS with all arguments joined const argsString = args.join(' '); processedContent = processedContent.replace(/\$ARGUMENTS/g, argsString); // Replace $1, $2, etc. with positional arguments args.forEach((arg, index) => { const placeholder = `$${index + 1}`; processedContent = processedContent.replace(new RegExp(`\\${placeholder}\\b`, 'g'), arg); }); res.json({ type: 'custom', command: commandName, content: processedContent, metadata, hasFileIncludes: processedContent.includes('@'), hasBashCommands: processedContent.includes('!') }); } 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 executing command:', error); res.status(500).json({ error: 'Failed to execute command', message: error.message }); } }); export default router; ================================================ FILE: server/routes/cursor.js ================================================ import express from 'express'; import { promises as fs } from 'fs'; import path from 'path'; import os from 'os'; import { spawn } from 'child_process'; import sqlite3 from 'sqlite3'; import { open } from 'sqlite'; import crypto from 'crypto'; import { CURSOR_MODELS } from '../../shared/modelConstants.js'; import { applyCustomSessionNames } from '../database/db.js'; const router = express.Router(); // GET /api/cursor/config - Read Cursor CLI configuration 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 }); } 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, config: { version: 1, model: { modelId: CURSOR_MODELS.DEFAULT, displayName: "GPT-5" }, permissions: { allow: [], deny: [] } }, isDefault: true }); } } catch (error) { console.error('Error reading Cursor config:', error); 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 = await open({ filename: storeDbPath, driver: sqlite3.Database, mode: sqlite3.OPEN_READONLY }); // Get metadata from meta table const metaRows = await db.all(` SELECT key, value FROM meta `); 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 = await db.get(` SELECT COUNT(*) as count FROM blobs WHERE substr(data, 1, 1) = X'7B' `); sessionData.messageCount = blobCount.count; // Get the most recent JSON blob for preview (actual message, not DAG structure) const lastBlob = await db.get(` SELECT data FROM blobs WHERE substr(data, 1, 1) = X'7B' ORDER BY rowid DESC LIMIT 1 `); 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); } await 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 }); } }); // GET /api/cursor/sessions/:sessionId - Get specific Cursor session from SQLite router.get('/sessions/:sessionId', async (req, res) => { try { const { sessionId } = req.params; const { projectPath } = req.query; // Calculate cwdID hash for the project path const cwdId = crypto.createHash('md5').update(projectPath || process.cwd()).digest('hex'); const storeDbPath = path.join(os.homedir(), '.cursor', 'chats', cwdId, sessionId, 'store.db'); // Open SQLite database const db = await open({ filename: storeDbPath, driver: sqlite3.Database, mode: sqlite3.OPEN_READONLY }); // Get all blobs to build the DAG structure const allBlobs = await db.all(` SELECT rowid, id, data FROM blobs `); // Build the DAG structure from parent-child relationships const blobMap = new Map(); // id -> blob data const parentRefs = new Map(); // blob id -> [parent blob ids] const childRefs = new Map(); // blob id -> [child blob ids] const jsonBlobs = []; // Clean JSON messages for (const blob of allBlobs) { blobMap.set(blob.id, blob); // Check if this is a JSON blob (actual message) or protobuf (DAG structure) if (blob.data && blob.data[0] === 0x7B) { // Starts with '{' - JSON blob try { const parsed = JSON.parse(blob.data.toString('utf8')); jsonBlobs.push({ ...blob, parsed }); } catch (e) { console.log('Failed to parse JSON blob:', blob.rowid); } } else if (blob.data) { // Protobuf blob - extract parent references const parents = []; let i = 0; // Scan for parent references (0x0A 0x20 followed by 32-byte hash) 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); // Update child references for (const parentId of parents) { if (!childRefs.has(parentId)) { childRefs.set(parentId, []); } childRefs.get(parentId).push(blob.id); } } } } // Perform topological sort to get chronological order const visited = new Set(); const sorted = []; // DFS-based topological sort function visit(nodeId) { if (visited.has(nodeId)) return; visited.add(nodeId); // Visit all parents first (dependencies) const parents = parentRefs.get(nodeId) || []; for (const parentId of parents) { visit(parentId); } // Add this node after all its parents const blob = blobMap.get(nodeId); if (blob) { sorted.push(blob); } } // Start with nodes that have no parents (roots) for (const blob of allBlobs) { if (!parentRefs.has(blob.id)) { visit(blob.id); } } // Visit any remaining nodes (disconnected components) for (const blob of allBlobs) { visit(blob.id); } // Now extract JSON messages in the order they appear in the sorted DAG const messageOrder = new Map(); // JSON blob id -> order index let orderIndex = 0; for (const blob of sorted) { // Check if this blob references any JSON messages if (blob.data && blob.data[0] !== 0x7B) { // Protobuf blob // Look for JSON blob references for (const jsonBlob of jsonBlobs) { try { const jsonIdBytes = Buffer.from(jsonBlob.id, 'hex'); if (blob.data.includes(jsonIdBytes)) { if (!messageOrder.has(jsonBlob.id)) { messageOrder.set(jsonBlob.id, orderIndex++); } } } catch (e) { // Skip if can't convert ID } } } } // Sort JSON blobs by their appearance order in the DAG const sortedJsonBlobs = jsonBlobs.sort((a, b) => { const orderA = messageOrder.get(a.id) ?? Number.MAX_SAFE_INTEGER; const orderB = messageOrder.get(b.id) ?? Number.MAX_SAFE_INTEGER; if (orderA !== orderB) return orderA - orderB; // Fallback to rowid if not in order map return a.rowid - b.rowid; }); // Use sorted JSON blobs const blobs = sortedJsonBlobs.map((blob, idx) => ({ ...blob, sequence_num: idx + 1, original_rowid: blob.rowid })); // Get metadata from meta table const metaRows = await db.all(` SELECT key, value FROM meta `); // Parse metadata let metadata = {}; 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'); metadata[row.key] = JSON.parse(jsonStr); } else { metadata[row.key] = row.value.toString(); } } catch (e) { metadata[row.key] = row.value.toString(); } } } // Extract messages from sorted JSON blobs const messages = []; for (const blob of blobs) { try { // We already parsed JSON blobs earlier const parsed = blob.parsed; if (parsed) { // Filter out ONLY system messages at the server level // Check both direct role and nested message.role const role = parsed?.role || parsed?.message?.role; if (role === 'system') { continue; // Skip only system messages } messages.push({ id: blob.id, sequence: blob.sequence_num, rowid: blob.original_rowid, content: parsed }); } } catch (e) { // Skip blobs that cause errors console.log(`Skipping blob ${blob.id}: ${e.message}`); } } await db.close(); res.json({ success: true, session: { id: sessionId, projectPath: projectPath, messages: messages, metadata: metadata, cwdId: cwdId } }); } catch (error) { console.error('Error reading Cursor session:', error); res.status(500).json({ error: 'Failed to read Cursor session', details: error.message }); } }); export default router; ================================================ FILE: server/routes/gemini.js ================================================ import express from 'express'; import sessionManager from '../sessionManager.js'; import { sessionNamesDb } from '../database/db.js'; const router = express.Router(); router.delete('/sessions/:sessionId', async (req, res) => { try { const { sessionId } = req.params; if (!sessionId || typeof sessionId !== 'string' || !/^[a-zA-Z0-9_.-]{1,100}$/.test(sessionId)) { return res.status(400).json({ success: false, error: 'Invalid session ID format' }); } await sessionManager.deleteSession(sessionId); sessionNamesDb.deleteName(sessionId, 'gemini'); res.json({ success: true }); } catch (error) { console.error(`Error deleting Gemini session ${req.params.sessionId}:`, error); res.status(500).json({ success: false, error: error.message }); } }); export default router; ================================================ FILE: server/routes/git.js ================================================ import express from 'express'; import { spawn } from 'child_process'; import path from 'path'; import { promises as fs } from 'fs'; import { extractProjectDirectory } from '../projects.js'; import { queryClaudeSDK } from '../claude-sdk.js'; import { spawnCursor } from '../cursor-cli.js'; const router = express.Router(); const COMMIT_DIFF_CHARACTER_LIMIT = 500_000; function spawnAsync(command, args, options = {}) { return new Promise((resolve, reject) => { const child = spawn(command, args, { ...options, shell: false, }); let stdout = ''; let stderr = ''; child.stdout.on('data', (data) => { stdout += data.toString(); }); child.stderr.on('data', (data) => { stderr += data.toString(); }); child.on('error', (error) => { reject(error); }); child.on('close', (code) => { if (code === 0) { resolve({ stdout, stderr }); return; } const error = new Error(`Command failed: ${command} ${args.join(' ')}`); error.code = code; error.stdout = stdout; error.stderr = stderr; reject(error); }); }); } // Input validation helpers (defense-in-depth) function validateCommitRef(commit) { // Allow hex hashes, HEAD, HEAD~N, HEAD^N, tag names, branch names if (!/^[a-zA-Z0-9._~^{}@\/-]+$/.test(commit)) { throw new Error('Invalid commit reference'); } return commit; } function validateBranchName(branch) { if (!/^[a-zA-Z0-9._\/-]+$/.test(branch)) { throw new Error('Invalid branch name'); } return branch; } function validateFilePath(file, projectPath) { if (!file || file.includes('\0')) { throw new Error('Invalid file path'); } // Prevent path traversal: resolve the file relative to the project root // and ensure the result stays within the project directory if (projectPath) { const resolved = path.resolve(projectPath, file); const normalizedRoot = path.resolve(projectPath) + path.sep; if (!resolved.startsWith(normalizedRoot) && resolved !== path.resolve(projectPath)) { throw new Error('Invalid file path: path traversal detected'); } } return file; } function validateRemoteName(remote) { if (!/^[a-zA-Z0-9._-]+$/.test(remote)) { throw new Error('Invalid remote name'); } return remote; } function validateProjectPath(projectPath) { if (!projectPath || projectPath.includes('\0')) { throw new Error('Invalid project path'); } const resolved = path.resolve(projectPath); // Must be an absolute path after resolution if (!path.isAbsolute(resolved)) { throw new Error('Invalid project path: must be absolute'); } // Block obviously dangerous paths if (resolved === '/' || resolved === path.sep) { throw new Error('Invalid project path: root directory not allowed'); } return resolved; } // Helper function to get the actual project path from the encoded project name async function getActualProjectPath(projectName) { let projectPath; try { projectPath = await extractProjectDirectory(projectName); } catch (error) { console.error(`Error extracting project directory for ${projectName}:`, error); throw new Error(`Unable to resolve project path for "${projectName}"`); } return validateProjectPath(projectPath); } // Helper function to strip git diff headers function stripDiffHeaders(diff) { if (!diff) return ''; const lines = diff.split('\n'); const filteredLines = []; let startIncluding = false; for (const line of lines) { // Skip all header lines including diff --git, index, file mode, and --- / +++ file paths if (line.startsWith('diff --git') || line.startsWith('index ') || line.startsWith('new file mode') || line.startsWith('deleted file mode') || line.startsWith('---') || line.startsWith('+++')) { continue; } // Start including lines from @@ hunk headers onwards if (line.startsWith('@@') || startIncluding) { startIncluding = true; filteredLines.push(line); } } return filteredLines.join('\n'); } // Helper function to validate git repository async function validateGitRepository(projectPath) { try { // Check if directory exists await fs.access(projectPath); } catch { throw new Error(`Project path not found: ${projectPath}`); } try { // Allow any directory that is inside a work tree (repo root or nested folder). const { stdout: insideWorkTreeOutput } = await spawnAsync('git', ['rev-parse', '--is-inside-work-tree'], { cwd: projectPath }); const isInsideWorkTree = insideWorkTreeOutput.trim() === 'true'; if (!isInsideWorkTree) { throw new Error('Not inside a git work tree'); } // Ensure git can resolve the repository root for this directory. await spawnAsync('git', ['rev-parse', '--show-toplevel'], { cwd: projectPath }); } catch { throw new Error('Not a git repository. This directory does not contain a .git folder. Initialize a git repository with "git init" to use source control features.'); } } function getGitErrorDetails(error) { return `${error?.message || ''} ${error?.stderr || ''} ${error?.stdout || ''}`; } function isMissingHeadRevisionError(error) { const errorDetails = getGitErrorDetails(error).toLowerCase(); return errorDetails.includes('unknown revision') || errorDetails.includes('ambiguous argument') || errorDetails.includes('needed a single revision') || errorDetails.includes('bad revision'); } async function getCurrentBranchName(projectPath) { try { // symbolic-ref works even when the repository has no commits. const { stdout } = await spawnAsync('git', ['symbolic-ref', '--short', 'HEAD'], { cwd: projectPath }); const branchName = stdout.trim(); if (branchName) { return branchName; } } catch (error) { // Fall back to rev-parse for detached HEAD and older git edge cases. } const { stdout } = await spawnAsync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: projectPath }); return stdout.trim(); } async function repositoryHasCommits(projectPath) { try { await spawnAsync('git', ['rev-parse', '--verify', 'HEAD'], { cwd: projectPath }); return true; } catch (error) { if (isMissingHeadRevisionError(error)) { return false; } throw error; } } async function getRepositoryRootPath(projectPath) { const { stdout } = await spawnAsync('git', ['rev-parse', '--show-toplevel'], { cwd: projectPath }); return stdout.trim(); } function normalizeRepositoryRelativeFilePath(filePath) { return String(filePath) .replace(/\\/g, '/') .replace(/^\.\/+/, '') .replace(/^\/+/, '') .trim(); } function parseStatusFilePaths(statusOutput) { return statusOutput .split('\n') .map((line) => line.trimEnd()) .filter((line) => line.trim()) .map((line) => { const statusPath = line.substring(3); const renamedFilePath = statusPath.split(' -> ')[1]; return normalizeRepositoryRelativeFilePath(renamedFilePath || statusPath); }) .filter(Boolean); } function buildFilePathCandidates(projectPath, repositoryRootPath, filePath) { const normalizedFilePath = normalizeRepositoryRelativeFilePath(filePath); const projectRelativePath = normalizeRepositoryRelativeFilePath(path.relative(repositoryRootPath, projectPath)); const candidates = [normalizedFilePath]; if ( projectRelativePath && projectRelativePath !== '.' && !normalizedFilePath.startsWith(`${projectRelativePath}/`) ) { candidates.push(`${projectRelativePath}/${normalizedFilePath}`); } return Array.from(new Set(candidates.filter(Boolean))); } async function resolveRepositoryFilePath(projectPath, filePath) { validateFilePath(filePath); const repositoryRootPath = await getRepositoryRootPath(projectPath); const candidateFilePaths = buildFilePathCandidates(projectPath, repositoryRootPath, filePath); for (const candidateFilePath of candidateFilePaths) { const { stdout } = await spawnAsync('git', ['status', '--porcelain', '--', candidateFilePath], { cwd: repositoryRootPath }); if (stdout.trim()) { return { repositoryRootPath, repositoryRelativeFilePath: candidateFilePath, }; } } // If the caller sent a bare filename (e.g. "hello.ts"), recover it from changed files. const normalizedFilePath = normalizeRepositoryRelativeFilePath(filePath); if (!normalizedFilePath.includes('/')) { const { stdout: repositoryStatusOutput } = await spawnAsync('git', ['status', '--porcelain'], { cwd: repositoryRootPath }); const changedFilePaths = parseStatusFilePaths(repositoryStatusOutput); const suffixMatches = changedFilePaths.filter( (changedFilePath) => changedFilePath === normalizedFilePath || changedFilePath.endsWith(`/${normalizedFilePath}`), ); if (suffixMatches.length === 1) { return { repositoryRootPath, repositoryRelativeFilePath: suffixMatches[0], }; } } return { repositoryRootPath, repositoryRelativeFilePath: candidateFilePaths[0], }; } // Get git status for a project router.get('/status', async (req, res) => { const { project } = req.query; if (!project) { return res.status(400).json({ error: 'Project name is required' }); } try { const projectPath = await getActualProjectPath(project); // Validate git repository await validateGitRepository(projectPath); const branch = await getCurrentBranchName(projectPath); const hasCommits = await repositoryHasCommits(projectPath); // Get git status const { stdout: statusOutput } = await spawnAsync('git', ['status', '--porcelain'], { cwd: projectPath }); const modified = []; const added = []; const deleted = []; const untracked = []; statusOutput.split('\n').forEach(line => { if (!line.trim()) return; const status = line.substring(0, 2); const file = line.substring(3); if (status === 'M ' || status === ' M' || status === 'MM') { modified.push(file); } else if (status === 'A ' || status === 'AM') { added.push(file); } else if (status === 'D ' || status === ' D') { deleted.push(file); } else if (status === '??') { untracked.push(file); } }); res.json({ branch, hasCommits, modified, added, deleted, untracked }); } catch (error) { console.error('Git status error:', error); res.json({ error: error.message.includes('not a git repository') || error.message.includes('Project directory is not a git repository') ? error.message : 'Git operation failed', details: error.message.includes('not a git repository') || error.message.includes('Project directory is not a git repository') ? error.message : `Failed to get git status: ${error.message}` }); } }); // Get diff for a specific file router.get('/diff', async (req, res) => { const { project, file } = req.query; if (!project || !file) { return res.status(400).json({ error: 'Project name and file path are required' }); } try { const projectPath = await getActualProjectPath(project); // Validate git repository await validateGitRepository(projectPath); const { repositoryRootPath, repositoryRelativeFilePath, } = await resolveRepositoryFilePath(projectPath, file); // Check if file is untracked or deleted const { stdout: statusOutput } = await spawnAsync( 'git', ['status', '--porcelain', '--', repositoryRelativeFilePath], { cwd: repositoryRootPath }, ); const isUntracked = statusOutput.startsWith('??'); const isDeleted = statusOutput.trim().startsWith('D ') || statusOutput.trim().startsWith(' D'); let diff; if (isUntracked) { // For untracked files, show the entire file content as additions const filePath = path.join(repositoryRootPath, repositoryRelativeFilePath); const stats = await fs.stat(filePath); if (stats.isDirectory()) { // For directories, show a simple message diff = `Directory: ${repositoryRelativeFilePath}\n(Cannot show diff for directories)`; } else { const fileContent = await fs.readFile(filePath, 'utf-8'); const lines = fileContent.split('\n'); diff = `--- /dev/null\n+++ b/${repositoryRelativeFilePath}\n@@ -0,0 +1,${lines.length} @@\n` + lines.map(line => `+${line}`).join('\n'); } } else if (isDeleted) { // For deleted files, show the entire file content from HEAD as deletions const { stdout: fileContent } = await spawnAsync( 'git', ['show', `HEAD:${repositoryRelativeFilePath}`], { cwd: repositoryRootPath }, ); const lines = fileContent.split('\n'); diff = `--- a/${repositoryRelativeFilePath}\n+++ /dev/null\n@@ -1,${lines.length} +0,0 @@\n` + lines.map(line => `-${line}`).join('\n'); } else { // Get diff for tracked files // First check for unstaged changes (working tree vs index) const { stdout: unstagedDiff } = await spawnAsync( 'git', ['diff', '--', repositoryRelativeFilePath], { cwd: repositoryRootPath }, ); if (unstagedDiff) { // Show unstaged changes if they exist diff = stripDiffHeaders(unstagedDiff); } else { // If no unstaged changes, check for staged changes (index vs HEAD) const { stdout: stagedDiff } = await spawnAsync( 'git', ['diff', '--cached', '--', repositoryRelativeFilePath], { cwd: repositoryRootPath }, ); diff = stripDiffHeaders(stagedDiff) || ''; } } res.json({ diff }); } catch (error) { console.error('Git diff error:', error); res.json({ error: error.message }); } }); // Get file content with diff information for CodeEditor router.get('/file-with-diff', async (req, res) => { const { project, file } = req.query; if (!project || !file) { return res.status(400).json({ error: 'Project name and file path are required' }); } try { const projectPath = await getActualProjectPath(project); // Validate git repository await validateGitRepository(projectPath); const { repositoryRootPath, repositoryRelativeFilePath, } = await resolveRepositoryFilePath(projectPath, file); // Check file status const { stdout: statusOutput } = await spawnAsync( 'git', ['status', '--porcelain', '--', repositoryRelativeFilePath], { cwd: repositoryRootPath }, ); const isUntracked = statusOutput.startsWith('??'); const isDeleted = statusOutput.trim().startsWith('D ') || statusOutput.trim().startsWith(' D'); let currentContent = ''; let oldContent = ''; if (isDeleted) { // For deleted files, get content from HEAD const { stdout: headContent } = await spawnAsync( 'git', ['show', `HEAD:${repositoryRelativeFilePath}`], { cwd: repositoryRootPath }, ); oldContent = headContent; currentContent = headContent; // Show the deleted content in editor } else { // Get current file content const filePath = path.join(repositoryRootPath, repositoryRelativeFilePath); const stats = await fs.stat(filePath); if (stats.isDirectory()) { // Cannot show content for directories return res.status(400).json({ error: 'Cannot show diff for directories' }); } currentContent = await fs.readFile(filePath, 'utf-8'); if (!isUntracked) { // Get the old content from HEAD for tracked files try { const { stdout: headContent } = await spawnAsync( 'git', ['show', `HEAD:${repositoryRelativeFilePath}`], { cwd: repositoryRootPath }, ); oldContent = headContent; } catch (error) { // File might be newly added to git (staged but not committed) oldContent = ''; } } } res.json({ currentContent, oldContent, isDeleted, isUntracked }); } catch (error) { console.error('Git file-with-diff error:', error); res.json({ error: error.message }); } }); // Create initial commit router.post('/initial-commit', async (req, res) => { const { project } = req.body; if (!project) { return res.status(400).json({ error: 'Project name is required' }); } try { const projectPath = await getActualProjectPath(project); // Validate git repository await validateGitRepository(projectPath); // Check if there are already commits try { await spawnAsync('git', ['rev-parse', 'HEAD'], { cwd: projectPath }); return res.status(400).json({ error: 'Repository already has commits. Use regular commit instead.' }); } catch (error) { // No HEAD - this is good, we can create initial commit } // Add all files await spawnAsync('git', ['add', '.'], { cwd: projectPath }); // Create initial commit const { stdout } = await spawnAsync('git', ['commit', '-m', 'Initial commit'], { cwd: projectPath }); res.json({ success: true, output: stdout, message: 'Initial commit created successfully' }); } catch (error) { console.error('Git initial commit error:', error); // Handle the case where there's nothing to commit if (error.message.includes('nothing to commit')) { return res.status(400).json({ error: 'Nothing to commit', details: 'No files found in the repository. Add some files first.' }); } res.status(500).json({ error: error.message }); } }); // Commit changes router.post('/commit', async (req, res) => { const { project, message, files } = req.body; if (!project || !message || !files || files.length === 0) { return res.status(400).json({ error: 'Project name, commit message, and files are required' }); } try { const projectPath = await getActualProjectPath(project); // Validate git repository await validateGitRepository(projectPath); const repositoryRootPath = await getRepositoryRootPath(projectPath); // Stage selected files for (const file of files) { const { repositoryRelativeFilePath } = await resolveRepositoryFilePath(projectPath, file); await spawnAsync('git', ['add', '--', repositoryRelativeFilePath], { cwd: repositoryRootPath }); } // Commit with message const { stdout } = await spawnAsync('git', ['commit', '-m', message], { cwd: repositoryRootPath }); res.json({ success: true, output: stdout }); } catch (error) { console.error('Git commit error:', error); res.status(500).json({ error: error.message }); } }); // Revert latest local commit (keeps changes staged) router.post('/revert-local-commit', async (req, res) => { const { project } = req.body; if (!project) { return res.status(400).json({ error: 'Project name is required' }); } try { const projectPath = await getActualProjectPath(project); await validateGitRepository(projectPath); try { await spawnAsync('git', ['rev-parse', '--verify', 'HEAD'], { cwd: projectPath }); } catch (error) { return res.status(400).json({ error: 'No local commit to revert', details: 'This repository has no commit yet.', }); } try { // Soft reset rewinds one commit while preserving all file changes in the index. await spawnAsync('git', ['reset', '--soft', 'HEAD~1'], { cwd: projectPath }); } catch (error) { const errorDetails = `${error.stderr || ''} ${error.message || ''}`; const isInitialCommit = errorDetails.includes('HEAD~1') && (errorDetails.includes('unknown revision') || errorDetails.includes('ambiguous argument')); if (!isInitialCommit) { throw error; } // Initial commit has no parent; deleting HEAD uncommits it and keeps files staged. await spawnAsync('git', ['update-ref', '-d', 'HEAD'], { cwd: projectPath }); } res.json({ success: true, output: 'Latest local commit reverted successfully. Changes were kept staged.', }); } catch (error) { console.error('Git revert local commit error:', error); res.status(500).json({ error: error.message }); } }); // Get list of branches router.get('/branches', async (req, res) => { const { project } = req.query; if (!project) { return res.status(400).json({ error: 'Project name is required' }); } try { const projectPath = await getActualProjectPath(project); // Validate git repository await validateGitRepository(projectPath); // Get all branches const { stdout } = await spawnAsync('git', ['branch', '-a'], { cwd: projectPath }); const rawLines = stdout .split('\n') .map(b => b.trim()) .filter(b => b && !b.includes('->')); // Local branches (may start with '* ' for current) const localBranches = rawLines .filter(b => !b.startsWith('remotes/')) .map(b => (b.startsWith('* ') ? b.substring(2) : b)); // Remote branches — strip 'remotes//' prefix const remoteBranches = rawLines .filter(b => b.startsWith('remotes/')) .map(b => b.replace(/^remotes\/[^/]+\//, '')) .filter(name => !localBranches.includes(name)); // skip if already a local branch // Backward-compat flat list (local + unique remotes, deduplicated) const branches = [...localBranches, ...remoteBranches] .filter((b, i, arr) => arr.indexOf(b) === i); res.json({ branches, localBranches, remoteBranches }); } catch (error) { console.error('Git branches error:', error); res.json({ error: error.message }); } }); // Checkout branch router.post('/checkout', async (req, res) => { const { project, branch } = req.body; if (!project || !branch) { return res.status(400).json({ error: 'Project name and branch are required' }); } try { const projectPath = await getActualProjectPath(project); // Checkout the branch validateBranchName(branch); const { stdout } = await spawnAsync('git', ['checkout', branch], { cwd: projectPath }); res.json({ success: true, output: stdout }); } catch (error) { console.error('Git checkout error:', error); res.status(500).json({ error: error.message }); } }); // Create new branch router.post('/create-branch', async (req, res) => { const { project, branch } = req.body; if (!project || !branch) { return res.status(400).json({ error: 'Project name and branch name are required' }); } try { const projectPath = await getActualProjectPath(project); // Create and checkout new branch validateBranchName(branch); const { stdout } = await spawnAsync('git', ['checkout', '-b', branch], { cwd: projectPath }); res.json({ success: true, output: stdout }); } catch (error) { console.error('Git create branch error:', error); res.status(500).json({ error: error.message }); } }); // Delete a local branch router.post('/delete-branch', async (req, res) => { const { project, branch } = req.body; if (!project || !branch) { return res.status(400).json({ error: 'Project name and branch name are required' }); } try { const projectPath = await getActualProjectPath(project); await validateGitRepository(projectPath); // Safety: cannot delete the currently checked-out branch const { stdout: currentBranch } = await spawnAsync('git', ['branch', '--show-current'], { cwd: projectPath }); if (currentBranch.trim() === branch) { return res.status(400).json({ error: 'Cannot delete the currently checked-out branch' }); } const { stdout } = await spawnAsync('git', ['branch', '-d', branch], { cwd: projectPath }); res.json({ success: true, output: stdout }); } catch (error) { console.error('Git delete branch error:', error); res.status(500).json({ error: error.message }); } }); // Get recent commits router.get('/commits', async (req, res) => { const { project, limit = 10 } = req.query; if (!project) { return res.status(400).json({ error: 'Project name is required' }); } try { const projectPath = await getActualProjectPath(project); await validateGitRepository(projectPath); const parsedLimit = Number.parseInt(String(limit), 10); const safeLimit = Number.isFinite(parsedLimit) && parsedLimit > 0 ? Math.min(parsedLimit, 100) : 10; // Get commit log with stats const { stdout } = await spawnAsync( 'git', ['log', '--pretty=format:%H|%an|%ae|%ad|%s', '--date=iso-strict', '-n', String(safeLimit)], { cwd: projectPath }, ); const commits = stdout .split('\n') .filter(line => line.trim()) .map(line => { const [hash, author, email, date, ...messageParts] = line.split('|'); return { hash, author, email, date, message: messageParts.join('|') }; }); // Get stats for each commit for (const commit of commits) { try { const { stdout: stats } = await spawnAsync( 'git', ['show', '--stat', '--format=', commit.hash], { cwd: projectPath } ); commit.stats = stats.trim().split('\n').pop(); // Get the summary line } catch (error) { commit.stats = ''; } } res.json({ commits }); } catch (error) { console.error('Git commits error:', error); res.json({ error: error.message }); } }); // Get diff for a specific commit router.get('/commit-diff', async (req, res) => { const { project, commit } = req.query; if (!project || !commit) { return res.status(400).json({ error: 'Project name and commit hash are required' }); } try { const projectPath = await getActualProjectPath(project); // Validate commit reference (defense-in-depth) validateCommitRef(commit); // Get diff for the commit const { stdout } = await spawnAsync( 'git', ['show', commit], { cwd: projectPath } ); const isTruncated = stdout.length > COMMIT_DIFF_CHARACTER_LIMIT; const diff = isTruncated ? `${stdout.slice(0, COMMIT_DIFF_CHARACTER_LIMIT)}\n\n... Diff truncated to keep the UI responsive ...` : stdout; res.json({ diff, isTruncated }); } catch (error) { console.error('Git commit diff error:', error); res.json({ error: error.message }); } }); // Generate commit message based on staged changes using AI router.post('/generate-commit-message', async (req, res) => { const { project, files, provider = 'claude' } = req.body; if (!project || !files || files.length === 0) { return res.status(400).json({ error: 'Project name and files are required' }); } // Validate provider if (!['claude', 'cursor'].includes(provider)) { return res.status(400).json({ error: 'provider must be "claude" or "cursor"' }); } try { const projectPath = await getActualProjectPath(project); await validateGitRepository(projectPath); const repositoryRootPath = await getRepositoryRootPath(projectPath); // Get diff for selected files let diffContext = ''; for (const file of files) { try { const { repositoryRelativeFilePath } = await resolveRepositoryFilePath(projectPath, file); const { stdout } = await spawnAsync( 'git', ['diff', 'HEAD', '--', repositoryRelativeFilePath], { cwd: repositoryRootPath } ); if (stdout) { diffContext += `\n--- ${repositoryRelativeFilePath} ---\n${stdout}`; } } catch (error) { console.error(`Error getting diff for ${file}:`, error); } } // If no diff found, might be untracked files if (!diffContext.trim()) { // Try to get content of untracked files for (const file of files) { try { const { repositoryRelativeFilePath } = await resolveRepositoryFilePath(projectPath, file); const filePath = path.join(repositoryRootPath, repositoryRelativeFilePath); const stats = await fs.stat(filePath); if (!stats.isDirectory()) { const content = await fs.readFile(filePath, 'utf-8'); diffContext += `\n--- ${repositoryRelativeFilePath} (new file) ---\n${content.substring(0, 1000)}\n`; } else { diffContext += `\n--- ${repositoryRelativeFilePath} (new directory) ---\n`; } } catch (error) { console.error(`Error reading file ${file}:`, error); } } } // Generate commit message using AI const message = await generateCommitMessageWithAI(files, diffContext, provider, projectPath); res.json({ message }); } catch (error) { console.error('Generate commit message error:', error); res.status(500).json({ error: error.message }); } }); /** * Generates a commit message using AI (Claude SDK or Cursor CLI) * @param {Array} files - List of changed files * @param {string} diffContext - Git diff content * @param {string} provider - 'claude' or 'cursor' * @param {string} projectPath - Project directory path * @returns {Promise} Generated commit message */ async function generateCommitMessageWithAI(files, diffContext, provider, projectPath) { // Create the prompt const prompt = `Generate a conventional commit message for these changes. REQUIREMENTS: - Format: type(scope): subject - Include body explaining what changed and why - Types: feat, fix, docs, style, refactor, perf, test, build, ci, chore - Subject under 50 chars, body wrapped at 72 chars - Focus on user-facing changes, not implementation details - Consider what's being added AND removed - Return ONLY the commit message (no markdown, explanations, or code blocks) FILES CHANGED: ${files.map(f => `- ${f}`).join('\n')} DIFFS: ${diffContext.substring(0, 4000)} Generate the commit message:`; try { // Create a simple writer that collects the response let responseText = ''; const writer = { send: (data) => { try { const parsed = typeof data === 'string' ? JSON.parse(data) : data; console.log('🔍 Writer received message type:', parsed.type); // Handle different message formats from Claude SDK and Cursor CLI // Claude SDK sends: {type: 'claude-response', data: {message: {content: [...]}}} if (parsed.type === 'claude-response' && parsed.data) { const message = parsed.data.message || parsed.data; console.log('📦 Claude response message:', JSON.stringify(message, null, 2).substring(0, 500)); if (message.content && Array.isArray(message.content)) { // Extract text from content array for (const item of message.content) { if (item.type === 'text' && item.text) { console.log('✅ Extracted text chunk:', item.text.substring(0, 100)); responseText += item.text; } } } } // Cursor CLI sends: {type: 'cursor-output', output: '...'} else if (parsed.type === 'cursor-output' && parsed.output) { console.log('✅ Cursor output:', parsed.output.substring(0, 100)); responseText += parsed.output; } // Also handle direct text messages else if (parsed.type === 'text' && parsed.text) { console.log('✅ Direct text:', parsed.text.substring(0, 100)); responseText += parsed.text; } } catch (e) { // Ignore parse errors console.error('Error parsing writer data:', e); } }, setSessionId: () => {}, // No-op for this use case }; console.log('🚀 Calling AI agent with provider:', provider); console.log('📝 Prompt length:', prompt.length); // Call the appropriate agent if (provider === 'claude') { await queryClaudeSDK(prompt, { cwd: projectPath, permissionMode: 'bypassPermissions', model: 'sonnet' }, writer); } else if (provider === 'cursor') { await spawnCursor(prompt, { cwd: projectPath, skipPermissions: true }, writer); } console.log('📊 Total response text collected:', responseText.length, 'characters'); console.log('📄 Response preview:', responseText.substring(0, 200)); // Clean up the response const cleanedMessage = cleanCommitMessage(responseText); console.log('🧹 Cleaned message:', cleanedMessage.substring(0, 200)); return cleanedMessage || 'chore: update files'; } catch (error) { console.error('Error generating commit message with AI:', error); // Fallback to simple message return `chore: update ${files.length} file${files.length !== 1 ? 's' : ''}`; } } /** * Cleans the AI-generated commit message by removing markdown, code blocks, and extra formatting * @param {string} text - Raw AI response * @returns {string} Clean commit message */ function cleanCommitMessage(text) { if (!text || !text.trim()) { return ''; } let cleaned = text.trim(); // Remove markdown code blocks cleaned = cleaned.replace(/```[a-z]*\n/g, ''); cleaned = cleaned.replace(/```/g, ''); // Remove markdown headers cleaned = cleaned.replace(/^#+\s*/gm, ''); // Remove leading/trailing quotes cleaned = cleaned.replace(/^["']|["']$/g, ''); // If there are multiple lines, take everything (subject + body) // Just clean up extra blank lines cleaned = cleaned.replace(/\n{3,}/g, '\n\n'); // Remove any explanatory text before the actual commit message // Look for conventional commit pattern and start from there const conventionalCommitMatch = cleaned.match(/(feat|fix|docs|style|refactor|perf|test|build|ci|chore)(\(.+?\))?:.+/s); if (conventionalCommitMatch) { cleaned = cleaned.substring(cleaned.indexOf(conventionalCommitMatch[0])); } return cleaned.trim(); } // Get remote status (ahead/behind commits with smart remote detection) router.get('/remote-status', async (req, res) => { const { project } = req.query; if (!project) { return res.status(400).json({ error: 'Project name is required' }); } try { const projectPath = await getActualProjectPath(project); await validateGitRepository(projectPath); const branch = await getCurrentBranchName(projectPath); const hasCommits = await repositoryHasCommits(projectPath); const { stdout: remoteOutput } = await spawnAsync('git', ['remote'], { cwd: projectPath }); const remotes = remoteOutput.trim().split('\n').filter(r => r.trim()); const hasRemote = remotes.length > 0; const fallbackRemoteName = hasRemote ? (remotes.includes('origin') ? 'origin' : remotes[0]) : null; // Repositories initialized with `git init` can have a branch but no commits. // Return a non-error state so the UI can show the initial-commit workflow. if (!hasCommits) { return res.json({ hasRemote, hasUpstream: false, branch, remoteName: fallbackRemoteName, ahead: 0, behind: 0, isUpToDate: false, message: 'Repository has no commits yet' }); } // Check if there's a remote tracking branch (smart detection) let trackingBranch; let remoteName; try { const { stdout } = await spawnAsync('git', ['rev-parse', '--abbrev-ref', `${branch}@{upstream}`], { cwd: projectPath }); trackingBranch = stdout.trim(); remoteName = trackingBranch.split('/')[0]; // Extract remote name (e.g., "origin/main" -> "origin") } catch (error) { return res.json({ hasRemote, hasUpstream: false, branch, remoteName: fallbackRemoteName, message: 'No remote tracking branch configured' }); } // Get ahead/behind counts const { stdout: countOutput } = await spawnAsync( 'git', ['rev-list', '--count', '--left-right', `${trackingBranch}...HEAD`], { cwd: projectPath } ); const [behind, ahead] = countOutput.trim().split('\t').map(Number); res.json({ hasRemote: true, hasUpstream: true, branch, remoteBranch: trackingBranch, remoteName, ahead: ahead || 0, behind: behind || 0, isUpToDate: ahead === 0 && behind === 0 }); } catch (error) { console.error('Git remote status error:', error); res.json({ error: error.message }); } }); // Fetch from remote (using smart remote detection) router.post('/fetch', async (req, res) => { const { project } = req.body; if (!project) { return res.status(400).json({ error: 'Project name is required' }); } try { const projectPath = await getActualProjectPath(project); await validateGitRepository(projectPath); // Get current branch and its upstream remote const branch = await getCurrentBranchName(projectPath); let remoteName = 'origin'; // fallback try { const { stdout } = await spawnAsync('git', ['rev-parse', '--abbrev-ref', `${branch}@{upstream}`], { cwd: projectPath }); remoteName = stdout.trim().split('/')[0]; // Extract remote name } catch (error) { // No upstream, try to fetch from origin anyway console.log('No upstream configured, using origin as fallback'); } validateRemoteName(remoteName); const { stdout } = await spawnAsync('git', ['fetch', remoteName], { cwd: projectPath }); res.json({ success: true, output: stdout || 'Fetch completed successfully', remoteName }); } catch (error) { console.error('Git fetch error:', error); res.status(500).json({ error: 'Fetch failed', details: error.message.includes('Could not resolve hostname') ? 'Unable to connect to remote repository. Check your internet connection.' : error.message.includes('fatal: \'origin\' does not appear to be a git repository') ? 'No remote repository configured. Add a remote with: git remote add origin ' : error.message }); } }); // Pull from remote (fetch + merge using smart remote detection) router.post('/pull', async (req, res) => { const { project } = req.body; if (!project) { return res.status(400).json({ error: 'Project name is required' }); } try { const projectPath = await getActualProjectPath(project); await validateGitRepository(projectPath); // Get current branch and its upstream remote const branch = await getCurrentBranchName(projectPath); let remoteName = 'origin'; // fallback let remoteBranch = branch; // fallback try { const { stdout } = await spawnAsync('git', ['rev-parse', '--abbrev-ref', `${branch}@{upstream}`], { cwd: projectPath }); const tracking = stdout.trim(); remoteName = tracking.split('/')[0]; // Extract remote name remoteBranch = tracking.split('/').slice(1).join('/'); // Extract branch name } catch (error) { // No upstream, use fallback console.log('No upstream configured, using origin/branch as fallback'); } validateRemoteName(remoteName); validateBranchName(remoteBranch); const { stdout } = await spawnAsync('git', ['pull', remoteName, remoteBranch], { cwd: projectPath }); res.json({ success: true, output: stdout || 'Pull completed successfully', remoteName, remoteBranch }); } catch (error) { console.error('Git pull error:', error); // Enhanced error handling for common pull scenarios let errorMessage = 'Pull failed'; let details = error.message; if (error.message.includes('CONFLICT')) { errorMessage = 'Merge conflicts detected'; details = 'Pull created merge conflicts. Please resolve conflicts manually in the editor, then commit the changes.'; } else if (error.message.includes('Please commit your changes or stash them')) { errorMessage = 'Uncommitted changes detected'; details = 'Please commit or stash your local changes before pulling.'; } else if (error.message.includes('Could not resolve hostname')) { errorMessage = 'Network error'; details = 'Unable to connect to remote repository. Check your internet connection.'; } else if (error.message.includes('fatal: \'origin\' does not appear to be a git repository')) { errorMessage = 'Remote not configured'; details = 'No remote repository configured. Add a remote with: git remote add origin '; } else if (error.message.includes('diverged')) { errorMessage = 'Branches have diverged'; details = 'Your local branch and remote branch have diverged. Consider fetching first to review changes.'; } res.status(500).json({ error: errorMessage, details: details }); } }); // Push commits to remote repository router.post('/push', async (req, res) => { const { project } = req.body; if (!project) { return res.status(400).json({ error: 'Project name is required' }); } try { const projectPath = await getActualProjectPath(project); await validateGitRepository(projectPath); // Get current branch and its upstream remote const branch = await getCurrentBranchName(projectPath); let remoteName = 'origin'; // fallback let remoteBranch = branch; // fallback try { const { stdout } = await spawnAsync('git', ['rev-parse', '--abbrev-ref', `${branch}@{upstream}`], { cwd: projectPath }); const tracking = stdout.trim(); remoteName = tracking.split('/')[0]; // Extract remote name remoteBranch = tracking.split('/').slice(1).join('/'); // Extract branch name } catch (error) { // No upstream, use fallback console.log('No upstream configured, using origin/branch as fallback'); } validateRemoteName(remoteName); validateBranchName(remoteBranch); const { stdout } = await spawnAsync('git', ['push', remoteName, remoteBranch], { cwd: projectPath }); res.json({ success: true, output: stdout || 'Push completed successfully', remoteName, remoteBranch }); } catch (error) { console.error('Git push error:', error); // Enhanced error handling for common push scenarios let errorMessage = 'Push failed'; let details = error.message; if (error.message.includes('rejected')) { errorMessage = 'Push rejected'; details = 'The remote has newer commits. Pull first to merge changes before pushing.'; } else if (error.message.includes('non-fast-forward')) { errorMessage = 'Non-fast-forward push'; details = 'Your branch is behind the remote. Pull the latest changes first.'; } else if (error.message.includes('Could not resolve hostname')) { errorMessage = 'Network error'; details = 'Unable to connect to remote repository. Check your internet connection.'; } else if (error.message.includes('fatal: \'origin\' does not appear to be a git repository')) { errorMessage = 'Remote not configured'; details = 'No remote repository configured. Add a remote with: git remote add origin '; } else if (error.message.includes('Permission denied')) { errorMessage = 'Authentication failed'; details = 'Permission denied. Check your credentials or SSH keys.'; } else if (error.message.includes('no upstream branch')) { errorMessage = 'No upstream branch'; details = 'No upstream branch configured. Use: git push --set-upstream origin '; } res.status(500).json({ error: errorMessage, details: details }); } }); // Publish branch to remote (set upstream and push) router.post('/publish', async (req, res) => { const { project, branch } = req.body; if (!project || !branch) { return res.status(400).json({ error: 'Project name and branch are required' }); } try { const projectPath = await getActualProjectPath(project); await validateGitRepository(projectPath); // Validate branch name validateBranchName(branch); // Get current branch to verify it matches the requested branch const currentBranchName = await getCurrentBranchName(projectPath); if (currentBranchName !== branch) { return res.status(400).json({ error: `Branch mismatch. Current branch is ${currentBranchName}, but trying to publish ${branch}` }); } // Check if remote exists let remoteName = 'origin'; try { const { stdout } = await spawnAsync('git', ['remote'], { cwd: projectPath }); const remotes = stdout.trim().split('\n').filter(r => r.trim()); if (remotes.length === 0) { return res.status(400).json({ error: 'No remote repository configured. Add a remote with: git remote add origin ' }); } remoteName = remotes.includes('origin') ? 'origin' : remotes[0]; } catch (error) { return res.status(400).json({ error: 'No remote repository configured. Add a remote with: git remote add origin ' }); } // Publish the branch (set upstream and push) validateRemoteName(remoteName); const { stdout } = await spawnAsync('git', ['push', '--set-upstream', remoteName, branch], { cwd: projectPath }); res.json({ success: true, output: stdout || 'Branch published successfully', remoteName, branch }); } catch (error) { console.error('Git publish error:', error); // Enhanced error handling for common publish scenarios let errorMessage = 'Publish failed'; let details = error.message; if (error.message.includes('rejected')) { errorMessage = 'Publish rejected'; details = 'The remote branch already exists and has different commits. Use push instead.'; } else if (error.message.includes('Could not resolve hostname')) { errorMessage = 'Network error'; details = 'Unable to connect to remote repository. Check your internet connection.'; } else if (error.message.includes('Permission denied')) { errorMessage = 'Authentication failed'; details = 'Permission denied. Check your credentials or SSH keys.'; } else if (error.message.includes('fatal:') && error.message.includes('does not appear to be a git repository')) { errorMessage = 'Remote not configured'; details = 'Remote repository not properly configured. Check your remote URL.'; } res.status(500).json({ error: errorMessage, details: details }); } }); // Discard changes for a specific file router.post('/discard', async (req, res) => { const { project, file } = req.body; if (!project || !file) { return res.status(400).json({ error: 'Project name and file path are required' }); } try { const projectPath = await getActualProjectPath(project); await validateGitRepository(projectPath); const { repositoryRootPath, repositoryRelativeFilePath, } = await resolveRepositoryFilePath(projectPath, file); // Check file status to determine correct discard command const { stdout: statusOutput } = await spawnAsync( 'git', ['status', '--porcelain', '--', repositoryRelativeFilePath], { cwd: repositoryRootPath }, ); if (!statusOutput.trim()) { return res.status(400).json({ error: 'No changes to discard for this file' }); } const status = statusOutput.substring(0, 2); if (status === '??') { // Untracked file or directory - delete it const filePath = path.join(repositoryRootPath, repositoryRelativeFilePath); const stats = await fs.stat(filePath); if (stats.isDirectory()) { await fs.rm(filePath, { recursive: true, force: true }); } else { await fs.unlink(filePath); } } else if (status.includes('M') || status.includes('D')) { // Modified or deleted file - restore from HEAD await spawnAsync('git', ['restore', '--', repositoryRelativeFilePath], { cwd: repositoryRootPath }); } else if (status.includes('A')) { // Added file - unstage it await spawnAsync('git', ['reset', 'HEAD', '--', repositoryRelativeFilePath], { cwd: repositoryRootPath }); } res.json({ success: true, message: `Changes discarded for ${repositoryRelativeFilePath}` }); } catch (error) { console.error('Git discard error:', error); res.status(500).json({ error: error.message }); } }); // Delete untracked file router.post('/delete-untracked', async (req, res) => { const { project, file } = req.body; if (!project || !file) { return res.status(400).json({ error: 'Project name and file path are required' }); } try { const projectPath = await getActualProjectPath(project); await validateGitRepository(projectPath); const { repositoryRootPath, repositoryRelativeFilePath, } = await resolveRepositoryFilePath(projectPath, file); // Check if file is actually untracked const { stdout: statusOutput } = await spawnAsync( 'git', ['status', '--porcelain', '--', repositoryRelativeFilePath], { cwd: repositoryRootPath }, ); if (!statusOutput.trim()) { return res.status(400).json({ error: 'File is not untracked or does not exist' }); } const status = statusOutput.substring(0, 2); if (status !== '??') { return res.status(400).json({ error: 'File is not untracked. Use discard for tracked files.' }); } // Delete the untracked file or directory const filePath = path.join(repositoryRootPath, repositoryRelativeFilePath); const stats = await fs.stat(filePath); if (stats.isDirectory()) { // Use rm with recursive option for directories await fs.rm(filePath, { recursive: true, force: true }); res.json({ success: true, message: `Untracked directory ${repositoryRelativeFilePath} deleted successfully` }); } else { await fs.unlink(filePath); res.json({ success: true, message: `Untracked file ${repositoryRelativeFilePath} deleted successfully` }); } } catch (error) { console.error('Git delete untracked error:', error); res.status(500).json({ error: error.message }); } }); export default router; ================================================ FILE: server/routes/mcp-utils.js ================================================ /** * MCP UTILITIES API ROUTES * ======================== * * API endpoints for MCP server detection and configuration utilities. * These endpoints expose centralized MCP detection functionality. */ import express from 'express'; import { detectTaskMasterMCPServer, getAllMCPServers } from '../utils/mcp-detector.js'; const router = express.Router(); /** * GET /api/mcp-utils/taskmaster-server * Check if TaskMaster MCP server is configured */ router.get('/taskmaster-server', async (req, res) => { try { const result = await detectTaskMasterMCPServer(); res.json(result); } catch (error) { console.error('TaskMaster MCP detection error:', error); res.status(500).json({ error: 'Failed to detect TaskMaster MCP server', message: error.message }); } }); /** * 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; ================================================ FILE: server/routes/mcp.js ================================================ 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 [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 '' 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 ' 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; ================================================ FILE: server/routes/messages.js ================================================ /** * Unified messages endpoint. * * GET /api/sessions/:sessionId/messages?provider=claude&projectName=foo&limit=50&offset=0 * * Replaces the four provider-specific session message endpoints with a single route * that delegates to the appropriate adapter via the provider registry. * * @module routes/messages */ import express from 'express'; import { getProvider, getAllProviders } from '../providers/registry.js'; const router = express.Router(); /** * GET /api/sessions/:sessionId/messages * * Auth: authenticateToken applied at mount level in index.js * * Query params: * provider - 'claude' | 'cursor' | 'codex' | 'gemini' (default: 'claude') * projectName - required for claude provider * projectPath - required for cursor provider (absolute path used for cwdId hash) * limit - page size (omit or null for all) * offset - pagination offset (default: 0) */ router.get('/:sessionId/messages', async (req, res) => { try { const { sessionId } = req.params; const provider = req.query.provider || 'claude'; const projectName = req.query.projectName || ''; const projectPath = req.query.projectPath || ''; const limitParam = req.query.limit; const limit = limitParam !== undefined && limitParam !== null && limitParam !== '' ? parseInt(limitParam, 10) : null; const offset = parseInt(req.query.offset || '0', 10); const adapter = getProvider(provider); if (!adapter) { const available = getAllProviders().join(', '); return res.status(400).json({ error: `Unknown provider: ${provider}. Available: ${available}` }); } const result = await adapter.fetchHistory(sessionId, { projectName, projectPath, limit, offset, }); return res.json(result); } catch (error) { console.error('Error fetching unified messages:', error); return res.status(500).json({ error: 'Failed to fetch messages' }); } }); export default router; ================================================ FILE: server/routes/plugins.js ================================================ import express from 'express'; import path from 'path'; import http from 'http'; import mime from 'mime-types'; import fs from 'fs'; import { scanPlugins, getPluginsConfig, getPluginsDir, savePluginsConfig, getPluginDir, resolvePluginAssetPath, installPluginFromGit, updatePluginFromGit, uninstallPlugin, } from '../utils/plugin-loader.js'; import { startPluginServer, stopPluginServer, getPluginPort, isPluginRunning, } from '../utils/plugin-process-manager.js'; const router = express.Router(); // GET / — List all installed plugins (includes server running status) router.get('/', (req, res) => { try { const plugins = scanPlugins().map(p => ({ ...p, serverRunning: p.server ? isPluginRunning(p.name) : false, })); res.json({ plugins }); } catch (err) { res.status(500).json({ error: 'Failed to scan plugins', details: err.message }); } }); // GET /:name/manifest — Get single plugin manifest router.get('/:name/manifest', (req, res) => { try { if (!/^[a-zA-Z0-9_-]+$/.test(req.params.name)) { return res.status(400).json({ error: 'Invalid plugin name' }); } const plugins = scanPlugins(); const plugin = plugins.find(p => p.name === req.params.name); if (!plugin) { return res.status(404).json({ error: 'Plugin not found' }); } res.json(plugin); } catch (err) { res.status(500).json({ error: 'Failed to read plugin manifest', details: err.message }); } }); // GET /:name/assets/* — Serve plugin static files router.get('/:name/assets/*', (req, res) => { const pluginName = req.params.name; if (!/^[a-zA-Z0-9_-]+$/.test(pluginName)) { return res.status(400).json({ error: 'Invalid plugin name' }); } const assetPath = req.params[0]; if (!assetPath) { return res.status(400).json({ error: 'No asset path specified' }); } const resolvedPath = resolvePluginAssetPath(pluginName, assetPath); if (!resolvedPath) { return res.status(404).json({ error: 'Asset not found' }); } try { const stat = fs.statSync(resolvedPath); if (!stat.isFile()) { return res.status(404).json({ error: 'Asset not found' }); } } catch { return res.status(404).json({ error: 'Asset not found' }); } const contentType = mime.lookup(resolvedPath) || 'application/octet-stream'; res.setHeader('Content-Type', contentType); // Prevent CDN/proxy caching of plugin assets so updates take effect immediately res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate'); res.setHeader('Pragma', 'no-cache'); res.setHeader('Expires', '0'); const stream = fs.createReadStream(resolvedPath); stream.on('error', () => { if (!res.headersSent) { res.status(500).json({ error: 'Failed to read asset' }); } else { res.end(); } }); stream.pipe(res); }); // PUT /:name/enable — Toggle plugin enabled/disabled (starts/stops server if applicable) router.put('/:name/enable', async (req, res) => { try { const { enabled } = req.body; if (typeof enabled !== 'boolean') { return res.status(400).json({ error: '"enabled" must be a boolean' }); } const plugins = scanPlugins(); const plugin = plugins.find(p => p.name === req.params.name); if (!plugin) { return res.status(404).json({ error: 'Plugin not found' }); } const config = getPluginsConfig(); config[req.params.name] = { ...config[req.params.name], enabled }; savePluginsConfig(config); // Start or stop the plugin server as needed if (plugin.server) { if (enabled && !isPluginRunning(plugin.name)) { const pluginDir = getPluginDir(plugin.name); if (pluginDir) { try { await startPluginServer(plugin.name, pluginDir, plugin.server); } catch (err) { console.error(`[Plugins] Failed to start server for "${plugin.name}":`, err.message); } } } else if (!enabled && isPluginRunning(plugin.name)) { await stopPluginServer(plugin.name); } } res.json({ success: true, name: req.params.name, enabled }); } catch (err) { res.status(500).json({ error: 'Failed to update plugin', details: err.message }); } }); // POST /install — Install plugin from git URL router.post('/install', async (req, res) => { try { const { url } = req.body; if (!url || typeof url !== 'string') { return res.status(400).json({ error: '"url" is required and must be a string' }); } // Basic URL validation if (!url.startsWith('https://') && !url.startsWith('git@')) { return res.status(400).json({ error: 'URL must start with https:// or git@' }); } const manifest = await installPluginFromGit(url); // Auto-start the server if the plugin has one (enabled by default) if (manifest.server) { const pluginDir = getPluginDir(manifest.name); if (pluginDir) { try { await startPluginServer(manifest.name, pluginDir, manifest.server); } catch (err) { console.error(`[Plugins] Failed to start server for "${manifest.name}":`, err.message); } } } res.json({ success: true, plugin: manifest }); } catch (err) { res.status(400).json({ error: 'Failed to install plugin', details: err.message }); } }); // POST /:name/update — Pull latest from git (restarts server if running) router.post('/:name/update', async (req, res) => { try { const pluginName = req.params.name; if (!/^[a-zA-Z0-9_-]+$/.test(pluginName)) { return res.status(400).json({ error: 'Invalid plugin name' }); } const wasRunning = isPluginRunning(pluginName); if (wasRunning) { await stopPluginServer(pluginName); } const manifest = await updatePluginFromGit(pluginName); // Restart server if it was running before the update if (wasRunning && manifest.server) { const pluginDir = getPluginDir(pluginName); if (pluginDir) { try { await startPluginServer(pluginName, pluginDir, manifest.server); } catch (err) { console.error(`[Plugins] Failed to restart server for "${pluginName}":`, err.message); } } } res.json({ success: true, plugin: manifest }); } catch (err) { res.status(400).json({ error: 'Failed to update plugin', details: err.message }); } }); // ALL /:name/rpc/* — Proxy requests to plugin's server subprocess router.all('/:name/rpc/*', async (req, res) => { const pluginName = req.params.name; const rpcPath = req.params[0] || ''; if (!/^[a-zA-Z0-9_-]+$/.test(pluginName)) { return res.status(400).json({ error: 'Invalid plugin name' }); } let port = getPluginPort(pluginName); if (!port) { // Lazily start the plugin server if it exists and is enabled const plugins = scanPlugins(); const plugin = plugins.find(p => p.name === pluginName); if (!plugin || !plugin.server) { return res.status(503).json({ error: 'Plugin server is not running' }); } if (!plugin.enabled) { return res.status(503).json({ error: 'Plugin is disabled' }); } const pluginDir = path.join(getPluginsDir(), plugin.dirName); try { port = await startPluginServer(pluginName, pluginDir, plugin.server); } catch (err) { return res.status(503).json({ error: 'Plugin server failed to start', details: err.message }); } } // Inject configured secrets as headers const config = getPluginsConfig(); const pluginConfig = config[pluginName] || {}; const secrets = pluginConfig.secrets || {}; const headers = { 'content-type': req.headers['content-type'] || 'application/json', }; // Add per-plugin user-configured secrets as X-Plugin-Secret-* headers for (const [key, value] of Object.entries(secrets)) { headers[`x-plugin-secret-${key.toLowerCase()}`] = String(value); } // Reconstruct query string const qs = req.url.includes('?') ? '?' + req.url.split('?').slice(1).join('?') : ''; const options = { hostname: '127.0.0.1', port, path: `/${rpcPath}${qs}`, method: req.method, headers, }; const proxyReq = http.request(options, (proxyRes) => { res.writeHead(proxyRes.statusCode, proxyRes.headers); proxyRes.pipe(res); }); proxyReq.on('error', (err) => { if (!res.headersSent) { res.status(502).json({ error: 'Plugin server error', details: err.message }); } else { res.end(); } }); // Forward body (already parsed by express JSON middleware, so re-stringify). // Check content-length to detect whether a body was actually sent, since // req.body can be falsy for valid payloads like 0, false, null, or {}. const hasBody = req.headers['content-length'] && parseInt(req.headers['content-length'], 10) > 0; if (hasBody && req.body !== undefined) { const bodyStr = JSON.stringify(req.body); proxyReq.setHeader('content-length', Buffer.byteLength(bodyStr)); proxyReq.write(bodyStr); } proxyReq.end(); }); // DELETE /:name — Uninstall plugin (stops server first) router.delete('/:name', async (req, res) => { try { const pluginName = req.params.name; // Validate name format to prevent path traversal if (!/^[a-zA-Z0-9_-]+$/.test(pluginName)) { return res.status(400).json({ error: 'Invalid plugin name' }); } // Stop server and wait for the process to fully exit before deleting files if (isPluginRunning(pluginName)) { await stopPluginServer(pluginName); } await uninstallPlugin(pluginName); res.json({ success: true, name: pluginName }); } catch (err) { res.status(400).json({ error: 'Failed to uninstall plugin', details: err.message }); } }); export default router; ================================================ FILE: server/routes/projects.js ================================================ import express from 'express'; import { promises as fs } from 'fs'; import path from 'path'; import { spawn } from 'child_process'; import os from 'os'; import { addProjectManually } from '../projects.js'; const router = express.Router(); function sanitizeGitError(message, token) { if (!message || !token) return message; return message.replace(new RegExp(token.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'), '***'); } // Configure allowed workspace root (defaults to user's home directory) export const WORKSPACES_ROOT = process.env.WORKSPACES_ROOT || os.homedir(); // System-critical paths that should never be used as workspace directories export const FORBIDDEN_PATHS = [ // Unix '/', '/etc', '/bin', '/sbin', '/usr', '/dev', '/proc', '/sys', '/var', '/boot', '/root', '/lib', '/lib64', '/opt', '/tmp', '/run', // Windows 'C:\\Windows', 'C:\\Program Files', 'C:\\Program Files (x86)', 'C:\\ProgramData', 'C:\\System Volume Information', 'C:\\$Recycle.Bin' ]; /** * Validates that a path is safe for workspace operations * @param {string} requestedPath - The path to validate * @returns {Promise<{valid: boolean, resolvedPath?: string, error?: string}>} */ export async function validateWorkspacePath(requestedPath) { try { // Resolve to absolute path let absolutePath = path.resolve(requestedPath); // Check if path is a forbidden system directory const normalizedPath = path.normalize(absolutePath); if (FORBIDDEN_PATHS.includes(normalizedPath) || normalizedPath === '/') { return { valid: false, error: 'Cannot use system-critical directories as workspace locations' }; } // Additional check for paths starting with forbidden directories for (const forbidden of FORBIDDEN_PATHS) { if (normalizedPath === forbidden || normalizedPath.startsWith(forbidden + path.sep)) { // Exception: /var/tmp and similar user-accessible paths might be allowed // but /var itself and most /var subdirectories should be blocked if (forbidden === '/var' && (normalizedPath.startsWith('/var/tmp') || normalizedPath.startsWith('/var/folders'))) { continue; // Allow these specific cases } return { valid: false, error: `Cannot create workspace in system directory: ${forbidden}` }; } } // Try to resolve the real path (following symlinks) let realPath; try { // Check if path exists to resolve real path await fs.access(absolutePath); realPath = await fs.realpath(absolutePath); } catch (error) { if (error.code === 'ENOENT') { // Path doesn't exist yet - check parent directory let parentPath = path.dirname(absolutePath); try { const parentRealPath = await fs.realpath(parentPath); // Reconstruct the full path with real parent realPath = path.join(parentRealPath, path.basename(absolutePath)); } catch (parentError) { if (parentError.code === 'ENOENT') { // Parent doesn't exist either - use the absolute path as-is // We'll validate it's within allowed root realPath = absolutePath; } else { throw parentError; } } } else { throw error; } } // Resolve the workspace root to its real path const resolvedWorkspaceRoot = await fs.realpath(WORKSPACES_ROOT); // Ensure the resolved path is contained within the allowed workspace root if (!realPath.startsWith(resolvedWorkspaceRoot + path.sep) && realPath !== resolvedWorkspaceRoot) { return { valid: false, error: `Workspace path must be within the allowed workspace root: ${WORKSPACES_ROOT}` }; } // Additional symlink check for existing paths try { await fs.access(absolutePath); const stats = await fs.lstat(absolutePath); if (stats.isSymbolicLink()) { // Verify symlink target is also within allowed root const linkTarget = await fs.readlink(absolutePath); const resolvedTarget = path.resolve(path.dirname(absolutePath), linkTarget); const realTarget = await fs.realpath(resolvedTarget); if (!realTarget.startsWith(resolvedWorkspaceRoot + path.sep) && realTarget !== resolvedWorkspaceRoot) { return { valid: false, error: 'Symlink target is outside the allowed workspace root' }; } } } catch (error) { if (error.code !== 'ENOENT') { throw error; } // Path doesn't exist - that's fine for new workspace creation } return { valid: true, resolvedPath: realPath }; } catch (error) { return { valid: false, error: `Path validation failed: ${error.message}` }; } } /** * Create a new workspace * POST /api/projects/create-workspace * * Body: * - workspaceType: 'existing' | 'new' * - path: string (workspace path) * - githubUrl?: string (optional, for new workspaces) * - githubTokenId?: number (optional, ID of stored token) * - newGithubToken?: string (optional, one-time token) */ router.post('/create-workspace', async (req, res) => { try { const { workspaceType, path: workspacePath, githubUrl, githubTokenId, newGithubToken } = req.body; // Validate required fields if (!workspaceType || !workspacePath) { return res.status(400).json({ error: 'workspaceType and path are required' }); } if (!['existing', 'new'].includes(workspaceType)) { return res.status(400).json({ error: 'workspaceType must be "existing" or "new"' }); } // Validate path safety before any operations const validation = await validateWorkspacePath(workspacePath); if (!validation.valid) { return res.status(400).json({ error: 'Invalid workspace path', details: validation.error }); } const absolutePath = validation.resolvedPath; // Handle existing workspace if (workspaceType === 'existing') { // Check if the path exists try { await fs.access(absolutePath); const stats = await fs.stat(absolutePath); if (!stats.isDirectory()) { return res.status(400).json({ error: 'Path exists but is not a directory' }); } } catch (error) { if (error.code === 'ENOENT') { return res.status(404).json({ error: 'Workspace path does not exist' }); } throw error; } // Add the existing workspace to the project list const project = await addProjectManually(absolutePath); return res.json({ success: true, project, message: 'Existing workspace added successfully' }); } // Handle new workspace creation if (workspaceType === 'new') { // Create the directory if it doesn't exist await fs.mkdir(absolutePath, { recursive: true }); // If GitHub URL is provided, clone the repository if (githubUrl) { let githubToken = null; // Get GitHub token if needed if (githubTokenId) { // Fetch token from database const token = await getGithubTokenById(githubTokenId, req.user.id); if (!token) { // Clean up created directory await fs.rm(absolutePath, { recursive: true, force: true }); return res.status(404).json({ error: 'GitHub token not found' }); } githubToken = token.github_token; } else if (newGithubToken) { githubToken = newGithubToken; } // Extract repo name from URL for the clone destination const normalizedUrl = githubUrl.replace(/\/+$/, '').replace(/\.git$/, ''); const repoName = normalizedUrl.split('/').pop() || 'repository'; const clonePath = path.join(absolutePath, repoName); // Check if clone destination already exists to prevent data loss try { await fs.access(clonePath); return res.status(409).json({ error: 'Directory already exists', details: `The destination path "${clonePath}" already exists. Please choose a different location or remove the existing directory.` }); } catch (err) { // Directory doesn't exist, which is what we want } // Clone the repository into a subfolder try { await cloneGitHubRepository(githubUrl, clonePath, githubToken); } catch (error) { // Only clean up if clone created partial data (check if dir exists and is empty or partial) try { const stats = await fs.stat(clonePath); if (stats.isDirectory()) { await fs.rm(clonePath, { recursive: true, force: true }); } } catch (cleanupError) { // Directory doesn't exist or cleanup failed - ignore } throw new Error(`Failed to clone repository: ${error.message}`); } // Add the cloned repo path to the project list const project = await addProjectManually(clonePath); return res.json({ success: true, project, message: 'New workspace created and repository cloned successfully' }); } // Add the new workspace to the project list (no clone) const project = await addProjectManually(absolutePath); return res.json({ success: true, project, message: 'New workspace created successfully' }); } } catch (error) { console.error('Error creating workspace:', error); res.status(500).json({ error: error.message || 'Failed to create workspace', details: process.env.NODE_ENV === 'development' ? error.stack : undefined }); } }); /** * Helper function to get GitHub token from database */ async function getGithubTokenById(tokenId, userId) { const { db } = await import('../database/db.js'); const credential = db.prepare( 'SELECT * FROM user_credentials WHERE id = ? AND user_id = ? AND credential_type = ? AND is_active = 1' ).get(tokenId, userId, 'github_token'); // Return in the expected format (github_token field for compatibility) if (credential) { return { ...credential, github_token: credential.credential_value }; } return null; } /** * Clone repository with progress streaming (SSE) * GET /api/projects/clone-progress */ router.get('/clone-progress', async (req, res) => { const { path: workspacePath, githubUrl, githubTokenId, newGithubToken } = req.query; res.setHeader('Content-Type', 'text/event-stream'); res.setHeader('Cache-Control', 'no-cache'); res.setHeader('Connection', 'keep-alive'); res.flushHeaders(); const sendEvent = (type, data) => { res.write(`data: ${JSON.stringify({ type, ...data })}\n\n`); }; try { if (!workspacePath || !githubUrl) { sendEvent('error', { message: 'workspacePath and githubUrl are required' }); res.end(); return; } const validation = await validateWorkspacePath(workspacePath); if (!validation.valid) { sendEvent('error', { message: validation.error }); res.end(); return; } const absolutePath = validation.resolvedPath; await fs.mkdir(absolutePath, { recursive: true }); let githubToken = null; if (githubTokenId) { const token = await getGithubTokenById(parseInt(githubTokenId), req.user.id); if (!token) { await fs.rm(absolutePath, { recursive: true, force: true }); sendEvent('error', { message: 'GitHub token not found' }); res.end(); return; } githubToken = token.github_token; } else if (newGithubToken) { githubToken = newGithubToken; } const normalizedUrl = githubUrl.replace(/\/+$/, '').replace(/\.git$/, ''); const repoName = normalizedUrl.split('/').pop() || 'repository'; const clonePath = path.join(absolutePath, repoName); // Check if clone destination already exists to prevent data loss try { await fs.access(clonePath); sendEvent('error', { message: `Directory "${repoName}" already exists. Please choose a different location or remove the existing directory.` }); res.end(); return; } catch (err) { // Directory doesn't exist, which is what we want } let cloneUrl = githubUrl; if (githubToken) { try { const url = new URL(githubUrl); url.username = githubToken; url.password = ''; cloneUrl = url.toString(); } catch (error) { // SSH URL or invalid - use as-is } } sendEvent('progress', { message: `Cloning into '${repoName}'...` }); const gitProcess = spawn('git', ['clone', '--progress', cloneUrl, clonePath], { stdio: ['ignore', 'pipe', 'pipe'], env: { ...process.env, GIT_TERMINAL_PROMPT: '0' } }); let lastError = ''; gitProcess.stdout.on('data', (data) => { const message = data.toString().trim(); if (message) { sendEvent('progress', { message }); } }); gitProcess.stderr.on('data', (data) => { const message = data.toString().trim(); lastError = message; if (message) { sendEvent('progress', { message }); } }); gitProcess.on('close', async (code) => { if (code === 0) { try { const project = await addProjectManually(clonePath); sendEvent('complete', { project, message: 'Repository cloned successfully' }); } catch (error) { sendEvent('error', { message: `Clone succeeded but failed to add project: ${error.message}` }); } } else { const sanitizedError = sanitizeGitError(lastError, githubToken); let errorMessage = 'Git clone failed'; if (lastError.includes('Authentication failed') || lastError.includes('could not read Username')) { errorMessage = 'Authentication failed. Please check your credentials.'; } else if (lastError.includes('Repository not found')) { errorMessage = 'Repository not found. Please check the URL and ensure you have access.'; } else if (lastError.includes('already exists')) { errorMessage = 'Directory already exists'; } else if (sanitizedError) { errorMessage = sanitizedError; } try { await fs.rm(clonePath, { recursive: true, force: true }); } catch (cleanupError) { console.error('Failed to clean up after clone failure:', sanitizeGitError(cleanupError.message, githubToken)); } sendEvent('error', { message: errorMessage }); } res.end(); }); gitProcess.on('error', (error) => { if (error.code === 'ENOENT') { sendEvent('error', { message: 'Git is not installed or not in PATH' }); } else { sendEvent('error', { message: error.message }); } res.end(); }); req.on('close', () => { gitProcess.kill(); }); } catch (error) { sendEvent('error', { message: error.message }); res.end(); } }); /** * Helper function to clone a GitHub repository */ function cloneGitHubRepository(githubUrl, destinationPath, githubToken = null) { return new Promise((resolve, reject) => { let cloneUrl = githubUrl; if (githubToken) { try { const url = new URL(githubUrl); url.username = githubToken; url.password = ''; cloneUrl = url.toString(); } catch (error) { // SSH URL - use as-is } } const gitProcess = spawn('git', ['clone', '--progress', cloneUrl, destinationPath], { stdio: ['ignore', 'pipe', 'pipe'], env: { ...process.env, GIT_TERMINAL_PROMPT: '0' } }); let stdout = ''; let stderr = ''; gitProcess.stdout.on('data', (data) => { stdout += data.toString(); }); gitProcess.stderr.on('data', (data) => { stderr += data.toString(); }); gitProcess.on('close', (code) => { if (code === 0) { resolve({ stdout, stderr }); } else { let errorMessage = 'Git clone failed'; if (stderr.includes('Authentication failed') || stderr.includes('could not read Username')) { errorMessage = 'Authentication failed. Please check your GitHub token.'; } else if (stderr.includes('Repository not found')) { errorMessage = 'Repository not found. Please check the URL and ensure you have access.'; } else if (stderr.includes('already exists')) { errorMessage = 'Directory already exists'; } else if (stderr) { errorMessage = stderr; } reject(new Error(errorMessage)); } }); gitProcess.on('error', (error) => { if (error.code === 'ENOENT') { reject(new Error('Git is not installed or not in PATH')); } else { reject(error); } }); }); } export default router; ================================================ FILE: server/routes/settings.js ================================================ import express from 'express'; import { apiKeysDb, credentialsDb, notificationPreferencesDb, pushSubscriptionsDb } from '../database/db.js'; import { getPublicKey } from '../services/vapid-keys.js'; import { createNotificationEvent, notifyUserIfEnabled } from '../services/notification-orchestrator.js'; const router = express.Router(); // =============================== // API Keys Management // =============================== // Get all API keys for the authenticated user router.get('/api-keys', async (req, res) => { try { const apiKeys = apiKeysDb.getApiKeys(req.user.id); // Don't send the full API key in the list for security const sanitizedKeys = apiKeys.map(key => ({ ...key, api_key: key.api_key.substring(0, 10) + '...' })); res.json({ apiKeys: sanitizedKeys }); } catch (error) { console.error('Error fetching API keys:', error); res.status(500).json({ error: 'Failed to fetch API keys' }); } }); // Create a new API key router.post('/api-keys', async (req, res) => { try { const { keyName } = req.body; if (!keyName || !keyName.trim()) { return res.status(400).json({ error: 'Key name is required' }); } const result = apiKeysDb.createApiKey(req.user.id, keyName.trim()); res.json({ success: true, apiKey: result }); } catch (error) { console.error('Error creating API key:', error); res.status(500).json({ error: 'Failed to create API key' }); } }); // Delete an API key router.delete('/api-keys/:keyId', async (req, res) => { try { const { keyId } = req.params; const success = apiKeysDb.deleteApiKey(req.user.id, parseInt(keyId)); if (success) { res.json({ success: true }); } else { res.status(404).json({ error: 'API key not found' }); } } catch (error) { console.error('Error deleting API key:', error); res.status(500).json({ error: 'Failed to delete API key' }); } }); // Toggle API key active status router.patch('/api-keys/:keyId/toggle', async (req, res) => { try { const { keyId } = req.params; const { isActive } = req.body; if (typeof isActive !== 'boolean') { return res.status(400).json({ error: 'isActive must be a boolean' }); } const success = apiKeysDb.toggleApiKey(req.user.id, parseInt(keyId), isActive); if (success) { res.json({ success: true }); } else { res.status(404).json({ error: 'API key not found' }); } } catch (error) { console.error('Error toggling API key:', error); res.status(500).json({ error: 'Failed to toggle API key' }); } }); // =============================== // Generic Credentials Management // =============================== // Get all credentials for the authenticated user (optionally filtered by type) router.get('/credentials', async (req, res) => { try { const { type } = req.query; const credentials = credentialsDb.getCredentials(req.user.id, type || null); // Don't send the actual credential values for security res.json({ credentials }); } catch (error) { console.error('Error fetching credentials:', error); res.status(500).json({ error: 'Failed to fetch credentials' }); } }); // Create a new credential router.post('/credentials', async (req, res) => { try { const { credentialName, credentialType, credentialValue, description } = req.body; if (!credentialName || !credentialName.trim()) { return res.status(400).json({ error: 'Credential name is required' }); } if (!credentialType || !credentialType.trim()) { return res.status(400).json({ error: 'Credential type is required' }); } if (!credentialValue || !credentialValue.trim()) { return res.status(400).json({ error: 'Credential value is required' }); } const result = credentialsDb.createCredential( req.user.id, credentialName.trim(), credentialType.trim(), credentialValue.trim(), description?.trim() || null ); res.json({ success: true, credential: result }); } catch (error) { console.error('Error creating credential:', error); res.status(500).json({ error: 'Failed to create credential' }); } }); // Delete a credential router.delete('/credentials/:credentialId', async (req, res) => { try { const { credentialId } = req.params; const success = credentialsDb.deleteCredential(req.user.id, parseInt(credentialId)); if (success) { res.json({ success: true }); } else { res.status(404).json({ error: 'Credential not found' }); } } catch (error) { console.error('Error deleting credential:', error); res.status(500).json({ error: 'Failed to delete credential' }); } }); // Toggle credential active status router.patch('/credentials/:credentialId/toggle', async (req, res) => { try { const { credentialId } = req.params; const { isActive } = req.body; if (typeof isActive !== 'boolean') { return res.status(400).json({ error: 'isActive must be a boolean' }); } const success = credentialsDb.toggleCredential(req.user.id, parseInt(credentialId), isActive); if (success) { res.json({ success: true }); } else { res.status(404).json({ error: 'Credential not found' }); } } catch (error) { console.error('Error toggling credential:', error); res.status(500).json({ error: 'Failed to toggle credential' }); } }); // =============================== // Notification Preferences // =============================== router.get('/notification-preferences', async (req, res) => { try { const preferences = notificationPreferencesDb.getPreferences(req.user.id); res.json({ success: true, preferences }); } catch (error) { console.error('Error fetching notification preferences:', error); res.status(500).json({ error: 'Failed to fetch notification preferences' }); } }); router.put('/notification-preferences', async (req, res) => { try { const preferences = notificationPreferencesDb.updatePreferences(req.user.id, req.body || {}); res.json({ success: true, preferences }); } catch (error) { console.error('Error saving notification preferences:', error); res.status(500).json({ error: 'Failed to save notification preferences' }); } }); // =============================== // Push Subscription Management // =============================== router.get('/push/vapid-public-key', async (req, res) => { try { const publicKey = getPublicKey(); res.json({ publicKey }); } catch (error) { console.error('Error fetching VAPID public key:', error); res.status(500).json({ error: 'Failed to fetch VAPID public key' }); } }); router.post('/push/subscribe', async (req, res) => { try { const { endpoint, keys } = req.body; if (!endpoint || !keys?.p256dh || !keys?.auth) { return res.status(400).json({ error: 'Missing subscription fields' }); } pushSubscriptionsDb.saveSubscription(req.user.id, endpoint, keys.p256dh, keys.auth); // Enable webPush in preferences so the confirmation goes through the full pipeline const currentPrefs = notificationPreferencesDb.getPreferences(req.user.id); if (!currentPrefs?.channels?.webPush) { notificationPreferencesDb.updatePreferences(req.user.id, { ...currentPrefs, channels: { ...currentPrefs?.channels, webPush: true }, }); } res.json({ success: true }); // Send a confirmation push through the full notification pipeline const event = createNotificationEvent({ provider: 'system', kind: 'info', code: 'push.enabled', meta: { message: 'Push notifications are now enabled!' }, severity: 'info' }); notifyUserIfEnabled({ userId: req.user.id, event }); } catch (error) { console.error('Error saving push subscription:', error); res.status(500).json({ error: 'Failed to save push subscription' }); } }); router.post('/push/unsubscribe', async (req, res) => { try { const { endpoint } = req.body; if (!endpoint) { return res.status(400).json({ error: 'Missing endpoint' }); } pushSubscriptionsDb.removeSubscription(endpoint); // Disable webPush in preferences to match subscription state const currentPrefs = notificationPreferencesDb.getPreferences(req.user.id); if (currentPrefs?.channels?.webPush) { notificationPreferencesDb.updatePreferences(req.user.id, { ...currentPrefs, channels: { ...currentPrefs.channels, webPush: false }, }); } res.json({ success: true }); } catch (error) { console.error('Error removing push subscription:', error); res.status(500).json({ error: 'Failed to remove push subscription' }); } }); export default router; ================================================ FILE: server/routes/taskmaster.js ================================================ /** * TASKMASTER API ROUTES * ==================== * * This module provides API endpoints for TaskMaster integration including: * - .taskmaster folder detection in project directories * - MCP server configuration detection * - TaskMaster state and metadata management */ import express from 'express'; 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(); /** * Check if TaskMaster CLI is installed globally * @returns {Promise} Installation status result */ async function checkTaskMasterInstallation() { return new Promise((resolve) => { // Check if task-master command is available const child = spawn('which', ['task-master'], { stdio: ['ignore', 'pipe', 'pipe'], shell: true }); let output = ''; let errorOutput = ''; child.stdout.on('data', (data) => { output += data.toString(); }); child.stderr.on('data', (data) => { errorOutput += data.toString(); }); child.on('close', (code) => { if (code === 0 && output.trim()) { // TaskMaster is installed, get version const versionChild = spawn('task-master', ['--version'], { stdio: ['ignore', 'pipe', 'pipe'], shell: true }); let versionOutput = ''; versionChild.stdout.on('data', (data) => { versionOutput += data.toString(); }); versionChild.on('close', (versionCode) => { resolve({ isInstalled: true, installPath: output.trim(), version: versionCode === 0 ? versionOutput.trim() : 'unknown', reason: null }); }); versionChild.on('error', () => { resolve({ isInstalled: true, installPath: output.trim(), version: 'unknown', reason: null }); }); } else { resolve({ isInstalled: false, installPath: null, version: null, reason: 'TaskMaster CLI not found in PATH' }); } }); child.on('error', (error) => { resolve({ isInstalled: false, installPath: null, version: null, reason: `Error checking installation: ${error.message}` }); }); }); } /** * Detect .taskmaster folder presence in a given project directory * @param {string} projectPath - Absolute path to project directory * @returns {Promise} 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 /** * GET /api/taskmaster/installation-status * Check if TaskMaster CLI is installed on the system */ router.get('/installation-status', async (req, res) => { try { const installationStatus = await checkTaskMasterInstallation(); // Also check for MCP server configuration const mcpStatus = await detectTaskMasterMCPServer(); res.json({ success: true, installation: installationStatus, mcpServer: mcpStatus, isReady: installationStatus.isInstalled && mcpStatus.hasMCPServer }); } catch (error) { console.error('Error checking TaskMaster installation:', error); res.status(500).json({ success: false, error: 'Failed to check TaskMaster installation status', installation: { isInstalled: false, reason: `Server error: ${error.message}` }, mcpServer: { hasMCPServer: false, reason: `Server error: ${error.message}` }, isReady: false }); } }); /** * 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 */ router.get('/tasks/: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` }); } const taskMasterPath = path.join(projectPath, '.taskmaster'); const tasksFilePath = path.join(taskMasterPath, 'tasks', 'tasks.json'); // Check if tasks file exists try { await fsPromises.access(tasksFilePath); } catch (error) { return res.json({ projectName, tasks: [], message: 'No tasks.json file found' }); } // Read and parse tasks file try { const tasksContent = await fsPromises.readFile(tasksFilePath, 'utf8'); const tasksData = JSON.parse(tasksContent); let tasks = []; let currentTag = 'master'; // Handle both tagged and legacy formats if (Array.isArray(tasksData)) { // Legacy format tasks = tasksData; } else if (tasksData.tasks) { // Simple format with tasks array tasks = tasksData.tasks; } else { // Tagged format - get tasks from current tag or master if (tasksData[currentTag] && tasksData[currentTag].tasks) { tasks = tasksData[currentTag].tasks; } else if (tasksData.master && tasksData.master.tasks) { tasks = tasksData.master.tasks; } else { // Get tasks from first available tag const firstTag = Object.keys(tasksData).find(key => tasksData[key].tasks && Array.isArray(tasksData[key].tasks) ); if (firstTag) { tasks = tasksData[firstTag].tasks; currentTag = firstTag; } } } // Transform tasks to ensure all have required fields const transformedTasks = tasks.map(task => ({ id: task.id, title: task.title || 'Untitled Task', description: task.description || '', status: task.status || 'pending', priority: task.priority || 'medium', dependencies: task.dependencies || [], createdAt: task.createdAt || task.created || new Date().toISOString(), updatedAt: task.updatedAt || task.updated || new Date().toISOString(), details: task.details || '', testStrategy: task.testStrategy || task.test_strategy || '', subtasks: task.subtasks || [] })); res.json({ projectName, projectPath, tasks: transformedTasks, currentTag, totalTasks: transformedTasks.length, tasksByStatus: { pending: transformedTasks.filter(t => t.status === 'pending').length, 'in-progress': transformedTasks.filter(t => t.status === 'in-progress').length, done: transformedTasks.filter(t => t.status === 'done').length, review: transformedTasks.filter(t => t.status === 'review').length, deferred: transformedTasks.filter(t => t.status === 'deferred').length, cancelled: transformedTasks.filter(t => t.status === 'cancelled').length }, timestamp: new Date().toISOString() }); } catch (parseError) { console.error('Failed to parse tasks.json:', parseError); return res.status(500).json({ error: 'Failed to parse tasks file', message: parseError.message }); } } catch (error) { console.error('TaskMaster tasks loading error:', error); res.status(500).json({ error: 'Failed to load TaskMaster tasks', message: error.message }); } }); /** * GET /api/taskmaster/prd/:projectName * List all PRD files in the project's .taskmaster/docs directory */ router.get('/prd/: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` }); } const docsPath = path.join(projectPath, '.taskmaster', 'docs'); // Check if docs directory exists try { await fsPromises.access(docsPath, fs.constants.R_OK); } catch (error) { return res.json({ projectName, prdFiles: [], message: 'No .taskmaster/docs directory found' }); } // Read directory and filter for PRD files try { const files = await fsPromises.readdir(docsPath); const prdFiles = []; for (const file of files) { const filePath = path.join(docsPath, file); const stats = await fsPromises.stat(filePath); if (stats.isFile() && (file.endsWith('.txt') || file.endsWith('.md'))) { prdFiles.push({ name: file, path: path.relative(projectPath, filePath), size: stats.size, modified: stats.mtime.toISOString(), created: stats.birthtime.toISOString() }); } } res.json({ projectName, projectPath, prdFiles: prdFiles.sort((a, b) => new Date(b.modified) - new Date(a.modified)), timestamp: new Date().toISOString() }); } catch (readError) { console.error('Error reading docs directory:', readError); return res.status(500).json({ error: 'Failed to read PRD files', message: readError.message }); } } catch (error) { console.error('PRD list error:', error); res.status(500).json({ error: 'Failed to list PRD files', message: error.message }); } }); /** * POST /api/taskmaster/prd/:projectName * Create or update a PRD file in the project's .taskmaster/docs directory */ router.post('/prd/:projectName', async (req, res) => { try { const { projectName } = req.params; const { fileName, content } = req.body; if (!fileName || !content) { return res.status(400).json({ error: 'Missing required fields', message: 'fileName and content are required' }); } // Validate filename if (!fileName.match(/^[\w\-. ]+\.(txt|md)$/)) { return res.status(400).json({ error: 'Invalid filename', message: 'Filename must end with .txt or .md and contain only alphanumeric characters, spaces, dots, and dashes' }); } // 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 docsPath = path.join(projectPath, '.taskmaster', 'docs'); const filePath = path.join(docsPath, fileName); // Ensure docs directory exists try { await fsPromises.mkdir(docsPath, { recursive: true }); } catch (error) { console.error('Failed to create docs directory:', error); return res.status(500).json({ error: 'Failed to create directory', message: error.message }); } // Write the PRD file try { await fsPromises.writeFile(filePath, content, 'utf8'); // Get file stats const stats = await fsPromises.stat(filePath); res.json({ projectName, projectPath, fileName, filePath: path.relative(projectPath, filePath), size: stats.size, created: stats.birthtime.toISOString(), modified: stats.mtime.toISOString(), message: 'PRD file saved successfully', timestamp: new Date().toISOString() }); } catch (writeError) { console.error('Failed to write PRD file:', writeError); return res.status(500).json({ error: 'Failed to write PRD file', message: writeError.message }); } } catch (error) { console.error('PRD create/update error:', error); res.status(500).json({ error: 'Failed to create/update PRD file', message: error.message }); } }); /** * GET /api/taskmaster/prd/:projectName/:fileName * Get content of a specific PRD file */ router.get('/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.R_OK); } catch (error) { return res.status(404).json({ error: 'PRD file not found', message: `File "${fileName}" does not exist` }); } // Read file content try { const content = await fsPromises.readFile(filePath, 'utf8'); const stats = await fsPromises.stat(filePath); res.json({ projectName, projectPath, fileName, filePath: path.relative(projectPath, filePath), content, size: stats.size, created: stats.birthtime.toISOString(), modified: stats.mtime.toISOString(), timestamp: new Date().toISOString() }); } catch (readError) { console.error('Failed to read PRD file:', readError); return res.status(500).json({ error: 'Failed to read PRD file', message: readError.message }); } } catch (error) { console.error('PRD read error:', error); res.status(500).json({ error: 'Failed to read PRD file', message: error.message }); } }); /** * 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 */ router.post('/init/: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` }); } // Check if TaskMaster is already initialized const taskMasterPath = path.join(projectPath, '.taskmaster'); try { await fsPromises.access(taskMasterPath, fs.constants.F_OK); return res.status(400).json({ error: 'TaskMaster already initialized', message: 'TaskMaster is already configured for this project' }); } catch (error) { // Directory doesn't exist, we can proceed } // Run taskmaster init command const initProcess = spawn('npx', ['task-master', 'init'], { cwd: projectPath, stdio: ['pipe', 'pipe', 'pipe'] }); let stdout = ''; let stderr = ''; initProcess.stdout.on('data', (data) => { stdout += data.toString(); }); initProcess.stderr.on('data', (data) => { stderr += data.toString(); }); initProcess.on('close', (code) => { if (code === 0) { // Broadcast TaskMaster project update via WebSocket if (req.app.locals.wss) { broadcastTaskMasterProjectUpdate( req.app.locals.wss, projectName, { hasTaskmaster: true, status: 'initialized' } ); } res.json({ projectName, projectPath, message: 'TaskMaster initialized successfully', output: stdout, timestamp: new Date().toISOString() }); } else { console.error('TaskMaster init failed:', stderr); res.status(500).json({ error: 'Failed to initialize TaskMaster', message: stderr || stdout, code }); } }); // Send 'yes' responses to automated prompts initProcess.stdin.write('yes\n'); initProcess.stdin.end(); } catch (error) { console.error('TaskMaster init error:', error); res.status(500).json({ error: 'Failed to initialize TaskMaster', message: error.message }); } }); /** * POST /api/taskmaster/add-task/:projectName * Add a new task to the project */ router.post('/add-task/:projectName', async (req, res) => { try { const { projectName } = req.params; const { prompt, title, description, priority = 'medium', dependencies } = req.body; if (!prompt && (!title || !description)) { return res.status(400).json({ error: 'Missing required parameters', message: 'Either "prompt" or both "title" and "description" are required' }); } // 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` }); } // Build the task-master add-task command const args = ['task-master-ai', 'add-task']; if (prompt) { args.push('--prompt', prompt); args.push('--research'); // Use research for AI-generated tasks } else { args.push('--prompt', `Create a task titled "${title}" with description: ${description}`); } if (priority) { args.push('--priority', priority); } if (dependencies) { args.push('--dependencies', dependencies); } // Run task-master add-task command const addTaskProcess = spawn('npx', args, { cwd: projectPath, stdio: ['pipe', 'pipe', 'pipe'] }); let stdout = ''; let stderr = ''; addTaskProcess.stdout.on('data', (data) => { stdout += data.toString(); }); addTaskProcess.stderr.on('data', (data) => { stderr += data.toString(); }); addTaskProcess.on('close', (code) => { console.log('Add task process completed with code:', code); console.log('Stdout:', stdout); console.log('Stderr:', stderr); if (code === 0) { // Broadcast task update via WebSocket if (req.app.locals.wss) { broadcastTaskMasterTasksUpdate( req.app.locals.wss, projectName ); } res.json({ projectName, projectPath, message: 'Task added successfully', output: stdout, timestamp: new Date().toISOString() }); } else { console.error('Add task failed:', stderr); res.status(500).json({ error: 'Failed to add task', message: stderr || stdout, code }); } }); addTaskProcess.stdin.end(); } catch (error) { console.error('Add task error:', error); res.status(500).json({ error: 'Failed to add task', message: error.message }); } }); /** * PUT /api/taskmaster/update-task/:projectName/:taskId * Update a specific task using TaskMaster CLI */ router.put('/update-task/:projectName/:taskId', async (req, res) => { try { const { projectName, taskId } = req.params; const { title, description, status, priority, details } = req.body; // 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` }); } // If only updating status, use set-status command if (status && Object.keys(req.body).length === 1) { const setStatusProcess = spawn('npx', ['task-master-ai', 'set-status', `--id=${taskId}`, `--status=${status}`], { cwd: projectPath, stdio: ['pipe', 'pipe', 'pipe'] }); let stdout = ''; let stderr = ''; setStatusProcess.stdout.on('data', (data) => { stdout += data.toString(); }); setStatusProcess.stderr.on('data', (data) => { stderr += data.toString(); }); setStatusProcess.on('close', (code) => { if (code === 0) { // Broadcast task update via WebSocket if (req.app.locals.wss) { broadcastTaskMasterTasksUpdate(req.app.locals.wss, projectName); } res.json({ projectName, projectPath, taskId, message: 'Task status updated successfully', output: stdout, timestamp: new Date().toISOString() }); } else { console.error('Set task status failed:', stderr); res.status(500).json({ error: 'Failed to update task status', message: stderr || stdout, code }); } }); setStatusProcess.stdin.end(); } else { // For other updates, use update-task command with a prompt describing the changes const updates = []; if (title) updates.push(`title: "${title}"`); if (description) updates.push(`description: "${description}"`); if (priority) updates.push(`priority: "${priority}"`); if (details) updates.push(`details: "${details}"`); const prompt = `Update task with the following changes: ${updates.join(', ')}`; const updateProcess = spawn('npx', ['task-master-ai', 'update-task', `--id=${taskId}`, `--prompt=${prompt}`], { cwd: projectPath, stdio: ['pipe', 'pipe', 'pipe'] }); let stdout = ''; let stderr = ''; updateProcess.stdout.on('data', (data) => { stdout += data.toString(); }); updateProcess.stderr.on('data', (data) => { stderr += data.toString(); }); updateProcess.on('close', (code) => { if (code === 0) { // Broadcast task update via WebSocket if (req.app.locals.wss) { broadcastTaskMasterTasksUpdate(req.app.locals.wss, projectName); } res.json({ projectName, projectPath, taskId, message: 'Task updated successfully', output: stdout, timestamp: new Date().toISOString() }); } else { console.error('Update task failed:', stderr); res.status(500).json({ error: 'Failed to update task', message: stderr || stdout, code }); } }); updateProcess.stdin.end(); } } catch (error) { console.error('Update task error:', error); res.status(500).json({ error: 'Failed to update task', message: error.message }); } }); /** * POST /api/taskmaster/parse-prd/:projectName * Parse a PRD file to generate tasks */ router.post('/parse-prd/:projectName', async (req, res) => { try { const { projectName } = req.params; const { fileName = 'prd.txt', numTasks, append = false } = req.body; // 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 prdPath = path.join(projectPath, '.taskmaster', 'docs', fileName); // Check if PRD file exists try { await fsPromises.access(prdPath, fs.constants.F_OK); } catch (error) { return res.status(404).json({ error: 'PRD file not found', message: `File "${fileName}" does not exist in .taskmaster/docs/` }); } // Build the command args const args = ['task-master-ai', 'parse-prd', prdPath]; if (numTasks) { args.push('--num-tasks', numTasks.toString()); } if (append) { args.push('--append'); } args.push('--research'); // Use research for better PRD parsing // Run task-master parse-prd command const parsePRDProcess = spawn('npx', args, { cwd: projectPath, stdio: ['pipe', 'pipe', 'pipe'] }); let stdout = ''; let stderr = ''; parsePRDProcess.stdout.on('data', (data) => { stdout += data.toString(); }); parsePRDProcess.stderr.on('data', (data) => { stderr += data.toString(); }); parsePRDProcess.on('close', (code) => { if (code === 0) { // Broadcast task update via WebSocket if (req.app.locals.wss) { broadcastTaskMasterTasksUpdate( req.app.locals.wss, projectName ); } res.json({ projectName, projectPath, prdFile: fileName, message: 'PRD parsed and tasks generated successfully', output: stdout, timestamp: new Date().toISOString() }); } else { console.error('Parse PRD failed:', stderr); res.status(500).json({ error: 'Failed to parse PRD', message: stderr || stdout, code }); } }); parsePRDProcess.stdin.end(); } catch (error) { console.error('Parse PRD error:', error); res.status(500).json({ error: 'Failed to parse PRD', message: error.message }); } }); /** * GET /api/taskmaster/prd-templates * Get available PRD templates */ router.get('/prd-templates', async (req, res) => { try { // Return built-in templates const templates = [ { id: 'web-app', name: 'Web Application', description: 'Template for web application projects with frontend and backend components', category: 'web', content: `# Product Requirements Document - Web Application ## Overview **Product Name:** [Your App Name] **Version:** 1.0 **Date:** ${new Date().toISOString().split('T')[0]} **Author:** [Your Name] ## Executive Summary Brief description of what this web application will do and why it's needed. ## Product Goals - Goal 1: [Specific measurable goal] - Goal 2: [Specific measurable goal] - Goal 3: [Specific measurable goal] ## User Stories ### Core Features 1. **User Registration & Authentication** - As a user, I want to create an account so I can access personalized features - As a user, I want to log in securely so my data is protected - As a user, I want to reset my password if I forget it 2. **Main Application Features** - As a user, I want to [core feature 1] so I can [benefit] - As a user, I want to [core feature 2] so I can [benefit] - As a user, I want to [core feature 3] so I can [benefit] 3. **User Interface** - As a user, I want a responsive design so I can use the app on any device - As a user, I want intuitive navigation so I can easily find features ## Technical Requirements ### Frontend - Framework: React/Vue/Angular or vanilla JavaScript - Styling: CSS framework (Tailwind, Bootstrap, etc.) - State Management: Redux/Vuex/Context API - Build Tools: Webpack/Vite - Testing: Jest/Vitest for unit tests ### Backend - Runtime: Node.js/Python/Java - Database: PostgreSQL/MySQL/MongoDB - API: RESTful API or GraphQL - Authentication: JWT tokens - Testing: Integration and unit tests ### Infrastructure - Hosting: Cloud provider (AWS, Azure, GCP) - CI/CD: GitHub Actions/GitLab CI - Monitoring: Application monitoring tools - Security: HTTPS, input validation, rate limiting ## Success Metrics - User engagement metrics - Performance benchmarks (load time < 2s) - Error rates < 1% - User satisfaction scores ## Timeline - Phase 1: Core functionality (4-6 weeks) - Phase 2: Advanced features (2-4 weeks) - Phase 3: Polish and launch (2 weeks) ## Constraints & Assumptions - Budget constraints - Technical limitations - Team size and expertise - Timeline constraints` }, { id: 'api', name: 'REST API', description: 'Template for REST API development projects', category: 'backend', content: `# Product Requirements Document - REST API ## Overview **API Name:** [Your API Name] **Version:** v1.0 **Date:** ${new Date().toISOString().split('T')[0]} **Author:** [Your Name] ## Executive Summary Description of the API's purpose, target users, and primary use cases. ## API Goals - Goal 1: Provide secure data access - Goal 2: Ensure scalable architecture - Goal 3: Maintain high availability (99.9% uptime) ## Functional Requirements ### Core Endpoints 1. **Authentication Endpoints** - POST /api/auth/login - User authentication - POST /api/auth/logout - User logout - POST /api/auth/refresh - Token refresh - POST /api/auth/register - User registration 2. **Data Management Endpoints** - GET /api/resources - List resources with pagination - GET /api/resources/{id} - Get specific resource - POST /api/resources - Create new resource - PUT /api/resources/{id} - Update existing resource - DELETE /api/resources/{id} - Delete resource 3. **Administrative Endpoints** - GET /api/admin/users - Manage users (admin only) - GET /api/admin/analytics - System analytics - POST /api/admin/backup - Trigger system backup ## Technical Requirements ### API Design - RESTful architecture following OpenAPI 3.0 specification - JSON request/response format - Consistent error response format - API versioning strategy ### Authentication & Security - JWT token-based authentication - Role-based access control (RBAC) - Rate limiting (100 requests/minute per user) - Input validation and sanitization - HTTPS enforcement ### Database - Database type: [PostgreSQL/MongoDB/MySQL] - Connection pooling - Database migrations - Backup and recovery procedures ### Performance Requirements - Response time: < 200ms for 95% of requests - Throughput: 1000+ requests/second - Concurrent users: 10,000+ - Database query optimization ### Documentation - Auto-generated API documentation (Swagger/OpenAPI) - Code examples for common use cases - SDK development for major languages - Postman collection for testing ## Error Handling - Standardized error codes and messages - Proper HTTP status codes - Detailed error logging - Graceful degradation strategies ## Testing Strategy - Unit tests (80%+ coverage) - Integration tests for all endpoints - Load testing and performance testing - Security testing (OWASP compliance) ## Monitoring & Logging - Application performance monitoring - Error tracking and alerting - Access logs and audit trails - Health check endpoints ## Deployment - Containerized deployment (Docker) - CI/CD pipeline setup - Environment management (dev, staging, prod) - Blue-green deployment strategy ## Success Metrics - API uptime > 99.9% - Average response time < 200ms - Zero critical security vulnerabilities - Developer adoption metrics` }, { id: 'mobile-app', name: 'Mobile Application', description: 'Template for mobile app development projects (iOS/Android)', category: 'mobile', content: `# Product Requirements Document - Mobile Application ## Overview **App Name:** [Your App Name] **Platform:** iOS / Android / Cross-platform **Version:** 1.0 **Date:** ${new Date().toISOString().split('T')[0]} **Author:** [Your Name] ## Executive Summary Brief description of the mobile app's purpose, target audience, and key value proposition. ## Product Goals - Goal 1: [Specific user engagement goal] - Goal 2: [Specific functionality goal] - Goal 3: [Specific performance goal] ## User Stories ### Core Features 1. **Onboarding & Authentication** - As a new user, I want a simple onboarding process - As a user, I want to sign up with email or social media - As a user, I want biometric authentication for security 2. **Main App Features** - As a user, I want [core feature 1] accessible from home screen - As a user, I want [core feature 2] to work offline - As a user, I want to sync data across devices 3. **User Experience** - As a user, I want intuitive navigation patterns - As a user, I want fast loading times - As a user, I want accessibility features ## Technical Requirements ### Mobile Development - **Cross-platform:** React Native / Flutter / Xamarin - **Native:** Swift (iOS) / Kotlin (Android) - **State Management:** Redux / MobX / Provider - **Navigation:** React Navigation / Flutter Navigation ### Backend Integration - REST API or GraphQL integration - Real-time features (WebSockets/Push notifications) - Offline data synchronization - Background processing ### Device Features - Camera and photo library access - GPS location services - Push notifications - Biometric authentication - Device storage ### Performance Requirements - App launch time < 3 seconds - Screen transition animations < 300ms - Memory usage optimization - Battery usage optimization ## Platform-Specific Considerations ### iOS Requirements - iOS 13.0+ minimum version - App Store guidelines compliance - iOS design guidelines (Human Interface Guidelines) - TestFlight beta testing ### Android Requirements - Android 8.0+ (API level 26) minimum - Google Play Store guidelines - Material Design guidelines - Google Play Console testing ## User Interface Design - Responsive design for different screen sizes - Dark mode support - Accessibility compliance (WCAG 2.1) - Consistent design system ## Security & Privacy - Secure data storage (Keychain/Keystore) - API communication encryption - Privacy policy compliance (GDPR/CCPA) - App security best practices ## Testing Strategy - Unit testing (80%+ coverage) - UI/E2E testing (Detox/Appium) - Device testing on multiple screen sizes - Performance testing - Security testing ## App Store Deployment - App store optimization (ASO) - App icons and screenshots - Store listing content - Release management strategy ## Analytics & Monitoring - User analytics (Firebase/Analytics) - Crash reporting (Crashlytics/Sentry) - Performance monitoring - User feedback collection ## Success Metrics - App store ratings > 4.0 - User retention rates - Daily/Monthly active users - App performance metrics - Conversion rates` }, { id: 'data-analysis', name: 'Data Analysis Project', description: 'Template for data analysis and visualization projects', category: 'data', content: `# Product Requirements Document - Data Analysis Project ## Overview **Project Name:** [Your Analysis Project] **Analysis Type:** [Descriptive/Predictive/Prescriptive] **Date:** ${new Date().toISOString().split('T')[0]} **Author:** [Your Name] ## Executive Summary Description of the business problem, data sources, and expected insights. ## Project Goals - Goal 1: [Specific business question to answer] - Goal 2: [Specific prediction to make] - Goal 3: [Specific recommendation to provide] ## Business Requirements ### Key Questions 1. What patterns exist in the current data? 2. What factors influence [target variable]? 3. What predictions can be made for [future outcome]? 4. What recommendations can improve [business metric]? ### Success Criteria - Actionable insights for stakeholders - Statistical significance in findings - Reproducible analysis pipeline - Clear visualization and reporting ## Data Requirements ### Data Sources 1. **Primary Data** - Source: [Database/API/Files] - Format: [CSV/JSON/SQL] - Size: [Volume estimate] - Update frequency: [Real-time/Daily/Monthly] 2. **External Data** - Third-party APIs - Public datasets - Market research data ### Data Quality Requirements - Data completeness (< 5% missing values) - Data accuracy validation - Data consistency checks - Historical data availability ## Technical Requirements ### Data Pipeline - Data extraction and ingestion - Data cleaning and preprocessing - Data transformation and feature engineering - Data validation and quality checks ### Analysis Tools - **Programming:** Python/R/SQL - **Libraries:** pandas, numpy, scikit-learn, matplotlib - **Visualization:** Tableau, PowerBI, or custom dashboards - **Version Control:** Git for code and DVC for data ### Computing Resources - Local development environment - Cloud computing (AWS/GCP/Azure) if needed - Database access and permissions - Storage requirements ## Analysis Methodology ### Data Exploration 1. Descriptive statistics and data profiling 2. Data visualization and pattern identification 3. Correlation analysis 4. Outlier detection and handling ### Statistical Analysis 1. Hypothesis formulation 2. Statistical testing 3. Confidence intervals 4. Effect size calculations ### Machine Learning (if applicable) 1. Feature selection and engineering 2. Model selection and training 3. Cross-validation and evaluation 4. Model interpretation and explainability ## Deliverables ### Reports - Executive summary for stakeholders - Technical analysis report - Data quality report - Methodology documentation ### Visualizations - Interactive dashboards - Static charts and graphs - Data story presentations - Key findings infographics ### Code & Documentation - Reproducible analysis scripts - Data pipeline code - Documentation and comments - Testing and validation code ## Timeline - Phase 1: Data collection and exploration (2 weeks) - Phase 2: Analysis and modeling (3 weeks) - Phase 3: Reporting and visualization (1 week) - Phase 4: Stakeholder presentation (1 week) ## Risks & Assumptions - Data availability and quality risks - Technical complexity assumptions - Resource and timeline constraints - Stakeholder engagement assumptions ## Success Metrics - Stakeholder satisfaction with insights - Accuracy of predictions (if applicable) - Business impact of recommendations - Reproducibility of results` } ]; res.json({ templates, timestamp: new Date().toISOString() }); } catch (error) { console.error('PRD templates error:', error); res.status(500).json({ error: 'Failed to get PRD templates', message: error.message }); } }); /** * POST /api/taskmaster/apply-template/:projectName * Apply a PRD template to create a new PRD file */ router.post('/apply-template/:projectName', async (req, res) => { try { const { projectName } = req.params; const { templateId, fileName = 'prd.txt', customizations = {} } = req.body; if (!templateId) { return res.status(400).json({ error: 'Missing required parameter', message: 'templateId is required' }); } // 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` }); } // Get the template content (this would normally fetch from the templates list) const templates = await getAvailableTemplates(); const template = templates.find(t => t.id === templateId); if (!template) { return res.status(404).json({ error: 'Template not found', message: `Template "${templateId}" does not exist` }); } // Apply customizations to template content let content = template.content; // Replace placeholders with customizations for (const [key, value] of Object.entries(customizations)) { const placeholder = `[${key}]`; content = content.replace(new RegExp(placeholder.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&'), 'g'), value); } // Ensure .taskmaster/docs directory exists const docsDir = path.join(projectPath, '.taskmaster', 'docs'); try { await fsPromises.mkdir(docsDir, { recursive: true }); } catch (error) { console.error('Failed to create docs directory:', error); } const filePath = path.join(docsDir, fileName); // Write the template content to the file try { await fsPromises.writeFile(filePath, content, 'utf8'); res.json({ projectName, projectPath, templateId, templateName: template.name, fileName, filePath: filePath, message: 'PRD template applied successfully', timestamp: new Date().toISOString() }); } catch (writeError) { console.error('Failed to write PRD template:', writeError); return res.status(500).json({ error: 'Failed to write PRD template', message: writeError.message }); } } catch (error) { console.error('Apply template error:', error); res.status(500).json({ error: 'Failed to apply PRD template', message: error.message }); } }); // Helper function to get available templates async function getAvailableTemplates() { // This could be extended to read from files or database return [ { id: 'web-app', name: 'Web Application', description: 'Template for web application projects', category: 'web', content: `# Product Requirements Document - Web Application ## Overview **Product Name:** [Your App Name] **Version:** 1.0 **Date:** ${new Date().toISOString().split('T')[0]} **Author:** [Your Name] ## Executive Summary Brief description of what this web application will do and why it's needed. ## User Stories 1. As a user, I want [feature] so I can [benefit] 2. As a user, I want [feature] so I can [benefit] 3. As a user, I want [feature] so I can [benefit] ## Technical Requirements - Frontend framework - Backend services - Database requirements - Security considerations ## Success Metrics - User engagement metrics - Performance benchmarks - Business objectives` }, // Add other templates here if needed ]; } export default router; ================================================ FILE: server/routes/user.js ================================================ import express from 'express'; import { userDb } from '../database/db.js'; import { authenticateToken } from '../middleware/auth.js'; import { getSystemGitConfig } from '../utils/gitConfig.js'; import { spawn } from 'child_process'; const router = express.Router(); function spawnAsync(command, args, options = {}) { return new Promise((resolve, reject) => { const child = spawn(command, args, { ...options, shell: false }); let stdout = ''; let stderr = ''; child.stdout.on('data', (data) => { stdout += data.toString(); }); child.stderr.on('data', (data) => { stderr += data.toString(); }); child.on('error', (error) => { reject(error); }); child.on('close', (code) => { if (code === 0) { resolve({ stdout, stderr }); return; } const error = new Error(`Command failed: ${command} ${args.join(' ')}`); error.code = code; error.stdout = stdout; error.stderr = stderr; reject(error); }); }); } router.get('/git-config', authenticateToken, async (req, res) => { try { const userId = req.user.id; let gitConfig = userDb.getGitConfig(userId); // If database is empty, try to get from system git config if (!gitConfig || (!gitConfig.git_name && !gitConfig.git_email)) { const systemConfig = await getSystemGitConfig(); // If system has values, save them to database for this user if (systemConfig.git_name || systemConfig.git_email) { userDb.updateGitConfig(userId, systemConfig.git_name, systemConfig.git_email); gitConfig = systemConfig; console.log(`Auto-populated git config from system for user ${userId}: ${systemConfig.git_name} <${systemConfig.git_email}>`); } } res.json({ success: true, gitName: gitConfig?.git_name || null, gitEmail: gitConfig?.git_email || null }); } catch (error) { console.error('Error getting git config:', error); res.status(500).json({ error: 'Failed to get git configuration' }); } }); // Apply git config globally via git config --global router.post('/git-config', authenticateToken, async (req, res) => { try { const userId = req.user.id; const { gitName, gitEmail } = req.body; if (!gitName || !gitEmail) { return res.status(400).json({ error: 'Git name and email are required' }); } // Validate email format const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; if (!emailRegex.test(gitEmail)) { return res.status(400).json({ error: 'Invalid email format' }); } userDb.updateGitConfig(userId, gitName, gitEmail); try { await spawnAsync('git', ['config', '--global', 'user.name', gitName]); await spawnAsync('git', ['config', '--global', 'user.email', gitEmail]); console.log(`Applied git config globally: ${gitName} <${gitEmail}>`); } catch (gitError) { console.error('Error applying git config:', gitError); } res.json({ success: true, gitName, gitEmail }); } catch (error) { console.error('Error updating git config:', error); res.status(500).json({ error: 'Failed to update git configuration' }); } }); router.post('/complete-onboarding', authenticateToken, async (req, res) => { try { const userId = req.user.id; userDb.completeOnboarding(userId); res.json({ success: true, message: 'Onboarding completed successfully' }); } catch (error) { console.error('Error completing onboarding:', error); res.status(500).json({ error: 'Failed to complete onboarding' }); } }); router.get('/onboarding-status', authenticateToken, async (req, res) => { try { const userId = req.user.id; const hasCompleted = userDb.hasCompletedOnboarding(userId); res.json({ success: true, hasCompletedOnboarding: hasCompleted }); } catch (error) { console.error('Error checking onboarding status:', error); res.status(500).json({ error: 'Failed to check onboarding status' }); } }); export default router; ================================================ FILE: server/services/notification-orchestrator.js ================================================ import webPush from 'web-push'; import { notificationPreferencesDb, pushSubscriptionsDb, sessionNamesDb } from '../database/db.js'; const KIND_TO_PREF_KEY = { action_required: 'actionRequired', stop: 'stop', error: 'error' }; const PROVIDER_LABELS = { claude: 'Claude', cursor: 'Cursor', codex: 'Codex', gemini: 'Gemini', system: 'System' }; const recentEventKeys = new Map(); const DEDUPE_WINDOW_MS = 20000; const cleanupOldEventKeys = () => { const now = Date.now(); for (const [key, timestamp] of recentEventKeys.entries()) { if (now - timestamp > DEDUPE_WINDOW_MS) { recentEventKeys.delete(key); } } }; function shouldSendPush(preferences, event) { const webPushEnabled = Boolean(preferences?.channels?.webPush); const prefEventKey = KIND_TO_PREF_KEY[event.kind]; const eventEnabled = prefEventKey ? Boolean(preferences?.events?.[prefEventKey]) : true; return webPushEnabled && eventEnabled; } function isDuplicate(event) { cleanupOldEventKeys(); const key = event.dedupeKey || `${event.provider}:${event.kind || 'info'}:${event.code || 'generic'}:${event.sessionId || 'none'}`; if (recentEventKeys.has(key)) { return true; } recentEventKeys.set(key, Date.now()); return false; } function createNotificationEvent({ provider, sessionId = null, kind = 'info', code = 'generic.info', meta = {}, severity = 'info', dedupeKey = null, requiresUserAction = false }) { return { provider, sessionId, kind, code, meta, severity, requiresUserAction, dedupeKey, createdAt: new Date().toISOString() }; } function normalizeErrorMessage(error) { if (typeof error === 'string') { return error; } if (error && typeof error.message === 'string') { return error.message; } if (error == null) { return 'Unknown error'; } return String(error); } function normalizeSessionName(sessionName) { if (typeof sessionName !== 'string') { return null; } const normalized = sessionName.replace(/\s+/g, ' ').trim(); if (!normalized) { return null; } return normalized.length > 80 ? `${normalized.slice(0, 77)}...` : normalized; } function resolveSessionName(event) { const explicitSessionName = normalizeSessionName(event.meta?.sessionName); if (explicitSessionName) { return explicitSessionName; } if (!event.sessionId || !event.provider) { return null; } return normalizeSessionName(sessionNamesDb.getName(event.sessionId, event.provider)); } function buildPushBody(event) { const CODE_MAP = { 'permission.required': event.meta?.toolName ? `Action Required: Tool "${event.meta.toolName}" needs approval` : 'Action Required: A tool needs your approval', 'run.stopped': event.meta?.stopReason || 'Run Stopped: The run has stopped', 'run.failed': event.meta?.error ? `Run Failed: ${event.meta.error}` : 'Run Failed: The run encountered an error', 'agent.notification': event.meta?.message ? String(event.meta.message) : 'You have a new notification', 'push.enabled': 'Push notifications are now enabled!' }; const providerLabel = PROVIDER_LABELS[event.provider] || 'Assistant'; const sessionName = resolveSessionName(event); const message = CODE_MAP[event.code] || 'You have a new notification'; return { title: sessionName || 'Claude Code UI', body: `${providerLabel}: ${message}`, data: { sessionId: event.sessionId || null, code: event.code, provider: event.provider || null, sessionName, tag: `${event.provider || 'assistant'}:${event.sessionId || 'none'}:${event.code}` } }; } async function sendWebPush(userId, event) { const subscriptions = pushSubscriptionsDb.getSubscriptions(userId); if (!subscriptions.length) return; const payload = JSON.stringify(buildPushBody(event)); const results = await Promise.allSettled( subscriptions.map((sub) => webPush.sendNotification( { endpoint: sub.endpoint, keys: { p256dh: sub.keys_p256dh, auth: sub.keys_auth } }, payload ) ) ); // Clean up gone subscriptions (410 Gone or 404) results.forEach((result, index) => { if (result.status === 'rejected') { const statusCode = result.reason?.statusCode; if (statusCode === 410 || statusCode === 404) { pushSubscriptionsDb.removeSubscription(subscriptions[index].endpoint); } } }); } function notifyUserIfEnabled({ userId, event }) { if (!userId || !event) { return; } const preferences = notificationPreferencesDb.getPreferences(userId); if (!shouldSendPush(preferences, event)) { return; } if (isDuplicate(event)) { return; } sendWebPush(userId, event).catch((err) => { console.error('Web push send error:', err); }); } function notifyRunStopped({ userId, provider, sessionId = null, stopReason = 'completed', sessionName = null }) { notifyUserIfEnabled({ userId, event: createNotificationEvent({ provider, sessionId, kind: 'stop', code: 'run.stopped', meta: { stopReason, sessionName }, severity: 'info', dedupeKey: `${provider}:run:stop:${sessionId || 'none'}:${stopReason}` }) }); } function notifyRunFailed({ userId, provider, sessionId = null, error, sessionName = null }) { const errorMessage = normalizeErrorMessage(error); notifyUserIfEnabled({ userId, event: createNotificationEvent({ provider, sessionId, kind: 'error', code: 'run.failed', meta: { error: errorMessage, sessionName }, severity: 'error', dedupeKey: `${provider}:run:error:${sessionId || 'none'}:${errorMessage}` }) }); } export { createNotificationEvent, notifyUserIfEnabled, notifyRunStopped, notifyRunFailed }; ================================================ FILE: server/services/vapid-keys.js ================================================ import webPush from 'web-push'; import { db } from '../database/db.js'; let cachedKeys = null; function ensureVapidKeys() { if (cachedKeys) return cachedKeys; const row = db.prepare('SELECT public_key, private_key FROM vapid_keys ORDER BY id DESC LIMIT 1').get(); if (row) { cachedKeys = { publicKey: row.public_key, privateKey: row.private_key }; return cachedKeys; } const keys = webPush.generateVAPIDKeys(); db.prepare('INSERT INTO vapid_keys (public_key, private_key) VALUES (?, ?)').run(keys.publicKey, keys.privateKey); cachedKeys = keys; return cachedKeys; } function getPublicKey() { return ensureVapidKeys().publicKey; } function configureWebPush() { const keys = ensureVapidKeys(); webPush.setVapidDetails( 'mailto:noreply@claudecodeui.local', keys.publicKey, keys.privateKey ); console.log('Web Push notifications configured'); } export { ensureVapidKeys, getPublicKey, configureWebPush }; ================================================ FILE: server/sessionManager.js ================================================ import { promises as fs } from 'fs'; import path from 'path'; import os from 'os'; class SessionManager { constructor() { // Store sessions in memory with conversation history this.sessions = new Map(); this.maxSessions = 100; this.sessionsDir = path.join(os.homedir(), '.gemini', 'sessions'); this.ready = this.init(); } async init() { await this.initSessionsDir(); await this.loadSessions(); } async initSessionsDir() { try { await fs.mkdir(this.sessionsDir, { recursive: true }); } catch (error) { // console.error('Error creating sessions directory:', error); } } // Create a new session createSession(sessionId, projectPath) { const session = { id: sessionId, projectPath: projectPath, messages: [], createdAt: new Date(), lastActivity: new Date() }; // Evict oldest session from memory if we exceed limit if (this.sessions.size >= this.maxSessions) { const oldestKey = this.sessions.keys().next().value; if (oldestKey) this.sessions.delete(oldestKey); } this.sessions.set(sessionId, session); this.saveSession(sessionId); return session; } // Add a message to session addMessage(sessionId, role, content) { let session = this.sessions.get(sessionId); if (!session) { // Create session if it doesn't exist session = this.createSession(sessionId, ''); } const message = { role: role, // 'user' or 'assistant' content: content, timestamp: new Date() }; session.messages.push(message); session.lastActivity = new Date(); this.saveSession(sessionId); return session; } // Get session by ID getSession(sessionId) { return this.sessions.get(sessionId); } // Get all sessions for a project getProjectSessions(projectPath) { const sessions = []; for (const [id, session] of this.sessions) { if (session.projectPath === projectPath) { sessions.push({ id: session.id, summary: this.getSessionSummary(session), messageCount: session.messages.length, lastActivity: session.lastActivity }); } } return sessions.sort((a, b) => new Date(b.lastActivity) - new Date(a.lastActivity) ); } // Get session summary getSessionSummary(session) { if (session.messages.length === 0) { return 'New Session'; } // Find first user message const firstUserMessage = session.messages.find(m => m.role === 'user'); if (firstUserMessage) { const content = firstUserMessage.content; return content.length > 50 ? content.substring(0, 50) + '...' : content; } return 'New Session'; } // Build conversation context for Gemini buildConversationContext(sessionId, maxMessages = 10) { const session = this.sessions.get(sessionId); if (!session || session.messages.length === 0) { return ''; } // Get last N messages for context const recentMessages = session.messages.slice(-maxMessages); let context = 'Here is the conversation history:\n\n'; for (const msg of recentMessages) { if (msg.role === 'user') { context += `User: ${msg.content}\n`; } else { context += `Assistant: ${msg.content}\n`; } } context += '\nBased on the conversation history above, please answer the following:\n'; return context; } // Prevent path traversal _safeFilePath(sessionId) { const safeId = String(sessionId).replace(/[/\\]|\.\./g, ''); return path.join(this.sessionsDir, `${safeId}.json`); } // Save session to disk async saveSession(sessionId) { const session = this.sessions.get(sessionId); if (!session) return; try { const filePath = this._safeFilePath(sessionId); await fs.writeFile(filePath, JSON.stringify(session, null, 2)); } catch (error) { // console.error('Error saving session:', error); } } // Load sessions from disk async loadSessions() { try { const files = await fs.readdir(this.sessionsDir); for (const file of files) { if (file.endsWith('.json')) { try { const filePath = path.join(this.sessionsDir, file); const data = await fs.readFile(filePath, 'utf8'); const session = JSON.parse(data); // Convert dates session.createdAt = new Date(session.createdAt); session.lastActivity = new Date(session.lastActivity); session.messages.forEach(msg => { msg.timestamp = new Date(msg.timestamp); }); this.sessions.set(session.id, session); } catch (error) { // console.error(`Error loading session ${file}:`, error); } } } // Enforce eviction after loading to prevent massive memory usage while (this.sessions.size > this.maxSessions) { const oldestKey = this.sessions.keys().next().value; if (oldestKey) this.sessions.delete(oldestKey); } } catch (error) { // console.error('Error loading sessions:', error); } } // Delete a session async deleteSession(sessionId) { this.sessions.delete(sessionId); try { const filePath = this._safeFilePath(sessionId); await fs.unlink(filePath); } catch (error) { // console.error('Error deleting session file:', error); } } // Get session messages for display getSessionMessages(sessionId) { const session = this.sessions.get(sessionId); if (!session) return []; return session.messages.map(msg => ({ type: 'message', message: { role: msg.role, content: msg.content }, timestamp: msg.timestamp.toISOString() })); } } // Singleton instance const sessionManager = new SessionManager(); export const ready = sessionManager.ready; export default sessionManager; ================================================ FILE: server/utils/commandParser.js ================================================ import { promises as fs } from 'fs'; import path from 'path'; import { execFile } from 'child_process'; import { promisify } from 'util'; import { parse as parseShellCommand } from 'shell-quote'; import { parseFrontmatter } from './frontmatter.js'; const execFileAsync = promisify(execFile); // Configuration const MAX_INCLUDE_DEPTH = 3; const BASH_TIMEOUT = 30000; // 30 seconds const BASH_COMMAND_ALLOWLIST = [ 'echo', 'ls', 'pwd', 'date', 'whoami', 'git', 'npm', 'node', 'cat', 'grep', 'find', 'task-master' ]; /** * Parse a markdown command file and extract frontmatter and content * @param {string} content - Raw markdown content * @returns {object} Parsed command with data (frontmatter) and content */ export function parseCommand(content) { try { const parsed = parseFrontmatter(content); return { data: parsed.data || {}, content: parsed.content || '', raw: content }; } catch (error) { throw new Error(`Failed to parse command: ${error.message}`); } } /** * Replace argument placeholders in content * @param {string} content - Content with placeholders * @param {string|array} args - Arguments to replace (string or array) * @returns {string} Content with replaced arguments */ export function replaceArguments(content, args) { if (!content) return content; let result = content; // Convert args to array if it's a string const argsArray = Array.isArray(args) ? args : (args ? [args] : []); // Replace $ARGUMENTS with all arguments joined by space const allArgs = argsArray.join(' '); result = result.replace(/\$ARGUMENTS/g, allArgs); // Replace positional arguments $1-$9 for (let i = 1; i <= 9; i++) { const regex = new RegExp(`\\$${i}`, 'g'); const value = argsArray[i - 1] || ''; result = result.replace(regex, value); } return result; } /** * Validate file path to prevent directory traversal * @param {string} filePath - Path to validate * @param {string} basePath - Base directory path * @returns {boolean} True if path is safe */ export function isPathSafe(filePath, basePath) { const resolvedPath = path.resolve(basePath, filePath); const resolvedBase = path.resolve(basePath); const relative = path.relative(resolvedBase, resolvedPath); return ( relative !== '' && !relative.startsWith('..') && !path.isAbsolute(relative) ); } /** * Process file includes in content (@filename syntax) * @param {string} content - Content with @filename includes * @param {string} basePath - Base directory for resolving file paths * @param {number} depth - Current recursion depth * @returns {Promise} Content with includes resolved */ export async function processFileIncludes(content, basePath, depth = 0) { if (!content) return content; // Prevent infinite recursion if (depth >= MAX_INCLUDE_DEPTH) { throw new Error(`Maximum include depth (${MAX_INCLUDE_DEPTH}) exceeded`); } // Match @filename patterns (at start of line or after whitespace) const includePattern = /(?:^|\s)@([^\s]+)/gm; const matches = [...content.matchAll(includePattern)]; if (matches.length === 0) { return content; } let result = content; for (const match of matches) { const fullMatch = match[0]; const filename = match[1]; // Security: prevent directory traversal if (!isPathSafe(filename, basePath)) { throw new Error(`Invalid file path (directory traversal detected): ${filename}`); } try { const filePath = path.resolve(basePath, filename); const fileContent = await fs.readFile(filePath, 'utf-8'); // Recursively process includes in the included file const processedContent = await processFileIncludes(fileContent, basePath, depth + 1); // Replace the @filename with the file content result = result.replace(fullMatch, fullMatch.startsWith(' ') ? ' ' + processedContent : processedContent); } catch (error) { if (error.code === 'ENOENT') { throw new Error(`File not found: ${filename}`); } throw error; } } return result; } /** * Validate that a command and its arguments are safe * @param {string} commandString - Command string to validate * @returns {{ allowed: boolean, command: string, args: string[], error?: string }} Validation result */ export function validateCommand(commandString) { const trimmedCommand = commandString.trim(); if (!trimmedCommand) { return { allowed: false, command: '', args: [], error: 'Empty command' }; } // Parse the command using shell-quote to handle quotes properly const parsed = parseShellCommand(trimmedCommand); // Check for shell operators or control structures const hasOperators = parsed.some(token => typeof token === 'object' && token.op ); if (hasOperators) { return { allowed: false, command: '', args: [], error: 'Shell operators (&&, ||, |, ;, etc.) are not allowed' }; } // Extract command and args (all should be strings after validation) const tokens = parsed.filter(token => typeof token === 'string'); if (tokens.length === 0) { return { allowed: false, command: '', args: [], error: 'No valid command found' }; } const [command, ...args] = tokens; // Extract just the command name (remove path if present) const commandName = path.basename(command); // Check if command exactly matches allowlist (no prefix matching) const isAllowed = BASH_COMMAND_ALLOWLIST.includes(commandName); if (!isAllowed) { return { allowed: false, command: commandName, args, error: `Command '${commandName}' is not in the allowlist` }; } // Validate arguments don't contain dangerous metacharacters const dangerousPattern = /[;&|`$()<>{}[\]\\]/; for (const arg of args) { if (dangerousPattern.test(arg)) { return { allowed: false, command: commandName, args, error: `Argument contains dangerous characters: ${arg}` }; } } return { allowed: true, command: commandName, args }; } /** * Backward compatibility: Check if command is allowed (deprecated) * @deprecated Use validateCommand() instead for better security * @param {string} command - Command to validate * @returns {boolean} True if command is allowed */ export function isBashCommandAllowed(command) { const result = validateCommand(command); return result.allowed; } /** * Sanitize bash command output * @param {string} output - Raw command output * @returns {string} Sanitized output */ export function sanitizeOutput(output) { if (!output) return ''; // Remove control characters except \t, \n, \r return [...output] .filter(ch => { const code = ch.charCodeAt(0); return code === 9 // \t || code === 10 // \n || code === 13 // \r || (code >= 32 && code !== 127); }) .join(''); } /** * Process bash commands in content (!command syntax) * @param {string} content - Content with !command syntax * @param {object} options - Options for bash execution * @returns {Promise} Content with bash commands executed and replaced */ export async function processBashCommands(content, options = {}) { if (!content) return content; const { cwd = process.cwd(), timeout = BASH_TIMEOUT } = options; // Match !command patterns (at start of line or after whitespace) const commandPattern = /(?:^|\n)!(.+?)(?=\n|$)/g; const matches = [...content.matchAll(commandPattern)]; if (matches.length === 0) { return content; } let result = content; for (const match of matches) { const fullMatch = match[0]; const commandString = match[1].trim(); // Security: validate command and parse args const validation = validateCommand(commandString); if (!validation.allowed) { throw new Error(`Command not allowed: ${commandString} - ${validation.error}`); } try { // Execute without shell using execFile with parsed args const { stdout, stderr } = await execFileAsync( validation.command, validation.args, { cwd, timeout, maxBuffer: 1024 * 1024, // 1MB max output shell: false, // IMPORTANT: No shell interpretation env: { ...process.env, PATH: process.env.PATH } // Inherit PATH for finding commands } ); const output = sanitizeOutput(stdout || stderr || ''); // Replace the !command with the output result = result.replace(fullMatch, fullMatch.startsWith('\n') ? '\n' + output : output); } catch (error) { if (error.killed) { throw new Error(`Command timeout: ${commandString}`); } throw new Error(`Command failed: ${commandString} - ${error.message}`); } } return result; } ================================================ FILE: server/utils/frontmatter.js ================================================ import matter from 'gray-matter'; const disabledFrontmatterEngine = () => ({}); const frontmatterOptions = { language: 'yaml', // Disable JS/JSON frontmatter parsing to avoid executable project content. // Mirrors Gatsby's mitigation for gray-matter. engines: { js: disabledFrontmatterEngine, javascript: disabledFrontmatterEngine, json: disabledFrontmatterEngine } }; export function parseFrontmatter(content) { return matter(content, frontmatterOptions); } ================================================ FILE: server/utils/gitConfig.js ================================================ import { spawn } from 'child_process'; function spawnAsync(command, args) { return new Promise((resolve, reject) => { const child = spawn(command, args, { shell: false }); let stdout = ''; child.stdout.on('data', (data) => { stdout += data.toString(); }); child.on('error', (error) => { reject(error); }); child.on('close', (code) => { if (code === 0) { resolve({ stdout }); return; } reject(new Error(`Command failed with code ${code}`)); }); }); } /** * Read git configuration from system's global git config * @returns {Promise<{git_name: string|null, git_email: string|null}>} */ export async function getSystemGitConfig() { try { const [nameResult, emailResult] = await Promise.all([ spawnAsync('git', ['config', '--global', 'user.name']).catch(() => ({ stdout: '' })), spawnAsync('git', ['config', '--global', 'user.email']).catch(() => ({ stdout: '' })) ]); return { git_name: nameResult.stdout.trim() || null, git_email: emailResult.stdout.trim() || null }; } catch (error) { return { git_name: null, git_email: null }; } } ================================================ FILE: server/utils/mcp-detector.js ================================================ /** * MCP SERVER DETECTION UTILITY * ============================ * * Centralized utility for detecting MCP server configurations. * Used across TaskMaster integration and other MCP-dependent features. */ import { promises as fsPromises } from 'fs'; import path from 'path'; import os from 'os'; /** * Check if task-master-ai MCP server is configured * Reads directly from Claude configuration files like claude-cli.js does * @returns {Promise} MCP detection result */ export async function detectTaskMasterMCPServer() { try { // Read Claude configuration files directly (same logic as mcp.js) 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) { // File doesn't exist or is not valid JSON, try next continue; } } if (!configData) { return { hasMCPServer: false, reason: 'No Claude configuration file found', hasConfig: false }; } // Look for task-master-ai in user-scoped MCP servers let taskMasterServer = null; if (configData.mcpServers && typeof configData.mcpServers === 'object') { const serverEntry = Object.entries(configData.mcpServers).find(([name, config]) => name === 'task-master-ai' || name.includes('task-master') || (config && config.command && config.command.includes('task-master')) ); if (serverEntry) { const [name, config] = serverEntry; taskMasterServer = { name, scope: 'user', config, type: config.command ? 'stdio' : (config.url ? 'http' : 'unknown') }; } } // Also check project-specific MCP servers if not found globally if (!taskMasterServer && configData.projects) { for (const [projectPath, projectConfig] of Object.entries(configData.projects)) { if (projectConfig.mcpServers && typeof projectConfig.mcpServers === 'object') { const serverEntry = Object.entries(projectConfig.mcpServers).find(([name, config]) => name === 'task-master-ai' || name.includes('task-master') || (config && config.command && config.command.includes('task-master')) ); if (serverEntry) { const [name, config] = serverEntry; taskMasterServer = { name, scope: 'local', projectPath, config, type: config.command ? 'stdio' : (config.url ? 'http' : 'unknown') }; break; } } } } if (taskMasterServer) { const isValid = !!(taskMasterServer.config && (taskMasterServer.config.command || taskMasterServer.config.url)); const hasEnvVars = !!(taskMasterServer.config && taskMasterServer.config.env && Object.keys(taskMasterServer.config.env).length > 0); return { hasMCPServer: true, isConfigured: isValid, hasApiKeys: hasEnvVars, scope: taskMasterServer.scope, config: { command: taskMasterServer.config?.command, args: taskMasterServer.config?.args || [], url: taskMasterServer.config?.url, envVars: hasEnvVars ? Object.keys(taskMasterServer.config.env) : [], type: taskMasterServer.type } }; } else { // Get list of available servers for debugging const availableServers = []; if (configData.mcpServers) { availableServers.push(...Object.keys(configData.mcpServers)); } if (configData.projects) { for (const projectConfig of Object.values(configData.projects)) { if (projectConfig.mcpServers) { availableServers.push(...Object.keys(projectConfig.mcpServers).map(name => `local:${name}`)); } } } return { hasMCPServer: false, reason: 'task-master-ai not found in configured MCP servers', hasConfig: true, configPath, availableServers }; } } catch (error) { console.error('Error detecting MCP server config:', error); return { hasMCPServer: false, reason: `Error checking MCP config: ${error.message}`, hasConfig: false }; } } /** * Get all configured MCP servers (not just TaskMaster) * @returns {Promise} 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: {} }; } } ================================================ FILE: server/utils/plugin-loader.js ================================================ import fs from 'fs'; import path from 'path'; import os from 'os'; import { spawn } from 'child_process'; const PLUGINS_DIR = path.join(os.homedir(), '.claude-code-ui', 'plugins'); const PLUGINS_CONFIG_PATH = path.join(os.homedir(), '.claude-code-ui', 'plugins.json'); const REQUIRED_MANIFEST_FIELDS = ['name', 'displayName', 'entry']; /** Strip embedded credentials from a repo URL before exposing it to the client. */ function sanitizeRepoUrl(raw) { try { const u = new URL(raw); u.username = ''; u.password = ''; return u.toString().replace(/\/$/, ''); } catch { // Not a parseable URL (e.g. SSH shorthand) — strip user:pass@ segment return raw.replace(/\/\/[^@/]+@/, '//'); } } const ALLOWED_TYPES = ['react', 'module']; const ALLOWED_SLOTS = ['tab']; export function getPluginsDir() { if (!fs.existsSync(PLUGINS_DIR)) { fs.mkdirSync(PLUGINS_DIR, { recursive: true }); } return PLUGINS_DIR; } export function getPluginsConfig() { try { if (fs.existsSync(PLUGINS_CONFIG_PATH)) { return JSON.parse(fs.readFileSync(PLUGINS_CONFIG_PATH, 'utf-8')); } } catch { // Corrupted config, start fresh } return {}; } export function savePluginsConfig(config) { const dir = path.dirname(PLUGINS_CONFIG_PATH); if (!fs.existsSync(dir)) { fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); } fs.writeFileSync(PLUGINS_CONFIG_PATH, JSON.stringify(config, null, 2), { mode: 0o600 }); } export function validateManifest(manifest) { if (!manifest || typeof manifest !== 'object') { return { valid: false, error: 'Manifest must be a JSON object' }; } for (const field of REQUIRED_MANIFEST_FIELDS) { if (!manifest[field] || typeof manifest[field] !== 'string') { return { valid: false, error: `Missing or invalid required field: ${field}` }; } } // Sanitize name — only allow alphanumeric, hyphens, underscores if (!/^[a-zA-Z0-9_-]+$/.test(manifest.name)) { return { valid: false, error: 'Plugin name must only contain letters, numbers, hyphens, and underscores' }; } if (manifest.type && !ALLOWED_TYPES.includes(manifest.type)) { return { valid: false, error: `Invalid plugin type: ${manifest.type}. Must be one of: ${ALLOWED_TYPES.join(', ')}` }; } if (manifest.slot && !ALLOWED_SLOTS.includes(manifest.slot)) { return { valid: false, error: `Invalid plugin slot: ${manifest.slot}. Must be one of: ${ALLOWED_SLOTS.join(', ')}` }; } // Validate entry is a relative path without traversal if (manifest.entry.includes('..') || path.isAbsolute(manifest.entry)) { return { valid: false, error: 'Entry must be a relative path without ".."' }; } if (manifest.server !== undefined && manifest.server !== null) { if (typeof manifest.server !== 'string' || manifest.server.includes('..') || path.isAbsolute(manifest.server)) { return { valid: false, error: 'Server entry must be a relative path string without ".."' }; } } if (manifest.permissions !== undefined) { if (!Array.isArray(manifest.permissions) || !manifest.permissions.every(p => typeof p === 'string')) { return { valid: false, error: 'Permissions must be an array of strings' }; } } return { valid: true }; } const BUILD_TIMEOUT_MS = 60_000; /** Run `npm run build` if the plugin's package.json declares a build script. */ function runBuildIfNeeded(dir, packageJsonPath, onSuccess, onError) { try { const pkg = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8')); if (!pkg.scripts?.build) { return onSuccess(); } } catch { return onSuccess(); // Unreadable package.json — skip build } const buildProcess = spawn('npm', ['run', 'build'], { cwd: dir, stdio: ['ignore', 'pipe', 'pipe'], }); let stderr = ''; let settled = false; const timer = setTimeout(() => { if (settled) return; settled = true; buildProcess.removeAllListeners(); buildProcess.kill(); onError(new Error('npm run build timed out')); }, BUILD_TIMEOUT_MS); buildProcess.stderr.on('data', (data) => { stderr += data.toString(); }); buildProcess.on('close', (code) => { if (settled) return; settled = true; clearTimeout(timer); if (code !== 0) { return onError(new Error(`npm run build failed (exit code ${code}): ${stderr.trim()}`)); } onSuccess(); }); buildProcess.on('error', (err) => { if (settled) return; settled = true; clearTimeout(timer); onError(new Error(`Failed to spawn build: ${err.message}`)); }); } export function scanPlugins() { const pluginsDir = getPluginsDir(); const config = getPluginsConfig(); const plugins = []; let entries; try { entries = fs.readdirSync(pluginsDir, { withFileTypes: true }); } catch { return plugins; } const seenNames = new Set(); for (const entry of entries) { if (!entry.isDirectory()) continue; // Skip transient temp directories from in-progress installs if (entry.name.startsWith('.tmp-')) continue; const manifestPath = path.join(pluginsDir, entry.name, 'manifest.json'); if (!fs.existsSync(manifestPath)) continue; try { const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8')); const validation = validateManifest(manifest); if (!validation.valid) { console.warn(`[Plugins] Skipping ${entry.name}: ${validation.error}`); continue; } // Skip duplicate manifest names if (seenNames.has(manifest.name)) { console.warn(`[Plugins] Skipping ${entry.name}: duplicate plugin name "${manifest.name}"`); continue; } seenNames.add(manifest.name); // Try to read git remote URL let repoUrl = null; try { const gitConfigPath = path.join(pluginsDir, entry.name, '.git', 'config'); if (fs.existsSync(gitConfigPath)) { const gitConfig = fs.readFileSync(gitConfigPath, 'utf-8'); const match = gitConfig.match(/url\s*=\s*(.+)/); if (match) { repoUrl = match[1].trim().replace(/\.git$/, ''); // Convert SSH URLs to HTTPS if (repoUrl.startsWith('git@')) { repoUrl = repoUrl.replace(/^git@([^:]+):/, 'https://$1/'); } // Strip embedded credentials (e.g. https://user:pass@host/...) repoUrl = sanitizeRepoUrl(repoUrl); } } } catch { /* ignore */ } plugins.push({ name: manifest.name, displayName: manifest.displayName, version: manifest.version || '0.0.0', description: manifest.description || '', author: manifest.author || '', icon: manifest.icon || 'Puzzle', type: manifest.type || 'module', slot: manifest.slot || 'tab', entry: manifest.entry, server: manifest.server || null, permissions: manifest.permissions || [], enabled: config[manifest.name]?.enabled !== false, // enabled by default dirName: entry.name, repoUrl, }); } catch (err) { console.warn(`[Plugins] Failed to read manifest for ${entry.name}:`, err.message); } } return plugins; } export function getPluginDir(name) { const plugins = scanPlugins(); const plugin = plugins.find(p => p.name === name); if (!plugin) return null; return path.join(getPluginsDir(), plugin.dirName); } export function resolvePluginAssetPath(name, assetPath) { const pluginDir = getPluginDir(name); if (!pluginDir) return null; const resolved = path.resolve(pluginDir, assetPath); // Prevent path traversal — canonicalize via realpath to defeat symlink bypasses if (!fs.existsSync(resolved)) return null; const realResolved = fs.realpathSync(resolved); const realPluginDir = fs.realpathSync(pluginDir); if (!realResolved.startsWith(realPluginDir + path.sep) && realResolved !== realPluginDir) { return null; } return realResolved; } export function installPluginFromGit(url) { return new Promise((resolve, reject) => { if (typeof url !== 'string' || !url.trim()) { return reject(new Error('Invalid URL: must be a non-empty string')); } if (url.startsWith('-')) { return reject(new Error('Invalid URL: must not start with "-"')); } // Extract repo name from URL for directory name const urlClean = url.replace(/\.git$/, '').replace(/\/$/, ''); const repoName = urlClean.split('/').pop(); if (!repoName || !/^[a-zA-Z0-9_.-]+$/.test(repoName)) { return reject(new Error('Could not determine a valid directory name from the URL')); } const pluginsDir = getPluginsDir(); const targetDir = path.resolve(pluginsDir, repoName); // Ensure the resolved target directory stays within the plugins directory if (!targetDir.startsWith(pluginsDir + path.sep)) { return reject(new Error('Invalid plugin directory path')); } if (fs.existsSync(targetDir)) { return reject(new Error(`Plugin directory "${repoName}" already exists`)); } // Clone into a temp directory so scanPlugins() never sees a partially-installed plugin const tempDir = fs.mkdtempSync(path.join(pluginsDir, `.tmp-${repoName}-`)); const cleanupTemp = () => { try { fs.rmSync(tempDir, { recursive: true, force: true }); } catch {} }; const finalize = (manifest) => { try { fs.renameSync(tempDir, targetDir); } catch (err) { cleanupTemp(); return reject(new Error(`Failed to move plugin into place: ${err.message}`)); } resolve(manifest); }; const gitProcess = spawn('git', ['clone', '--depth', '1', '--', url, tempDir], { stdio: ['ignore', 'pipe', 'pipe'], }); let stderr = ''; gitProcess.stderr.on('data', (data) => { stderr += data.toString(); }); gitProcess.on('close', (code) => { if (code !== 0) { cleanupTemp(); return reject(new Error(`git clone failed (exit code ${code}): ${stderr.trim()}`)); } // Validate manifest exists const manifestPath = path.join(tempDir, 'manifest.json'); if (!fs.existsSync(manifestPath)) { cleanupTemp(); return reject(new Error('Cloned repository does not contain a manifest.json')); } let manifest; try { manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8')); } catch { cleanupTemp(); return reject(new Error('manifest.json is not valid JSON')); } const validation = validateManifest(manifest); if (!validation.valid) { cleanupTemp(); return reject(new Error(`Invalid manifest: ${validation.error}`)); } // Reject if another installed plugin already uses this name const existing = scanPlugins().find(p => p.name === manifest.name); if (existing) { cleanupTemp(); return reject(new Error(`A plugin named "${manifest.name}" is already installed (in "${existing.dirName}")`)); } // Run npm install if package.json exists. // --ignore-scripts prevents postinstall hooks from executing arbitrary code. const packageJsonPath = path.join(tempDir, 'package.json'); if (fs.existsSync(packageJsonPath)) { const npmProcess = spawn('npm', ['install', '--ignore-scripts'], { cwd: tempDir, stdio: ['ignore', 'pipe', 'pipe'], }); npmProcess.on('close', (npmCode) => { if (npmCode !== 0) { cleanupTemp(); return reject(new Error(`npm install for ${repoName} failed (exit code ${npmCode})`)); } runBuildIfNeeded(tempDir, packageJsonPath, () => finalize(manifest), (err) => { cleanupTemp(); reject(err); }); }); npmProcess.on('error', (err) => { cleanupTemp(); reject(err); }); } else { finalize(manifest); } }); gitProcess.on('error', (err) => { cleanupTemp(); reject(new Error(`Failed to spawn git: ${err.message}`)); }); }); } export function updatePluginFromGit(name) { return new Promise((resolve, reject) => { const pluginDir = getPluginDir(name); if (!pluginDir) { return reject(new Error(`Plugin "${name}" not found`)); } // Only fast-forward to avoid silent divergence const gitProcess = spawn('git', ['pull', '--ff-only', '--'], { cwd: pluginDir, stdio: ['ignore', 'pipe', 'pipe'], }); let stderr = ''; gitProcess.stderr.on('data', (data) => { stderr += data.toString(); }); gitProcess.on('close', (code) => { if (code !== 0) { return reject(new Error(`git pull failed (exit code ${code}): ${stderr.trim()}`)); } // Re-validate manifest after update const manifestPath = path.join(pluginDir, 'manifest.json'); let manifest; try { manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8')); } catch { return reject(new Error('manifest.json is not valid JSON after update')); } const validation = validateManifest(manifest); if (!validation.valid) { return reject(new Error(`Invalid manifest after update: ${validation.error}`)); } // Re-run npm install if package.json exists const packageJsonPath = path.join(pluginDir, 'package.json'); if (fs.existsSync(packageJsonPath)) { const npmProcess = spawn('npm', ['install', '--ignore-scripts'], { cwd: pluginDir, stdio: ['ignore', 'pipe', 'pipe'], }); npmProcess.on('close', (npmCode) => { if (npmCode !== 0) { return reject(new Error(`npm install for ${name} failed (exit code ${npmCode})`)); } runBuildIfNeeded(pluginDir, packageJsonPath, () => resolve(manifest), (err) => reject(err)); }); npmProcess.on('error', (err) => reject(err)); } else { resolve(manifest); } }); gitProcess.on('error', (err) => { reject(new Error(`Failed to spawn git: ${err.message}`)); }); }); } export async function uninstallPlugin(name) { const pluginDir = getPluginDir(name); if (!pluginDir) { throw new Error(`Plugin "${name}" not found`); } // On Windows, file handles may be released slightly after process exit. // Retry a few times with a short delay before giving up. const MAX_RETRIES = 5; const RETRY_DELAY_MS = 500; for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) { try { fs.rmSync(pluginDir, { recursive: true, force: true }); break; } catch (err) { if (err.code === 'EBUSY' && attempt < MAX_RETRIES) { await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY_MS)); } else { throw err; } } } // Remove from config const config = getPluginsConfig(); delete config[name]; savePluginsConfig(config); } ================================================ FILE: server/utils/plugin-process-manager.js ================================================ import { spawn } from 'child_process'; import path from 'path'; import { scanPlugins, getPluginsConfig, getPluginDir } from './plugin-loader.js'; // Map const runningPlugins = new Map(); // Map> — in-flight start operations const startingPlugins = new Map(); /** * Start a plugin's server subprocess. * The plugin's server entry must print a JSON line with { ready: true, port: } * to stdout within 10 seconds. */ export function startPluginServer(name, pluginDir, serverEntry) { if (runningPlugins.has(name)) { return Promise.resolve(runningPlugins.get(name).port); } // Coalesce concurrent starts for the same plugin if (startingPlugins.has(name)) { return startingPlugins.get(name); } const startPromise = new Promise((resolve, reject) => { const serverPath = path.join(pluginDir, serverEntry); // Restricted env — only essentials, no host secrets const pluginProcess = spawn('node', [serverPath], { cwd: pluginDir, env: { PATH: process.env.PATH, HOME: process.env.HOME, NODE_ENV: process.env.NODE_ENV || 'production', PLUGIN_NAME: name, }, stdio: ['ignore', 'pipe', 'pipe'], }); let resolved = false; let stdout = ''; const timeout = setTimeout(() => { if (!resolved) { resolved = true; pluginProcess.kill(); reject(new Error('Plugin server did not report ready within 10 seconds')); } }, 10000); pluginProcess.stdout.on('data', (data) => { if (resolved) return; stdout += data.toString(); // Look for the JSON ready line const lines = stdout.split('\n'); for (const line of lines) { try { const msg = JSON.parse(line.trim()); if (msg.ready && typeof msg.port === 'number') { clearTimeout(timeout); resolved = true; runningPlugins.set(name, { process: pluginProcess, port: msg.port }); pluginProcess.on('exit', () => { runningPlugins.delete(name); }); console.log(`[Plugins] Server started for "${name}" on port ${msg.port}`); resolve(msg.port); } } catch { // Not JSON yet, keep buffering } } }); pluginProcess.stderr.on('data', (data) => { console.warn(`[Plugin:${name}] ${data.toString().trim()}`); }); pluginProcess.on('error', (err) => { clearTimeout(timeout); if (!resolved) { resolved = true; reject(new Error(`Failed to start plugin server: ${err.message}`)); } }); pluginProcess.on('exit', (code) => { clearTimeout(timeout); runningPlugins.delete(name); if (!resolved) { resolved = true; reject(new Error(`Plugin server exited with code ${code} before reporting ready`)); } }); }).finally(() => { startingPlugins.delete(name); }); startingPlugins.set(name, startPromise); return startPromise; } /** * Stop a plugin's server subprocess. * Returns a Promise that resolves when the process has fully exited. */ export function stopPluginServer(name) { const entry = runningPlugins.get(name); if (!entry) return Promise.resolve(); return new Promise((resolve) => { const cleanup = () => { clearTimeout(forceKillTimer); runningPlugins.delete(name); resolve(); }; entry.process.once('exit', cleanup); entry.process.kill('SIGTERM'); // Force kill after 5 seconds if still running const forceKillTimer = setTimeout(() => { if (runningPlugins.has(name)) { entry.process.kill('SIGKILL'); cleanup(); } }, 5000); console.log(`[Plugins] Server stopped for "${name}"`); }); } /** * Get the port a running plugin server is listening on. */ export function getPluginPort(name) { return runningPlugins.get(name)?.port ?? null; } /** * Check if a plugin's server is running. */ export function isPluginRunning(name) { return runningPlugins.has(name); } /** * Stop all running plugin servers (called on host shutdown). */ export function stopAllPlugins() { const stops = []; for (const [name] of runningPlugins) { stops.push(stopPluginServer(name)); } return Promise.all(stops); } /** * Start servers for all enabled plugins that have a server entry. * Called once on host server boot. */ export async function startEnabledPluginServers() { const plugins = scanPlugins(); const config = getPluginsConfig(); for (const plugin of plugins) { if (!plugin.server) continue; if (config[plugin.name]?.enabled === false) continue; const pluginDir = getPluginDir(plugin.name); if (!pluginDir) continue; try { await startPluginServer(plugin.name, pluginDir, plugin.server); } catch (err) { console.error(`[Plugins] Failed to start server for "${plugin.name}":`, err.message); } } } ================================================ FILE: server/utils/taskmaster-websocket.js ================================================ /** * TASKMASTER WEBSOCKET UTILITIES * ============================== * * Utilities for broadcasting TaskMaster state changes via WebSocket. * Integrates with the existing WebSocket system to provide real-time updates. */ /** * Broadcast TaskMaster project update to all connected clients * @param {WebSocket.Server} wss - WebSocket server instance * @param {string} projectName - Name of the updated project * @param {Object} taskMasterData - Updated TaskMaster data */ export function broadcastTaskMasterProjectUpdate(wss, projectName, taskMasterData) { if (!wss || !projectName) { console.warn('TaskMaster WebSocket broadcast: Missing wss or projectName'); return; } const message = { type: 'taskmaster-project-updated', projectName, taskMasterData, timestamp: new Date().toISOString() }; wss.clients.forEach((client) => { if (client.readyState === 1) { // WebSocket.OPEN try { client.send(JSON.stringify(message)); } catch (error) { console.error('Error sending TaskMaster project update:', error); } } }); } /** * Broadcast TaskMaster tasks update for a specific project * @param {WebSocket.Server} wss - WebSocket server instance * @param {string} projectName - Name of the project with updated tasks * @param {Object} tasksData - Updated tasks data */ export function broadcastTaskMasterTasksUpdate(wss, projectName, tasksData) { if (!wss || !projectName) { console.warn('TaskMaster WebSocket broadcast: Missing wss or projectName'); return; } const message = { type: 'taskmaster-tasks-updated', projectName, tasksData, timestamp: new Date().toISOString() }; wss.clients.forEach((client) => { if (client.readyState === 1) { // WebSocket.OPEN try { client.send(JSON.stringify(message)); } catch (error) { console.error('Error sending TaskMaster tasks update:', error); } } }); } /** * Broadcast MCP server status change * @param {WebSocket.Server} wss - WebSocket server instance * @param {Object} mcpStatus - Updated MCP server status */ export function broadcastMCPStatusChange(wss, mcpStatus) { if (!wss) { console.warn('TaskMaster WebSocket broadcast: Missing wss'); return; } const message = { type: 'taskmaster-mcp-status-changed', mcpStatus, timestamp: new Date().toISOString() }; wss.clients.forEach((client) => { if (client.readyState === 1) { // WebSocket.OPEN try { client.send(JSON.stringify(message)); } catch (error) { console.error('Error sending TaskMaster MCP status update:', error); } } }); } /** * Broadcast general TaskMaster update notification * @param {WebSocket.Server} wss - WebSocket server instance * @param {string} updateType - Type of update (e.g., 'initialization', 'configuration') * @param {Object} data - Additional data about the update */ export function broadcastTaskMasterUpdate(wss, updateType, data = {}) { if (!wss || !updateType) { console.warn('TaskMaster WebSocket broadcast: Missing wss or updateType'); return; } const message = { type: 'taskmaster-update', updateType, data, timestamp: new Date().toISOString() }; wss.clients.forEach((client) => { if (client.readyState === 1) { // WebSocket.OPEN try { client.send(JSON.stringify(message)); } catch (error) { console.error('Error sending TaskMaster update:', error); } } }); } ================================================ FILE: shared/modelConstants.js ================================================ /** * Centralized Model Definitions * Single source of truth for all supported AI models */ /** * Claude (Anthropic) Models * * Note: Claude uses two different formats: * - SDK format ('sonnet', 'opus') - used by the UI and claude-sdk.js * - API format ('claude-sonnet-4.5') - used by slash commands for display */ export const CLAUDE_MODELS = { // Models in SDK format (what the actual SDK accepts) OPTIONS: [ { value: "sonnet", label: "Sonnet" }, { value: "opus", label: "Opus" }, { value: "haiku", label: "Haiku" }, { value: "opusplan", label: "Opus Plan" }, { value: "sonnet[1m]", label: "Sonnet [1M]" }, ], DEFAULT: "sonnet", }; /** * Cursor Models */ export const CURSOR_MODELS = { OPTIONS: [ { value: "opus-4.6-thinking", label: "Claude 4.6 Opus (Thinking)" }, { value: "gpt-5.3-codex", label: "GPT-5.3" }, { value: "gpt-5.2-high", label: "GPT-5.2 High" }, { value: "gemini-3-pro", label: "Gemini 3 Pro" }, { value: "opus-4.5-thinking", label: "Claude 4.5 Opus (Thinking)" }, { value: "gpt-5.2", label: "GPT-5.2" }, { value: "gpt-5.1", label: "GPT-5.1" }, { value: "gpt-5.1-high", label: "GPT-5.1 High" }, { value: "composer-1", label: "Composer 1" }, { value: "auto", label: "Auto" }, { value: "sonnet-4.5", label: "Claude 4.5 Sonnet" }, { value: "sonnet-4.5-thinking", label: "Claude 4.5 Sonnet (Thinking)" }, { value: "opus-4.5", label: "Claude 4.5 Opus" }, { value: "gpt-5.1-codex", label: "GPT-5.1 Codex" }, { value: "gpt-5.1-codex-high", label: "GPT-5.1 Codex High" }, { value: "gpt-5.1-codex-max", label: "GPT-5.1 Codex Max" }, { value: "gpt-5.1-codex-max-high", label: "GPT-5.1 Codex Max High" }, { value: "opus-4.1", label: "Claude 4.1 Opus" }, { value: "grok", label: "Grok" }, ], DEFAULT: "gpt-5-3-codex", }; /** * Codex (OpenAI) Models */ export const CODEX_MODELS = { OPTIONS: [ { value: "gpt-5.4", label: "GPT-5.4" }, { value: "gpt-5.3-codex", label: "GPT-5.3 Codex" }, { value: "gpt-5.2-codex", label: "GPT-5.2 Codex" }, { value: "gpt-5.2", label: "GPT-5.2" }, { value: "gpt-5.1-codex-max", label: "GPT-5.1 Codex Max" }, { value: "o3", label: "O3" }, { value: "o4-mini", label: "O4-mini" }, ], DEFAULT: "gpt-5.4", }; /** * Gemini Models */ export const GEMINI_MODELS = { OPTIONS: [ { value: "gemini-3.1-pro-preview", label: "Gemini 3.1 Pro Preview" }, { value: "gemini-3-pro-preview", label: "Gemini 3 Pro Preview" }, { value: "gemini-3-flash-preview", label: "Gemini 3 Flash Preview" }, { value: "gemini-2.5-flash", label: "Gemini 2.5 Flash" }, { value: "gemini-2.5-pro", label: "Gemini 2.5 Pro" }, { value: "gemini-2.0-flash-lite", label: "Gemini 2.0 Flash Lite" }, { value: "gemini-2.0-flash", label: "Gemini 2.0 Flash" }, { value: "gemini-2.0-pro-exp", label: "Gemini 2.0 Pro Experimental" }, { value: "gemini-2.0-flash-thinking-exp", label: "Gemini 2.0 Flash Thinking", }, ], DEFAULT: "gemini-2.5-flash", }; ================================================ FILE: shared/networkHosts.js ================================================ export function isWildcardHost(host) { return host === '0.0.0.0' || host === '::'; } export function isLoopbackHost(host) { return host === 'localhost' || host === '127.0.0.1' || host === '::1' || host === '[::1]'; } export function normalizeLoopbackHost(host) { if (!host) { return host; } return isLoopbackHost(host) ? 'localhost' : host; } // Use localhost for connectable loopback and wildcard addresses in browser-facing URLs. export function getConnectableHost(host) { if (!host) { return 'localhost'; } return isWildcardHost(host) || isLoopbackHost(host) ? 'localhost' : host; } ================================================ FILE: src/App.tsx ================================================ import { BrowserRouter as Router, Route, Routes } from 'react-router-dom'; import { I18nextProvider } from 'react-i18next'; import { ThemeProvider } from './contexts/ThemeContext'; import { AuthProvider, ProtectedRoute } from './components/auth'; import { TaskMasterProvider } from './contexts/TaskMasterContext'; import { TasksSettingsProvider } from './contexts/TasksSettingsContext'; import { WebSocketProvider } from './contexts/WebSocketContext'; import { PluginsProvider } from './contexts/PluginsContext'; import AppContent from './components/app/AppContent'; import i18n from './i18n/config.js'; export default function App() { return ( } /> } /> ); } ================================================ FILE: src/components/app/AppContent.tsx ================================================ import { useEffect, useRef } from 'react'; import { useNavigate, useParams } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import Sidebar from '../sidebar/view/Sidebar'; import MainContent from '../main-content/view/MainContent'; import { useWebSocket } from '../../contexts/WebSocketContext'; import { useDeviceSettings } from '../../hooks/useDeviceSettings'; import { useSessionProtection } from '../../hooks/useSessionProtection'; import { useProjectsState } from '../../hooks/useProjectsState'; import MobileNav from './MobileNav'; export default function AppContent() { const navigate = useNavigate(); const { sessionId } = useParams<{ sessionId?: string }>(); const { t } = useTranslation('common'); const { isMobile } = useDeviceSettings({ trackPWA: false }); const { ws, sendMessage, latestMessage, isConnected } = useWebSocket(); const wasConnectedRef = useRef(false); const { activeSessions, processingSessions, markSessionAsActive, markSessionAsInactive, markSessionAsProcessing, markSessionAsNotProcessing, replaceTemporarySession, } = useSessionProtection(); const { selectedProject, selectedSession, activeTab, sidebarOpen, isLoadingProjects, isInputFocused, externalMessageUpdate, setActiveTab, setSidebarOpen, setIsInputFocused, setShowSettings, openSettings, refreshProjectsSilently, sidebarSharedProps, } = useProjectsState({ sessionId, navigate, latestMessage, isMobile, activeSessions, }); useEffect(() => { // Expose a non-blocking refresh for chat/session flows. // Full loading refreshes are still available through direct fetchProjects calls. window.refreshProjects = refreshProjectsSilently; return () => { if (window.refreshProjects === refreshProjectsSilently) { delete window.refreshProjects; } }; }, [refreshProjectsSilently]); useEffect(() => { window.openSettings = openSettings; return () => { if (window.openSettings === openSettings) { delete window.openSettings; } }; }, [openSettings]); useEffect(() => { if (typeof navigator === 'undefined' || !('serviceWorker' in navigator)) { return undefined; } const handleServiceWorkerMessage = (event: MessageEvent) => { const message = event.data; if (!message || message.type !== 'notification:navigate') { return; } if (typeof message.provider === 'string' && message.provider.trim()) { localStorage.setItem('selected-provider', message.provider); } setActiveTab('chat'); setSidebarOpen(false); void refreshProjectsSilently(); if (typeof message.sessionId === 'string' && message.sessionId) { navigate(`/session/${message.sessionId}`); return; } navigate('/'); }; navigator.serviceWorker.addEventListener('message', handleServiceWorkerMessage); return () => { navigator.serviceWorker.removeEventListener('message', handleServiceWorkerMessage); }; }, [navigate, refreshProjectsSilently, setActiveTab, setSidebarOpen]); // Permission recovery: query pending permissions on WebSocket reconnect or session change useEffect(() => { const isReconnect = isConnected && !wasConnectedRef.current; if (isReconnect) { wasConnectedRef.current = true; } else if (!isConnected) { wasConnectedRef.current = false; } if (isConnected && selectedSession?.id) { sendMessage({ type: 'get-pending-permissions', sessionId: selectedSession.id }); } }, [isConnected, selectedSession?.id, sendMessage]); return (
{!isMobile ? (
) : (
)}
setSidebarOpen(true)} isLoading={isLoadingProjects} onInputFocusChange={setIsInputFocused} onSessionActive={markSessionAsActive} onSessionInactive={markSessionAsInactive} onSessionProcessing={markSessionAsProcessing} onSessionNotProcessing={markSessionAsNotProcessing} processingSessions={processingSessions} onReplaceTemporarySession={replaceTemporarySession} onNavigateToSession={(targetSessionId: string) => navigate(`/session/${targetSessionId}`)} onShowSettings={() => setShowSettings(true)} externalMessageUpdate={externalMessageUpdate} />
{isMobile && ( )}
); } ================================================ FILE: src/components/app/MobileNav.tsx ================================================ import { useState, useRef, useEffect, type Dispatch, type SetStateAction } from 'react'; import { useTranslation } from 'react-i18next'; import { MessageSquare, Folder, Terminal, GitBranch, ClipboardCheck, Ellipsis, Puzzle, Box, Database, Globe, Wrench, Zap, BarChart3, type LucideIcon, } from 'lucide-react'; import { useTasksSettings } from '../../contexts/TasksSettingsContext'; import { usePlugins } from '../../contexts/PluginsContext'; import { AppTab } from '../../types/app'; const PLUGIN_ICON_MAP: Record = { Puzzle, Box, Database, Globe, Terminal, Wrench, Zap, BarChart3, Folder, MessageSquare, GitBranch, }; type CoreTabId = Exclude; type CoreNavItem = { id: CoreTabId; icon: LucideIcon; label: string; }; type MobileNavProps = { activeTab: AppTab; setActiveTab: Dispatch>; isInputFocused: boolean; }; export default function MobileNav({ activeTab, setActiveTab, isInputFocused }: MobileNavProps) { const { t } = useTranslation(['common', 'settings']); const { tasksEnabled, isTaskMasterInstalled } = useTasksSettings(); const shouldShowTasksTab = Boolean(tasksEnabled && isTaskMasterInstalled); const { plugins } = usePlugins(); const [moreOpen, setMoreOpen] = useState(false); const moreRef = useRef(null); const enabledPlugins = plugins.filter((p) => p.enabled); const hasPlugins = enabledPlugins.length > 0; const isPluginActive = activeTab.startsWith('plugin:'); // Close the menu on outside tap useEffect(() => { if (!moreOpen) return; const handleTap = (e: PointerEvent) => { const target = e.target; if (moreRef.current && target instanceof Node && !moreRef.current.contains(target)) { setMoreOpen(false); } }; document.addEventListener('pointerdown', handleTap); return () => document.removeEventListener('pointerdown', handleTap); }, [moreOpen]); // Close menu when a plugin tab is selected const selectPlugin = (name: string) => { const pluginTab = `plugin:${name}` as AppTab; setActiveTab(pluginTab); setMoreOpen(false); }; const baseCoreItems: CoreNavItem[] = [ { id: 'chat', icon: MessageSquare, label: 'Chat' }, { id: 'shell', icon: Terminal, label: 'Shell' }, { id: 'files', icon: Folder, label: 'Files' }, { id: 'git', icon: GitBranch, label: 'Git' }, ]; const coreItems: CoreNavItem[] = shouldShowTasksTab ? [...baseCoreItems, { id: 'tasks', icon: ClipboardCheck, label: 'Tasks' }] : baseCoreItems; return (
{coreItems.map((item) => { const Icon = item.icon; const isActive = activeTab === item.id; return ( ); })} {/* "More" button — only shown when there are enabled plugins */} {hasPlugins && (
{/* Popover menu */} {moreOpen && (
{enabledPlugins.map((p) => { const Icon = PLUGIN_ICON_MAP[p.icon] || Puzzle; const isActive = activeTab === `plugin:${p.name}`; return ( ); })}
)}
)}
); } ================================================ FILE: src/components/auth/constants.ts ================================================ export const AUTH_TOKEN_STORAGE_KEY = 'auth-token'; export const AUTH_ERROR_MESSAGES = { authStatusCheckFailed: 'Failed to check authentication status', loginFailed: 'Login failed', registrationFailed: 'Registration failed', networkError: 'Network error. Please try again.', } as const; ================================================ FILE: src/components/auth/context/AuthContext.tsx ================================================ import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'; import { IS_PLATFORM } from '../../../constants/config'; import { api } from '../../../utils/api'; import { AUTH_ERROR_MESSAGES, AUTH_TOKEN_STORAGE_KEY } from '../constants'; import type { AuthContextValue, AuthProviderProps, AuthSessionPayload, AuthStatusPayload, AuthUser, AuthUserPayload, OnboardingStatusPayload, } from '../types'; import { parseJsonSafely, resolveApiErrorMessage } from '../utils'; const AuthContext = createContext(null); const readStoredToken = (): string | null => localStorage.getItem(AUTH_TOKEN_STORAGE_KEY); const persistToken = (token: string) => { localStorage.setItem(AUTH_TOKEN_STORAGE_KEY, token); }; const clearStoredToken = () => { localStorage.removeItem(AUTH_TOKEN_STORAGE_KEY); }; export function useAuth(): AuthContextValue { const context = useContext(AuthContext); if (!context) { throw new Error('useAuth must be used within an AuthProvider'); } return context; } export function AuthProvider({ children }: AuthProviderProps) { const [user, setUser] = useState(null); const [token, setToken] = useState(() => readStoredToken()); const [isLoading, setIsLoading] = useState(true); const [needsSetup, setNeedsSetup] = useState(false); const [hasCompletedOnboarding, setHasCompletedOnboarding] = useState(true); const [error, setError] = useState(null); const setSession = useCallback((nextUser: AuthUser, nextToken: string) => { setUser(nextUser); setToken(nextToken); persistToken(nextToken); }, []); const clearSession = useCallback(() => { setUser(null); setToken(null); clearStoredToken(); }, []); const checkOnboardingStatus = useCallback(async () => { try { const response = await api.user.onboardingStatus(); if (!response.ok) { return; } const payload = await parseJsonSafely(response); setHasCompletedOnboarding(Boolean(payload?.hasCompletedOnboarding)); } catch (caughtError) { console.error('Error checking onboarding status:', caughtError); // Fail open to avoid blocking access on transient onboarding status errors. setHasCompletedOnboarding(true); } }, []); const refreshOnboardingStatus = useCallback(async () => { await checkOnboardingStatus(); }, [checkOnboardingStatus]); const checkAuthStatus = useCallback(async () => { try { setIsLoading(true); setError(null); const statusResponse = await api.auth.status(); const statusPayload = await parseJsonSafely(statusResponse); if (statusPayload?.needsSetup) { setNeedsSetup(true); return; } setNeedsSetup(false); if (!token) { return; } const userResponse = await api.auth.user(); if (!userResponse.ok) { clearSession(); return; } const userPayload = await parseJsonSafely(userResponse); if (!userPayload?.user) { clearSession(); return; } setUser(userPayload.user); await checkOnboardingStatus(); } catch (caughtError) { console.error('[Auth] Auth status check failed:', caughtError); setError(AUTH_ERROR_MESSAGES.authStatusCheckFailed); } finally { setIsLoading(false); } }, [checkOnboardingStatus, clearSession, token]); useEffect(() => { if (IS_PLATFORM) { setUser({ username: 'platform-user' }); setNeedsSetup(false); void checkOnboardingStatus().finally(() => { setIsLoading(false); }); return; } void checkAuthStatus(); }, [checkAuthStatus, checkOnboardingStatus]); const login = useCallback( async (username, password) => { try { setError(null); const response = await api.auth.login(username, password); const payload = await parseJsonSafely(response); if (!response.ok || !payload?.token || !payload.user) { const message = resolveApiErrorMessage(payload, AUTH_ERROR_MESSAGES.loginFailed); setError(message); return { success: false, error: message }; } setSession(payload.user, payload.token); setNeedsSetup(false); await checkOnboardingStatus(); return { success: true }; } catch (caughtError) { console.error('Login error:', caughtError); setError(AUTH_ERROR_MESSAGES.networkError); return { success: false, error: AUTH_ERROR_MESSAGES.networkError }; } }, [checkOnboardingStatus, setSession], ); const register = useCallback( async (username, password) => { try { setError(null); const response = await api.auth.register(username, password); const payload = await parseJsonSafely(response); if (!response.ok || !payload?.token || !payload.user) { const message = resolveApiErrorMessage(payload, AUTH_ERROR_MESSAGES.registrationFailed); setError(message); return { success: false, error: message }; } setSession(payload.user, payload.token); setNeedsSetup(false); await checkOnboardingStatus(); return { success: true }; } catch (caughtError) { console.error('Registration error:', caughtError); setError(AUTH_ERROR_MESSAGES.networkError); return { success: false, error: AUTH_ERROR_MESSAGES.networkError }; } }, [checkOnboardingStatus, setSession], ); const logout = useCallback(() => { const tokenToInvalidate = token; clearSession(); if (tokenToInvalidate) { void api.auth.logout().catch((caughtError: unknown) => { console.error('Logout endpoint error:', caughtError); }); } }, [clearSession, token]); const contextValue = useMemo( () => ({ user, token, isLoading, needsSetup, hasCompletedOnboarding, error, login, register, logout, refreshOnboardingStatus, }), [ error, hasCompletedOnboarding, isLoading, login, logout, needsSetup, refreshOnboardingStatus, register, token, user, ], ); return {children}; } ================================================ FILE: src/components/auth/index.ts ================================================ export { AuthProvider, useAuth } from './context/AuthContext'; export { default as ProtectedRoute } from './view/ProtectedRoute'; ================================================ FILE: src/components/auth/types.ts ================================================ import type { ReactNode } from 'react'; export type AuthUser = { id?: number | string; username: string; [key: string]: unknown; }; export type AuthActionResult = { success: true } | { success: false; error: string }; export type AuthSessionPayload = { token?: string; user?: AuthUser; error?: string; message?: string; }; export type AuthStatusPayload = { needsSetup?: boolean; }; export type AuthUserPayload = { user?: AuthUser; }; export type OnboardingStatusPayload = { hasCompletedOnboarding?: boolean; }; export type ApiErrorPayload = { error?: string; message?: string; }; export type AuthContextValue = { user: AuthUser | null; token: string | null; isLoading: boolean; needsSetup: boolean; hasCompletedOnboarding: boolean; error: string | null; login: (username: string, password: string) => Promise; register: (username: string, password: string) => Promise; logout: () => void; refreshOnboardingStatus: () => Promise; }; export type AuthProviderProps = { children: ReactNode; }; ================================================ FILE: src/components/auth/utils.ts ================================================ import type { ApiErrorPayload } from './types'; export async function parseJsonSafely(response: Response): Promise { try { return (await response.json()) as T; } catch { return null; } } export function resolveApiErrorMessage(payload: ApiErrorPayload | null, fallback: string): string { if (!payload) { return fallback; } return payload.error ?? payload.message ?? fallback; } ================================================ FILE: src/components/auth/view/AuthErrorAlert.tsx ================================================ type AuthErrorAlertProps = { errorMessage: string; }; export default function AuthErrorAlert({ errorMessage }: AuthErrorAlertProps) { if (!errorMessage) { return null; } return (

{errorMessage}

); } ================================================ FILE: src/components/auth/view/AuthInputField.tsx ================================================ type AuthInputFieldProps = { id: string; label: string; value: string; onChange: (nextValue: string) => void; placeholder: string; isDisabled: boolean; type?: 'text' | 'password' | 'email'; name?: string; autoComplete?: string; }; /** * A labelled input field for authentication forms. * Renders a `