diff --git a/bun.lock b/bun.lock index db583128..f159f01d 100644 --- a/bun.lock +++ b/bun.lock @@ -5,11 +5,10 @@ "name": "21st-desktop", "dependencies": { "@ai-sdk/react": "^3.0.14", - "@anthropic-ai/claude-agent-sdk": "0.2.25", + "@anthropic-ai/claude-agent-sdk": "0.2.22", "@git-diff-view/react": "^0.0.35", "@git-diff-view/shiki": "^0.0.36", "@modelcontextprotocol/sdk": "^1.25.3", - "@monaco-editor/react": "^4.7.0", "@radix-ui/react-accordion": "^1.2.12", "@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-checkbox": "^1.3.3", @@ -55,7 +54,6 @@ "jsonc-parser": "^3.3.1", "lucide-react": "^0.468.0", "mermaid": "^11.12.2", - "monaco-editor": "^0.55.1", "motion": "^11.15.0", "next-themes": "^0.4.4", "node-pty": "^1.1.0", @@ -66,6 +64,7 @@ "react-dom": "19.2.1", "react-hotkeys-hook": "^4.6.1", "react-icons": "^5.5.0", + "react-syntax-highlighter": "^16.1.0", "react-zoom-pan-pinch": "^3.7.0", "remark-breaks": "^4.0.0", "remark-gfm": "^4.0.1", @@ -89,6 +88,7 @@ "@types/node": "^20.17.50", "@types/react": "^19.0.7", "@types/react-dom": "^19.0.3", + "@types/react-syntax-highlighter": "^15.5.13", "@vitejs/plugin-react": "^4.3.4", "@welldone-software/why-did-you-render": "^10.0.1", "autoprefixer": "^10.4.20", @@ -119,7 +119,7 @@ "@antfu/install-pkg": ["@antfu/install-pkg@1.1.0", "", { "dependencies": { "package-manager-detector": "^1.3.0", "tinyexec": "^1.0.1" } }, "sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ=="], - "@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.2.25", "", { "optionalDependencies": { "@img/sharp-darwin-arm64": "^0.33.5", "@img/sharp-darwin-x64": "^0.33.5", "@img/sharp-linux-arm": "^0.33.5", "@img/sharp-linux-arm64": "^0.33.5", "@img/sharp-linux-x64": "^0.33.5", "@img/sharp-linuxmusl-arm64": "^0.33.5", "@img/sharp-linuxmusl-x64": "^0.33.5", "@img/sharp-win32-x64": "^0.33.5" }, "peerDependencies": { "zod": "^4.0.0" } }, "sha512-YIP3I40+XSkC3zE1Z8KRQY02VA7UfofFamF1cFrLe7FbtCnjpslyDl9coGBh2DAi9xj2yQcKZZf751jEWpB+dQ=="], + "@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.2.22", "", { "optionalDependencies": { "@img/sharp-darwin-arm64": "^0.33.5", "@img/sharp-darwin-x64": "^0.33.5", "@img/sharp-linux-arm": "^0.33.5", "@img/sharp-linux-arm64": "^0.33.5", "@img/sharp-linux-x64": "^0.33.5", "@img/sharp-linuxmusl-arm64": "^0.33.5", "@img/sharp-linuxmusl-x64": "^0.33.5", "@img/sharp-win32-x64": "^0.33.5" }, "peerDependencies": { "zod": "^4.0.0" } }, "sha512-AO39PYe8nAfqx748UmQQ26BZAX91sOQomYFdtf5AwMwgOIH0BumrNHsHtrmgBZalsseWn84LAFfKtG5ylGR5Nw=="], "@apm-js-collab/code-transformer": ["@apm-js-collab/code-transformer@0.8.2", "", {}, "sha512-YRjJjNq5KFSjDUoqu5pFUWrrsvGOxl6c3bu+uMFc9HNNptZ2rNU/TI2nLw4jnhQNtka972Ee2m3uqbvDQtPeCA=="], @@ -159,6 +159,8 @@ "@babel/plugin-transform-react-jsx-source": ["@babel/plugin-transform-react-jsx-source@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw=="], + "@babel/runtime": ["@babel/runtime@7.28.6", "", {}, "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA=="], + "@babel/template": ["@babel/template@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="], "@babel/traverse": ["@babel/traverse@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/generator": "^7.28.6", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.28.6", "@babel/template": "^7.28.6", "@babel/types": "^7.28.6", "debug": "^4.3.1" } }, "sha512-fgWX62k02qtjqdSNTAGxmKYY/7FSL9WAS1o2Hu5+I5m9T0yxZzr4cnrfXQ/MX0rIifthCSs6FKTlzYbJcPtMNg=="], @@ -335,10 +337,6 @@ "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.25.3", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.0.1", "express-rate-limit": "^7.5.0", "jose": "^6.1.1", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.0" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-vsAMBMERybvYgKbg/l4L1rhS7VXV1c0CtyJg72vwxONVX0l4ZfKVAnZEWTQixJGTzKnELjQ59e4NbdFDALRiAQ=="], - "@monaco-editor/loader": ["@monaco-editor/loader@1.7.0", "", { "dependencies": { "state-local": "^1.0.6" } }, "sha512-gIwR1HrJrrx+vfyOhYmCZ0/JcWqG5kbfG7+d3f/C1LXk2EvzAbHSg3MQ5lO2sMlo9izoAZ04shohfKLVT6crVA=="], - - "@monaco-editor/react": ["@monaco-editor/react@4.7.0", "", { "dependencies": { "@monaco-editor/loader": "^1.5.0" }, "peerDependencies": { "monaco-editor": ">= 0.25.0 < 1", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-cyzXQCtO47ydzxpQtCGSQGOC8Gk3ZUeBXFAxD+CWXYFo5OqZyZUonFl0DwUlTyAfRHntBfw2p3w4s9R6oe1eCA=="], - "@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="], "@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="], @@ -755,10 +753,14 @@ "@types/plist": ["@types/plist@3.0.5", "", { "dependencies": { "@types/node": "*", "xmlbuilder": ">=11.0.1" } }, "sha512-E6OCaRmAe4WDmWNsL/9RMqdkkzDCY1etutkflWk4c+AcjDU07Pcz1fQwTX0TQz+Pxqn9i4L1TU3UFpjnrcDgxA=="], + "@types/prismjs": ["@types/prismjs@1.26.5", "", {}, "sha512-AUZTa7hQ2KY5L7AmtSiqxlhWxb4ina0yd8hNbl4TWuqnv/pFP0nDMb3YrfSBf4hJVGLh2YEIBfKaBW/9UEl6IQ=="], + "@types/react": ["@types/react@19.2.9", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-Lpo8kgb/igvMIPeNV2rsYKTgaORYdO1XGVZ4Qz3akwOj0ySGYMPlQWa8BaLn0G63D1aSaAQ5ldR06wCpChQCjA=="], "@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="], + "@types/react-syntax-highlighter": ["@types/react-syntax-highlighter@15.5.13", "", { "dependencies": { "@types/react": "*" } }, "sha512-uLGJ87j6Sz8UaBAooU0T6lWJ0dBmjZgN1PZTrj05TNql2/XpC6+4HhMT5syIdFUUt+FASfCeLLv4kBygNU+8qA=="], + "@types/responselike": ["@types/responselike@1.0.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw=="], "@types/tedious": ["@types/tedious@4.0.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-KHPsfX/FoVbUGbyYvk1q9MMQHLPeRZhRJZdO45Q4YjvFkv4hMNghCWTvy7rdKessBsmtz4euWCWAB6/tVpI1Iw=="], @@ -1251,6 +1253,8 @@ "fastq": ["fastq@1.20.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw=="], + "fault": ["fault@1.0.4", "", { "dependencies": { "format": "^0.2.0" } }, "sha512-CJ0HCB5tL5fYTEA7ToAq5+kTwd++Borf1/bifxd9iT70QcXr4MRrO3Llf8Ifs70q+SJcGHFtnIE/Nw6giCtECA=="], + "fd-slicer": ["fd-slicer@1.1.0", "", { "dependencies": { "pend": "~1.2.0" } }, "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g=="], "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], @@ -1269,6 +1273,8 @@ "form-data": ["form-data@4.0.5", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w=="], + "format": ["format@0.2.2", "", {}, "sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww=="], + "forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="], "forwarded-parse": ["forwarded-parse@2.1.2", "", {}, "sha512-alTFZZQDKMporBH77856pXgzhEzaUVmLCDk+egLgIgHst3Tpndzz8MnKe+GzRJRfvVdn69HhpW7cmXzvtLvJAw=="], @@ -1359,6 +1365,8 @@ "highlight.js": ["highlight.js@11.11.1", "", {}, "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w=="], + "highlightjs-vue": ["highlightjs-vue@1.0.0", "", {}, "sha512-PDEfEF102G23vHmPhLyPboFCD+BkMGu+GuJe2d9/eH4FsCwvgBpnc9n0pGE+ffKdph38s6foEZiEjdgHdzp+IA=="], + "hono": ["hono@4.11.5", "", {}, "sha512-WemPi9/WfyMwZs+ZUXdiwcCh9Y+m7L+8vki9MzDw3jJ+W9Lc+12HGsd368Qc1vZi1xwW8BWMMsnK5efYKPdt4g=="], "hosted-git-info": ["hosted-git-info@4.1.0", "", { "dependencies": { "lru-cache": "^6.0.0" } }, "sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA=="], @@ -1681,8 +1689,6 @@ "module-details-from-path": ["module-details-from-path@1.0.4", "", {}, "sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w=="], - "monaco-editor": ["monaco-editor@0.55.1", "", { "dependencies": { "dompurify": "3.2.7", "marked": "14.0.0" } }, "sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A=="], - "motion": ["motion@11.18.2", "", { "dependencies": { "framer-motion": "^11.18.2", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-JLjvFDuFr42NFtcVoMAyC2sEjnpA8xpy6qWPyzQvCloznAyQ8FIXioxWfHiLtgYhoVpfUqSWpn1h9++skj9+Wg=="], "motion-dom": ["motion-dom@11.18.1", "", { "dependencies": { "motion-utils": "^11.18.1" } }, "sha512-g76KvA001z+atjfxczdRtw/RXOM3OMSdd1f4DL77qCTF/+avrRJiawSG4yDibEQ215sr9kpinSlX2pCTJ9zbhw=="], @@ -1833,6 +1839,8 @@ "prebuild-install": ["prebuild-install@7.1.3", "", { "dependencies": { "detect-libc": "^2.0.0", "expand-template": "^2.0.3", "github-from-package": "0.0.0", "minimist": "^1.2.3", "mkdirp-classic": "^0.5.3", "napi-build-utils": "^2.0.0", "node-abi": "^3.3.0", "pump": "^3.0.0", "rc": "^1.2.7", "simple-get": "^4.0.0", "tar-fs": "^2.0.0", "tunnel-agent": "^0.6.0" }, "bin": { "prebuild-install": "bin.js" } }, "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug=="], + "prismjs": ["prismjs@1.30.0", "", {}, "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw=="], + "process-nextick-args": ["process-nextick-args@2.0.1", "", {}, "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag=="], "progress": ["progress@2.0.3", "", {}, "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA=="], @@ -1881,6 +1889,8 @@ "react-style-singleton": ["react-style-singleton@2.2.3", "", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="], + "react-syntax-highlighter": ["react-syntax-highlighter@16.1.0", "", { "dependencies": { "@babel/runtime": "^7.28.4", "highlight.js": "^10.4.1", "highlightjs-vue": "^1.0.0", "lowlight": "^1.17.0", "prismjs": "^1.30.0", "refractor": "^5.0.0" }, "peerDependencies": { "react": ">= 0.14.0" } }, "sha512-E40/hBiP5rCNwkeBN1vRP+xow1X0pndinO+z3h7HLsHyjztbyjfzNWNKuAsJj+7DLam9iT4AaaOZnueCU+Nplg=="], + "react-zoom-pan-pinch": ["react-zoom-pan-pinch@3.7.0", "", { "peerDependencies": { "react": "*", "react-dom": "*" } }, "sha512-UmReVZ0TxlKzxSbYiAj+LeGRW8s8LraAFTXRAxzMYnNRgGPsxCudwZKVkjvGmjtx7SW/hZamt69NUmGf4xrkXA=="], "reactivity-store": ["reactivity-store@0.3.12", "", { "dependencies": { "@vue/reactivity": "~3.5.22", "@vue/shared": "~3.5.22", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Idz9EL4dFUtQbHySZQzckWOTUfqjdYpUtNW0iOysC32mG7IjiUGB77QrsyR5eAWBkRiS9JscF6A3fuQAIy+LrQ=="], @@ -1895,6 +1905,8 @@ "readdirp": ["readdirp@5.0.0", "", {}, "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ=="], + "refractor": ["refractor@5.0.0", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/prismjs": "^1.0.0", "hastscript": "^9.0.0", "parse-entities": "^4.0.0" } }, "sha512-QXOrHQF5jOpjjLfiNk5GFnWhRXvxjUVnlFxkeDmewR5sXkr3iM46Zo+CnRR8B+MDVqkULW4EcLVcRBNOPXHosw=="], + "regex": ["regex@5.1.1", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-dN5I359AVGPnwzJm2jN1k0W9LPZ+ePvoOeVMMfqIMFz53sSwXkxaJoxr50ptnsC771lK95BnTrVSZxq0b9yCGw=="], "regex-recursion": ["regex-recursion@5.1.1", "", { "dependencies": { "regex": "^5.1.1", "regex-utilities": "^2.3.0" } }, "sha512-ae7SBCbzVNrIjgSbh7wMznPcQel1DNlDtzensnFxpiNpXt1U2ju/bHugH422r+4LAVS1FpW1YCwilmnNsjum9w=="], @@ -2031,8 +2043,6 @@ "stat-mode": ["stat-mode@1.0.0", "", {}, "sha512-jH9EhtKIjuXZ2cWxmXS8ZP80XyC3iasQxMDV8jzhNJpfDb7VbQLVW4Wvsxz9QZvzV+G4YoSfBUVKDOyxLzi/sg=="], - "state-local": ["state-local@1.0.7", "", {}, "sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w=="], - "statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="], "streamdown": ["streamdown@2.1.0", "", { "dependencies": { "clsx": "^2.1.1", "hast-util-to-jsx-runtime": "^2.3.6", "html-url-attributes": "^3.0.1", "marked": "^17.0.1", "rehype-harden": "^1.1.7", "rehype-raw": "^7.0.0", "rehype-sanitize": "^6.0.0", "remark-gfm": "^4.0.1", "remark-parse": "^11.0.0", "remark-rehype": "^11.1.2", "remend": "1.1.0", "tailwind-merge": "^3.4.0", "unified": "^11.0.5", "unist-util-visit": "^5.0.0" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0" } }, "sha512-u9gWd0AmjKg1d+74P44XaPlGrMeC21oDOSIhjGNEYMAttDMzCzlJO6lpTyJ9JkSinQQF65YcK4eOd3q9iTvULw=="], @@ -2409,10 +2419,6 @@ "minizlib/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], - "monaco-editor/dompurify": ["dompurify@3.2.7", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw=="], - - "monaco-editor/marked": ["marked@14.0.0", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ=="], - "parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="], "path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], @@ -2421,6 +2427,10 @@ "raw-body/iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="], + "react-syntax-highlighter/highlight.js": ["highlight.js@10.7.3", "", {}, "sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A=="], + + "react-syntax-highlighter/lowlight": ["lowlight@1.20.0", "", { "dependencies": { "fault": "^1.0.0", "highlight.js": "~10.7.0" } }, "sha512-8Ktj+prEb1RoCPkEOrPMYUN/nCggB7qAWe3a7OpMjWQkh3l2RD5wKRQ+o8Q8YuI9RG/xs95waaI/E6ym/7NsTw=="], + "readdir-glob/minimatch": ["minimatch@5.1.6", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g=="], "roarr/sprintf-js": ["sprintf-js@1.1.3", "", {}, "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA=="], diff --git a/bun.lockb b/bun.lockb index 6601c57c..45978d6c 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/drizzle/0007_clammy_grim_reaper.sql b/drizzle/0007_clammy_grim_reaper.sql deleted file mode 100644 index 381e02c4..00000000 --- a/drizzle/0007_clammy_grim_reaper.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE `projects` ADD `icon_path` text; \ No newline at end of file diff --git a/drizzle/meta/0007_snapshot.json b/drizzle/meta/0007_snapshot.json deleted file mode 100644 index a6aa7862..00000000 --- a/drizzle/meta/0007_snapshot.json +++ /dev/null @@ -1,434 +0,0 @@ -{ - "version": "6", - "dialect": "sqlite", - "id": "b2d2d602-5de1-43b1-ada8-c9ed3edde22d", - "prevId": "b1c2d3e4-f5a6-7890-bcde-fa1234567890", - "tables": { - "anthropic_accounts": { - "name": "anthropic_accounts", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "email": { - "name": "email", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "display_name": { - "name": "display_name", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "oauth_token": { - "name": "oauth_token", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "connected_at": { - "name": "connected_at", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "last_used_at": { - "name": "last_used_at", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "desktop_user_id": { - "name": "desktop_user_id", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "anthropic_settings": { - "name": "anthropic_settings", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false, - "default": "'singleton'" - }, - "active_account_id": { - "name": "active_account_id", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "updated_at": { - "name": "updated_at", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "chats": { - "name": "chats", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "project_id": { - "name": "project_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "updated_at": { - "name": "updated_at", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "archived_at": { - "name": "archived_at", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "worktree_path": { - "name": "worktree_path", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "branch": { - "name": "branch", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "base_branch": { - "name": "base_branch", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "pr_url": { - "name": "pr_url", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "pr_number": { - "name": "pr_number", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false - } - }, - "indexes": { - "chats_worktree_path_idx": { - "name": "chats_worktree_path_idx", - "columns": [ - "worktree_path" - ], - "isUnique": false - } - }, - "foreignKeys": { - "chats_project_id_projects_id_fk": { - "name": "chats_project_id_projects_id_fk", - "tableFrom": "chats", - "tableTo": "projects", - "columnsFrom": [ - "project_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "claude_code_credentials": { - "name": "claude_code_credentials", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false, - "default": "'default'" - }, - "oauth_token": { - "name": "oauth_token", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "connected_at": { - "name": "connected_at", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "user_id": { - "name": "user_id", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "projects": { - "name": "projects", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "path": { - "name": "path", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "updated_at": { - "name": "updated_at", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "git_remote_url": { - "name": "git_remote_url", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "git_provider": { - "name": "git_provider", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "git_owner": { - "name": "git_owner", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "git_repo": { - "name": "git_repo", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "icon_path": { - "name": "icon_path", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - } - }, - "indexes": { - "projects_path_unique": { - "name": "projects_path_unique", - "columns": [ - "path" - ], - "isUnique": true - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "sub_chats": { - "name": "sub_chats", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "chat_id": { - "name": "chat_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "session_id": { - "name": "session_id", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "stream_id": { - "name": "stream_id", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "mode": { - "name": "mode", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": "'agent'" - }, - "messages": { - "name": "messages", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": "'[]'" - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "updated_at": { - "name": "updated_at", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false - } - }, - "indexes": {}, - "foreignKeys": { - "sub_chats_chat_id_chats_id_fk": { - "name": "sub_chats_chat_id_chats_id_fk", - "tableFrom": "sub_chats", - "tableTo": "chats", - "columnsFrom": [ - "chat_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - } - }, - "views": {}, - "enums": {}, - "_meta": { - "schemas": {}, - "tables": {}, - "columns": {} - }, - "internal": { - "indexes": {} - } -} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 88a3e0a6..5ec7efd1 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -50,13 +50,6 @@ "when": 1769480000000, "tag": "0006_anthropic_multi_account", "breakpoints": true - }, - { - "idx": 7, - "version": "6", - "when": 1769810815497, - "tag": "0007_clammy_grim_reaper", - "breakpoints": true } ] } \ No newline at end of file diff --git a/package.json b/package.json index 48e649c6..e4de7f5d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "21st-desktop", - "version": "0.0.54", + "version": "0.0.50", "private": true, "description": "1Code - UI for parallel work with AI agents", "author": { @@ -19,8 +19,8 @@ "dist": "electron-builder", "dist:manifest": "node scripts/generate-update-manifest.mjs", "dist:upload": "node scripts/upload-release.mjs", - "claude:download": "node scripts/download-claude-binary.mjs --version=2.1.25", - "claude:download:all": "node scripts/download-claude-binary.mjs --version=2.1.25 --all", + "claude:download": "node scripts/download-claude-binary.mjs --version=2.1.22", + "claude:download:all": "node scripts/download-claude-binary.mjs --version=2.1.22 --all", "release": "rm -rf release && bun i && bun run claude:download && bun run build && bun run package:mac && bun run dist:manifest && ./scripts/upload-release-wrangler.sh", "release:dev": "rm -rf release && bun run claude:download && bun run build && bun run package:mac && rm -rf node_modules && bun i", "sync:public": "./scripts/sync-to-public.sh", @@ -33,12 +33,10 @@ }, "dependencies": { "@ai-sdk/react": "^3.0.14", - "@anthropic-ai/claude-agent-sdk": "0.2.25", + "@anthropic-ai/claude-agent-sdk": "0.2.22", "@git-diff-view/react": "^0.0.35", "@git-diff-view/shiki": "^0.0.36", "@modelcontextprotocol/sdk": "^1.25.3", - "@monaco-editor/react": "^4.7.0", - "@pierre/diffs": "^1.0.10", "@radix-ui/react-accordion": "^1.2.12", "@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-checkbox": "^1.3.3", @@ -71,12 +69,11 @@ "@xterm/addon-webgl": "^0.19.0", "ai": "^6.0.14", "async-mutex": "^0.5.0", - "better-sqlite3": "^12.6.2", + "better-sqlite3": "^11.8.1", "chokidar": "^5.0.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "date-fns": "^3.6.0", - "diff": "^8.0.3", "drizzle-orm": "^0.45.1", "electron-log": "^5.4.3", "electron-updater": "^6.7.3", @@ -85,7 +82,6 @@ "jsonc-parser": "^3.3.1", "lucide-react": "^0.468.0", "mermaid": "^11.12.2", - "monaco-editor": "^0.55.1", "motion": "^11.15.0", "next-themes": "^0.4.4", "node-pty": "^1.1.0", @@ -96,6 +92,7 @@ "react-dom": "19.2.1", "react-hotkeys-hook": "^4.6.1", "react-icons": "^5.5.0", + "react-syntax-highlighter": "^16.1.0", "react-zoom-pan-pinch": "^3.7.0", "remark-breaks": "^4.0.0", "remark-gfm": "^4.0.1", @@ -116,17 +113,17 @@ "@electron-toolkit/preload": "^3.0.1", "@electron-toolkit/utils": "^4.0.0", "@types/better-sqlite3": "^7.6.13", - "@types/diff": "^8.0.0", "@types/node": "^20.17.50", "@types/react": "^19.0.7", "@types/react-dom": "^19.0.3", + "@types/react-syntax-highlighter": "^15.5.13", "@vitejs/plugin-react": "^4.3.4", "@welldone-software/why-did-you-render": "^10.0.1", "autoprefixer": "^10.4.20", "drizzle-kit": "^0.31.8", - "electron": "~39.4.0", + "electron": "33.4.5", "electron-builder": "^25.1.8", - "@electron/rebuild": "^4.0.3", + "electron-rebuild": "^3.2.9", "electron-vite": "^3.0.0", "postcss": "^8.5.1", "tailwindcss": "^3.4.17", @@ -247,10 +244,5 @@ "provider": "generic", "url": "https://cdn.21st.dev/releases/desktop" } - }, - "pnpm": { - "overrides": { - "source-map-support>source-map": "0.7.4" - } } } diff --git a/scripts/generate-update-manifest.mjs b/scripts/generate-update-manifest.mjs index 386d9373..764f7e77 100644 --- a/scripts/generate-update-manifest.mjs +++ b/scripts/generate-update-manifest.mjs @@ -24,17 +24,6 @@ import { fileURLToPath } from "url" const __filename = fileURLToPath(import.meta.url) const __dirname = dirname(__filename) -// Parse --channel argument (default: "latest") -const channelArgIndex = process.argv.indexOf("--channel") -const channel = channelArgIndex !== -1 && process.argv[channelArgIndex + 1] - ? process.argv[channelArgIndex + 1] - : "latest" - -if (channel !== "latest" && channel !== "beta") { - console.error(`Invalid channel: "${channel}". Must be "latest" or "beta".`) - process.exit(1) -} - // Get version from package.json const packageJson = JSON.parse( readFileSync(join(__dirname, "../package.json"), "utf-8") @@ -108,11 +97,10 @@ function generateManifest(arch) { } // Manifest file names expected by electron-updater: - // For stable (latest): latest-mac.yml / latest-mac-x64.yml - // For beta: beta-mac.yml / beta-mac-x64.yml - const prefix = channel === "beta" ? "beta" : "latest" + // arm64: latest-mac.yml (primary) + // x64: latest-mac-x64.yml const manifestFileName = - arch === "arm64" ? `${prefix}-mac.yml` : `${prefix}-mac-x64.yml` + arch === "arm64" ? "latest-mac.yml" : "latest-mac-x64.yml" const manifestPath = join(releaseDir, manifestFileName) // Convert to YAML format (simple implementation) @@ -179,7 +167,6 @@ console.log("=".repeat(50)) console.log("Generating electron-updater manifests") console.log("=".repeat(50)) console.log(`Version: ${version}`) -console.log(`Channel: ${channel}`) console.log(`Release dir: ${releaseDir}`) console.log() @@ -195,16 +182,15 @@ if (!arm64Manifest && !x64Manifest) { console.log("=".repeat(50)) console.log("Manifest generation complete!") console.log() -const prefix = channel === "beta" ? "beta" : "latest" console.log("Next steps:") console.log("1. Upload the following files to cdn.21st.dev/releases/desktop/:") if (arm64Manifest) { - console.log(` - ${prefix}-mac.yml`) + console.log(` - latest-mac.yml`) console.log(` - Agents-${version}-arm64-mac.zip`) console.log(` - Agents-${version}-arm64.dmg (for manual download)`) } if (x64Manifest) { - console.log(` - ${prefix}-mac-x64.yml`) + console.log(` - latest-mac-x64.yml`) console.log(` - Agents-${version}-mac.zip`) console.log(` - Agents-${version}.dmg (for manual download)`) } diff --git a/scripts/sync-to-public.sh b/scripts/sync-to-public.sh index 9acfa639..4c58f108 100755 --- a/scripts/sync-to-public.sh +++ b/scripts/sync-to-public.sh @@ -78,9 +78,6 @@ test-electron.js # Exclude internal release docs (contains credentials, CDN URLs) RELEASE.md scripts/upload-release-wrangler.sh - -# Exclude wrangler local state (large R2 blobs) -.wrangler EOF # Commit and push diff --git a/src/main/index.ts b/src/main/index.ts index 32bf51eb..57b3be16 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -26,6 +26,7 @@ import { uninstallCli, parseLaunchDirectory, } from "./lib/cli" +import { startClaudeConfigWatcher, stopClaudeConfigWatcher } from "./lib/claude-config-watcher" import { cleanupGitWatchers } from "./lib/git/watcher" import { cancelAllPendingOAuth, handleMcpOAuthCallback } from "./lib/mcp-auth" import { @@ -877,6 +878,11 @@ if (gotTheLock) { } }, 3000) + // Watch ~/.claude.json for changes to auto-refresh MCP servers + startClaudeConfigWatcher().catch((error) => { + console.error("[App] Failed to start config watcher:", error) + }) + // Handle directory argument from CLI (e.g., `1code /path/to/project`) parseLaunchDirectory() @@ -907,6 +913,7 @@ if (gotTheLock) { app.on("before-quit", async () => { console.log("[App] Shutting down...") cancelAllPendingOAuth() + await stopClaudeConfigWatcher() await cleanupGitWatchers() await shutdownAnalytics() await closeDatabase() diff --git a/src/main/lib/auto-updater.ts b/src/main/lib/auto-updater.ts index 24fe39f0..2525969e 100644 --- a/src/main/lib/auto-updater.ts +++ b/src/main/lib/auto-updater.ts @@ -1,8 +1,6 @@ import { BrowserWindow, ipcMain, app } from "electron" import log from "electron-log" import { autoUpdater, type UpdateInfo, type ProgressInfo } from "electron-updater" -import { readFileSync, writeFileSync, existsSync } from "fs" -import { join } from "path" /** * IMPORTANT: Do NOT use lazy/dynamic imports for electron-updater! @@ -32,38 +30,6 @@ const CDN_BASE = "https://cdn.21st.dev/releases/desktop" const MIN_CHECK_INTERVAL = 60 * 1000 // 1 minute let lastCheckTime = 0 -// Update channel preference file -const CHANNEL_PREF_FILE = "update-channel.json" - -type UpdateChannel = "latest" | "beta" - -function getChannelPrefPath(): string { - return join(app.getPath("userData"), CHANNEL_PREF_FILE) -} - -function getSavedChannel(): UpdateChannel { - try { - const prefPath = getChannelPrefPath() - if (existsSync(prefPath)) { - const data = JSON.parse(readFileSync(prefPath, "utf-8")) - if (data.channel === "beta" || data.channel === "latest") { - return data.channel - } - } - } catch { - // Ignore read errors, fall back to default - } - return "latest" -} - -function saveChannel(channel: UpdateChannel): void { - try { - writeFileSync(getChannelPrefPath(), JSON.stringify({ channel }), "utf-8") - } catch (error) { - log.error("[AutoUpdater] Failed to save channel preference:", error) - } -} - let getAllWindows: (() => BrowserWindow[]) | null = null /** @@ -92,11 +58,6 @@ export async function initAutoUpdater(getWindows: () => BrowserWindow[]) { // Initialize config initAutoUpdaterConfig() - // Set update channel from saved preference - const savedChannel = getSavedChannel() - autoUpdater.channel = savedChannel - log.info(`[AutoUpdater] Using update channel: ${savedChannel}`) - // Configure feed URL to point to R2 CDN // Note: We use a custom request headers to bypass CDN cache autoUpdater.setFeedURL({ @@ -241,31 +202,6 @@ function registerIpcHandlers() { currentVersion: app.getVersion(), } }) - - // Set update channel (latest = stable only, beta = stable + beta) - ipcMain.handle("update:set-channel", async (_event, channel: string) => { - if (channel !== "latest" && channel !== "beta") { - log.warn(`[AutoUpdater] Invalid channel: ${channel}`) - return false - } - log.info(`[AutoUpdater] Switching update channel to: ${channel}`) - autoUpdater.channel = channel - saveChannel(channel) - // Check for updates immediately with new channel - if (app.isPackaged) { - try { - await autoUpdater.checkForUpdates() - } catch (error) { - log.error("[AutoUpdater] Post-channel-switch check failed:", error) - } - } - return true - }) - - // Get current update channel - ipcMain.handle("update:get-channel", () => { - return getSavedChannel() - }) } /** diff --git a/src/main/lib/claude-config-watcher.ts b/src/main/lib/claude-config-watcher.ts new file mode 100644 index 00000000..c3c47e36 --- /dev/null +++ b/src/main/lib/claude-config-watcher.ts @@ -0,0 +1,91 @@ +/** + * Watches ~/.claude.json for changes and notifies renderer to re-initialize MCP servers. + * + * When a user edits their Claude config (e.g., adding/removing MCP servers), + * this watcher detects the change, clears cached MCP data, and notifies + * the renderer so it can refresh MCP server status without requiring a restart. + */ +import { BrowserWindow } from "electron" +import * as os from "os" +import * as path from "path" +import { mcpConfigCache, workingMcpServers } from "./trpc/routers/claude" + +const CLAUDE_CONFIG_PATH = path.join(os.homedir(), ".claude.json") + +// Simple debounce to batch rapid file changes +function debounce unknown>( + func: T, + wait: number, +): (...args: Parameters) => void { + let timeoutId: NodeJS.Timeout | null = null + return (...args: Parameters) => { + if (timeoutId) clearTimeout(timeoutId) + timeoutId = setTimeout(() => func(...args), wait) + } +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +let watcher: any = null + +/** + * Start watching ~/.claude.json for changes. + * When changes are detected: + * 1. Clears the in-memory MCP config cache and working servers cache + * 2. Sends an IPC event to all renderer windows so they can refetch MCP config + */ +export async function startClaudeConfigWatcher(): Promise { + if (watcher) return + + const chokidar = await import("chokidar") + + watcher = chokidar.watch(CLAUDE_CONFIG_PATH, { + persistent: true, + ignoreInitial: true, + awaitWriteFinish: { + stabilityThreshold: 100, + pollInterval: 50, + }, + usePolling: false, + followSymlinks: false, + }) + + const handleChange = debounce(() => { + console.log("[ConfigWatcher] ~/.claude.json changed, clearing MCP caches") + + // Clear MCP-related caches so next session/query reads fresh config + mcpConfigCache.clear() + workingMcpServers.clear() + + // Notify all renderer windows + for (const win of BrowserWindow.getAllWindows()) { + if (!win.isDestroyed()) { + try { + win.webContents.send("claude-config-changed") + } catch { + // Window may have been destroyed between check and send + } + } + } + }, 300) + + watcher + .on("change", () => handleChange()) + .on("add", () => handleChange()) + .on("error", (error: Error) => { + console.error("[ConfigWatcher] Error watching ~/.claude.json:", error) + }) + + console.log("[ConfigWatcher] Watching ~/.claude.json for changes") +} + +/** + * Stop watching ~/.claude.json. + * Call this when the app is shutting down. + */ +export async function stopClaudeConfigWatcher(): Promise { + if (watcher) { + await (watcher as any).close() + watcher = null + console.log("[ConfigWatcher] Stopped watching ~/.claude.json") + } +} diff --git a/src/main/lib/claude-config.ts b/src/main/lib/claude-config.ts index 1f907522..c0d7db46 100644 --- a/src/main/lib/claude-config.ts +++ b/src/main/lib/claude-config.ts @@ -179,38 +179,6 @@ export function updateMcpServerConfig( return config } -/** - * Remove an MCP server from config - * Use projectPath = GLOBAL_MCP_PATH (or null) for global MCP servers - * Automatically resolves worktree paths to original project paths - */ -export function removeMcpServerConfig( - config: ClaudeConfig, - projectPath: string | null, - serverName: string -): ClaudeConfig { - // Global MCP servers - if (!projectPath || projectPath === GLOBAL_MCP_PATH) { - if (config.mcpServers?.[serverName]) { - delete config.mcpServers[serverName] - } - return config - } - // Project-specific MCP servers - const resolvedPath = resolveProjectPathFromWorktree(projectPath) || projectPath - if (config.projects?.[resolvedPath]?.mcpServers?.[serverName]) { - delete config.projects[resolvedPath].mcpServers[serverName] - // Clean up empty objects - if (Object.keys(config.projects[resolvedPath].mcpServers).length === 0) { - delete config.projects[resolvedPath].mcpServers - } - if (Object.keys(config.projects[resolvedPath]).length === 0) { - delete config.projects[resolvedPath] - } - } - return config -} - /** * Resolve original project path from a worktree path. * Supports legacy (~/.21st/worktrees/{projectId}/{chatId}/) and diff --git a/src/main/lib/claude/transform.ts b/src/main/lib/claude/transform.ts index 93e0a353..d275ab26 100644 --- a/src/main/lib/claude/transform.ts +++ b/src/main/lib/claude/transform.ts @@ -60,25 +60,13 @@ export function createTransformer(options?: { emitSdkMessageUuid?: boolean; isUs if (currentToolCallId) { // Track this tool ID to avoid duplicates from assistant message emittedToolIds.add(currentToolCallId) - - let parsedInput = {} - if (accumulatedToolInput) { - try { - parsedInput = JSON.parse(accumulatedToolInput) - } catch (e) { - // Stream may have been interrupted mid-JSON (e.g. network error, abort) - // resulting in incomplete JSON like '{"prompt":"write co' - console.error("[transform] Failed to parse tool input JSON:", (e as Error).message, "partial:", accumulatedToolInput.slice(0, 120)) - parsedInput = { _raw: accumulatedToolInput, _parseError: true } - } - } - + // Emit complete tool call with accumulated input yield { type: "tool-input-available", toolCallId: currentToolCallId, toolName: currentToolName || "unknown", - input: parsedInput, + input: accumulatedToolInput ? JSON.parse(accumulatedToolInput) : {}, } currentToolCallId = null currentToolName = null @@ -356,7 +344,7 @@ export function createTransformer(options?: { emitSdkMessageUuid?: boolean; isUs } // ===== USER MESSAGE (tool results) ===== - if (msg.type === "user" && msg.message?.content && Array.isArray(msg.message.content)) { + if (msg.type === "user" && msg.message?.content) { // DEBUG: Log the message structure to understand tool_use_result console.log("[Transform DEBUG] User message:", { tool_use_result: msg.tool_use_result, @@ -426,7 +414,7 @@ export function createTransformer(options?: { emitSdkMessageUuid?: boolean; isUs }) // Map MCP servers with validated status type and additional info const mcpServers: MCPServer[] = (msg.mcp_servers || []).map( - (s: { name: string; status: string; serverInfo?: { name: string; version: string; icons?: { src: string; mimeType?: string; sizes?: string[]; theme?: "light" | "dark" }[] }; error?: string }) => ({ + (s: { name: string; status: string; serverInfo?: { name: string; version: string }; error?: string }) => ({ name: s.name, status: (["connected", "failed", "pending", "needs-auth"].includes( s.status, @@ -470,31 +458,14 @@ export function createTransformer(options?: { emitSdkMessageUuid?: boolean; isUs // ===== RESULT (final) ===== if (msg.type === "result") { + console.log("[transform] RESULT message, textStarted:", textStarted, "lastTextId:", lastTextId) yield* endTextBlock() yield* endToolInput() const inputTokens = msg.usage?.input_tokens const outputTokens = msg.usage?.output_tokens - - // Extract per-model usage from SDK (if available) - const modelUsage = msg.modelUsage - ? Object.fromEntries( - Object.entries(msg.modelUsage).map(([model, usage]: [string, any]) => [ - model, - { - inputTokens: usage.inputTokens || 0, - outputTokens: usage.outputTokens || 0, - cacheReadInputTokens: usage.cacheReadInputTokens || 0, - cacheCreationInputTokens: usage.cacheCreationInputTokens || 0, - costUSD: usage.costUSD || 0, - }, - ]) - ) - : undefined - const metadata: MessageMetadata = { sessionId: msg.session_id, - sdkMessageUuid: emitSdkMessageUuid ? msg.uuid : undefined, inputTokens, outputTokens, totalTokens: inputTokens && outputTokens ? inputTokens + outputTokens : undefined, @@ -503,11 +474,10 @@ export function createTransformer(options?: { emitSdkMessageUuid?: boolean; isUs resultSubtype: msg.subtype || "success", // Include finalTextId for collapsing tools when there's a final response finalTextId: lastTextId || undefined, - // Per-model usage breakdown - modelUsage, } yield { type: "message-metadata", messageMetadata: metadata } yield { type: "finish-step" } + console.log("[transform] YIELDING FINISH from result message") yield { type: "finish", messageMetadata: metadata } } } diff --git a/src/main/lib/claude/types.ts b/src/main/lib/claude/types.ts index 89ab312c..9ad956b7 100644 --- a/src/main/lib/claude/types.ts +++ b/src/main/lib/claude/types.ts @@ -55,32 +55,16 @@ export type UIMessageChunk = export type MCPServerStatus = "connected" | "failed" | "pending" | "needs-auth" -export type MCPServerIcon = { - src: string - mimeType?: string - sizes?: string[] - theme?: "light" | "dark" -} - export type MCPServer = { name: string status: MCPServerStatus serverInfo?: { name: string version: string - icons?: MCPServerIcon[] } error?: string } -export type ModelUsageEntry = { - inputTokens: number - outputTokens: number - cacheReadInputTokens: number - cacheCreationInputTokens: number - costUSD: number -} - export type MessageMetadata = { sessionId?: string sdkMessageUuid?: string // SDK's message UUID for resumeSessionAt (rollback support) @@ -91,6 +75,4 @@ export type MessageMetadata = { durationMs?: number resultSubtype?: string finalTextId?: string - // Per-model usage breakdown from SDK (model name -> usage) - modelUsage?: Record } diff --git a/src/main/lib/db/schema/index.ts b/src/main/lib/db/schema/index.ts index fe6aa349..a6cf58f4 100644 --- a/src/main/lib/db/schema/index.ts +++ b/src/main/lib/db/schema/index.ts @@ -20,8 +20,6 @@ export const projects = sqliteTable("projects", { gitProvider: text("git_provider"), // "github" | "gitlab" | "bitbucket" | null gitOwner: text("git_owner"), gitRepo: text("git_repo"), - // Custom project icon (absolute path to local image file) - iconPath: text("icon_path"), }) export const projectsRelations = relations(projects, ({ many }) => ({ diff --git a/src/main/lib/git/diff-parser.ts b/src/main/lib/git/diff-parser.ts index 98cb7c2e..8c3b270e 100644 --- a/src/main/lib/git/diff-parser.ts +++ b/src/main/lib/git/diff-parser.ts @@ -175,16 +175,6 @@ export function splitUnifiedDiffByFile(diffText: string): ParsedDiffFile[] { let deletions = 0 for (const line of blockLines) { - if (line.startsWith("diff --git ")) { - // Fallback: parse paths from "diff --git a/path b/path" - // Needed for binary files that don't have ---/+++ lines - const match = line.match(/^diff --git a\/(.+) b\/(.+)$/) - if (match) { - if (!oldPath) oldPath = match[1]! - if (!newPath) newPath = match[2]! - } - } - if (line.startsWith("Binary files ") && line.endsWith(" differ")) { isBinary = true } diff --git a/src/main/lib/git/stash.ts b/src/main/lib/git/stash.ts index e0af6bb2..2734ad61 100644 --- a/src/main/lib/git/stash.ts +++ b/src/main/lib/git/stash.ts @@ -105,15 +105,10 @@ function parseCheckpointTrees( } } -export type RollbackResult = - | { success: true; checkpointFound: true } - | { success: true; checkpointFound: false } - | { success: false; error: string } - export async function applyRollbackStash( worktreePath: string, sdkMessageUuid: string, -): Promise { +) { try { const git = simpleGit(worktreePath) @@ -125,9 +120,7 @@ export async function applyRollbackStash( console.warn( `[claude] Rollback checkpoint not found for sdkMessageUuid=${sdkMessageUuid}`, ) - // Checkpoint not found - return success but indicate no checkpoint was applied - // The caller can decide whether to proceed with message truncation - return { success: true, checkpointFound: false } + return true // This is fine, just skip } const commitMessage = await git.raw([ @@ -141,7 +134,7 @@ export async function applyRollbackStash( console.error( `[claude] Rollback checkpoint missing tree metadata for sdkMessageUuid=${sdkMessageUuid}`, ) - return { success: false, error: "Checkpoint missing tree metadata" } + return false } let lastError: unknown @@ -151,7 +144,7 @@ export async function applyRollbackStash( await git.raw(["checkout-index", "-a", "-f"]) await git.raw(["clean", "-fd"]) await git.raw(["read-tree", indexTree]) - return { success: true, checkpointFound: true } + return true } catch (error) { lastError = error if (attempt < APPLY_RETRIES) { @@ -162,7 +155,6 @@ export async function applyRollbackStash( throw lastError } catch (e) { console.error("[claude] Failed to apply rollback checkpoint:", e) - const errorMessage = e instanceof Error ? e.message : "Unknown error" - return { success: false, error: errorMessage } + return false } } diff --git a/src/main/lib/mcp-auth.ts b/src/main/lib/mcp-auth.ts index f2464686..5edf4333 100644 --- a/src/main/lib/mcp-auth.ts +++ b/src/main/lib/mcp-auth.ts @@ -11,7 +11,6 @@ import { } from './claude-config'; import { getClaudeShellEnvironment } from './claude/env'; import { CraftOAuth, fetchOAuthMetadata, getMcpBaseUrl, type OAuthMetadata, type OAuthTokens } from './oauth'; -import { discoverPluginMcpServers } from './plugins'; import { bringToFront } from './window'; @@ -20,15 +19,10 @@ import { bringToFront } from './window'; * @param serverUrl The MCP server URL * @param accessToken Optional access token (not needed for public MCPs) */ -export interface McpToolInfo { - name: string; - description?: string; -} - export async function fetchMcpTools( serverUrl: string, headers?: Record -): Promise { +): Promise { let client: Client | null = null; let transport: StreamableHTTPClientTransport | null = null; @@ -53,7 +47,7 @@ export async function fetchMcpTools( const tools = result.tools || []; console.log(`[MCP] Fetched ${tools.length} tools via SDK`); - return tools.map(t => ({ name: t.name, description: t.description })); + return tools.map(t => t.name); } catch (error) { console.error('[MCP] Failed to fetch tools:', error); return []; @@ -91,7 +85,7 @@ export async function fetchMcpToolsStdio(config: { command: string; args?: string[]; env?: Record; -}): Promise { +}): Promise { let transport: StdioClientTransport | null = null; try { @@ -124,7 +118,7 @@ export async function fetchMcpToolsStdio(config: { const tools = result.tools || []; console.log(`[MCP] Fetched ${tools.length} tools via stdio`); - return tools.map(t => ({ name: t.name, description: t.description })); + return tools.map(t => t.name); } catch (error) { console.error('[MCP] Failed to fetch tools via stdio:', error); return []; @@ -173,26 +167,7 @@ export async function startMcpOAuth( ): Promise<{ success: boolean; error?: string }> { // 1. Read server config from ~/.claude.json const config = await readClaudeConfig(); - let serverConfig = getMcpServerConfig(config, projectPath, serverName); - - // Fallback: check plugin MCP servers if not found in ~/.claude.json - if (!serverConfig?.url) { - const pluginMcpConfigs = await discoverPluginMcpServers(); - for (const pluginConfig of pluginMcpConfigs) { - if (pluginConfig.mcpServers[serverName]) { - serverConfig = pluginConfig.mcpServers[serverName]; - // Save plugin server config to ~/.claude.json so token storage works - await updateClaudeConfigAtomic((cfg) => { - return updateMcpServerConfig(cfg, GLOBAL_MCP_PATH, serverName, { - url: serverConfig!.url, - type: serverConfig!.url?.endsWith('/sse') ? 'sse' : 'http', - authType: 'oauth', - }); - }); - break; - } - } - } + const serverConfig = getMcpServerConfig(config, projectPath, serverName); if (!serverConfig?.url) { return { success: false, error: `MCP server "${serverName}" URL not configured` }; diff --git a/src/main/lib/plugins/index.ts b/src/main/lib/plugins/index.ts deleted file mode 100644 index a54996c4..00000000 --- a/src/main/lib/plugins/index.ts +++ /dev/null @@ -1,203 +0,0 @@ -import * as fs from "fs/promises" -import type { Dirent } from "fs" -import * as path from "path" -import * as os from "os" -import type { McpServerConfig } from "../claude-config" - -export interface PluginInfo { - name: string - version: string - description?: string - path: string - source: string // e.g., "marketplace:plugin-name" - marketplace: string // e.g., "claude-plugins-official" - category?: string - homepage?: string - tags?: string[] -} - -interface MarketplacePlugin { - name: string - version?: string - description?: string - source: string | { source: string; url: string } - category?: string - homepage?: string - tags?: string[] -} - -interface MarketplaceJson { - name: string - plugins: MarketplacePlugin[] -} - -export interface PluginMcpConfig { - pluginSource: string // e.g., "ccsetup:ccsetup" - mcpServers: Record -} - -// Cache for plugin discovery results -let pluginCache: { plugins: PluginInfo[]; timestamp: number } | null = null -let mcpCache: { configs: PluginMcpConfig[]; timestamp: number } | null = null -const CACHE_TTL_MS = 30000 // 30 seconds - plugins don't change often during a session - -/** - * Clear plugin caches (for testing/manual invalidation) - */ -export function clearPluginCache() { - pluginCache = null - mcpCache = null -} - -/** - * Discover all installed plugins from ~/.claude/plugins/marketplaces/ - * Returns array of plugin info with paths to their component directories - * Results are cached for 30 seconds to avoid repeated filesystem scans - */ -export async function discoverInstalledPlugins(): Promise { - // Return cached result if still valid - if (pluginCache && Date.now() - pluginCache.timestamp < CACHE_TTL_MS) { - return pluginCache.plugins - } - - const plugins: PluginInfo[] = [] - const marketplacesDir = path.join(os.homedir(), ".claude", "plugins", "marketplaces") - - try { - await fs.access(marketplacesDir) - } catch { - pluginCache = { plugins, timestamp: Date.now() } - return plugins - } - - let marketplaces: Dirent[] - try { - marketplaces = await fs.readdir(marketplacesDir, { withFileTypes: true }) - } catch { - pluginCache = { plugins, timestamp: Date.now() } - return plugins - } - - for (const marketplace of marketplaces) { - if (!marketplace.isDirectory() || marketplace.name.startsWith(".")) continue - - const marketplacePath = path.join(marketplacesDir, marketplace.name) - const marketplaceJsonPath = path.join(marketplacePath, ".claude-plugin", "marketplace.json") - - try { - const content = await fs.readFile(marketplaceJsonPath, "utf-8") - - let marketplaceJson: MarketplaceJson - try { - marketplaceJson = JSON.parse(content) - } catch { - continue - } - - if (!Array.isArray(marketplaceJson.plugins)) { - continue - } - - for (const plugin of marketplaceJson.plugins) { - // Validate plugin.source exists - if (!plugin.source) continue - - // source can be a string path or an object { source: "url", url: "..." } - const sourcePath = typeof plugin.source === "string" ? plugin.source : null - if (!sourcePath) continue - - const pluginPath = path.resolve(marketplacePath, sourcePath) - try { - await fs.access(pluginPath) - plugins.push({ - name: plugin.name, - version: plugin.version || "0.0.0", - description: plugin.description, - path: pluginPath, - source: `${marketplaceJson.name}:${plugin.name}`, - marketplace: marketplaceJson.name, - category: plugin.category, - homepage: plugin.homepage, - tags: plugin.tags, - }) - } catch { - // Plugin directory not found, skip - } - } - } catch { - // No marketplace.json, skip silently (expected for non-plugin directories) - } - } - - pluginCache = { plugins, timestamp: Date.now() } - return plugins -} - -/** - * Get component paths for a plugin (commands, skills, agents directories) - */ -export function getPluginComponentPaths(plugin: PluginInfo) { - return { - commands: path.join(plugin.path, "commands"), - skills: path.join(plugin.path, "skills"), - agents: path.join(plugin.path, "agents"), - } -} - -/** - * Discover MCP server configs from all installed plugins - * Reads .mcp.json from each plugin directory - * Results are cached for 30 seconds to avoid repeated filesystem scans - */ -export async function discoverPluginMcpServers(): Promise { - // Return cached result if still valid - if (mcpCache && Date.now() - mcpCache.timestamp < CACHE_TTL_MS) { - return mcpCache.configs - } - - const plugins = await discoverInstalledPlugins() - const configs: PluginMcpConfig[] = [] - - for (const plugin of plugins) { - const mcpJsonPath = path.join(plugin.path, ".mcp.json") - try { - const content = await fs.readFile(mcpJsonPath, "utf-8") - let parsed: Record - try { - parsed = JSON.parse(content) - } catch { - continue - } - - // Support two formats: - // Format A (flat): { "server-name": { "command": "...", ... } } - // Format B (nested): { "mcpServers": { "server-name": { ... } } } - const serversObj = - parsed.mcpServers && - typeof parsed.mcpServers === "object" && - !Array.isArray(parsed.mcpServers) - ? (parsed.mcpServers as Record) - : parsed - - const validServers: Record = {} - for (const [name, config] of Object.entries(serversObj)) { - if (config && typeof config === "object" && !Array.isArray(config)) { - validServers[name] = config as McpServerConfig - } - } - - if (Object.keys(validServers).length > 0) { - configs.push({ - pluginSource: plugin.source, - mcpServers: validServers, - }) - } - } catch { - // No .mcp.json file, skip silently (this is expected for most plugins) - } - } - - // Cache the result - mcpCache = { configs, timestamp: Date.now() } - return configs -} diff --git a/src/main/lib/trpc/routers/agent-utils.ts b/src/main/lib/trpc/routers/agent-utils.ts index 776a7d51..b8090163 100644 --- a/src/main/lib/trpc/routers/agent-utils.ts +++ b/src/main/lib/trpc/routers/agent-utils.ts @@ -2,8 +2,6 @@ import * as fs from "fs/promises" import * as path from "path" import * as os from "os" import matter from "gray-matter" -import { discoverInstalledPlugins, getPluginComponentPaths } from "../../plugins" -import { getEnabledPlugins } from "./claude-settings" // Valid model values for agents export const VALID_AGENT_MODELS = ["sonnet", "opus", "haiku", "inherit"] as const @@ -21,8 +19,7 @@ export interface ParsedAgent { // Agent with source/path metadata export interface FileAgent extends ParsedAgent { - source: "user" | "project" | "plugin" - pluginName?: string + source: "user" | "project" path: string } @@ -149,36 +146,7 @@ export async function loadAgent( } } - // Search in plugin directories - const [enabledPluginSources, installedPlugins] = await Promise.all([ - getEnabledPlugins(), - discoverInstalledPlugins(), - ]) - const enabledPlugins = installedPlugins.filter( - (p) => enabledPluginSources.includes(p.source), - ) - const pluginResults = await Promise.all( - enabledPlugins.map(async (plugin) => { - const paths = getPluginComponentPaths(plugin) - const agentPath = path.join(paths.agents, `${name}.md`) - try { - const content = await fs.readFile(agentPath, "utf-8") - const parsed = parseAgentMd(content, `${name}.md`) - if (parsed.description && parsed.prompt) { - return { - name: parsed.name || name, - description: parsed.description, - prompt: parsed.prompt, - tools: parsed.tools, - disallowedTools: parsed.disallowedTools, - model: parsed.model, - } - } - } catch {} - return null - }), - ) - return pluginResults.find((r) => r !== null) ?? null + return null } /** @@ -187,7 +155,7 @@ export async function loadAgent( */ export async function scanAgentsDirectory( dir: string, - source: "user" | "project" | "plugin", + source: "user" | "project", basePath?: string // For project agents, the cwd to make paths relative to ): Promise { const agents: FileAgent[] = [] diff --git a/src/main/lib/trpc/routers/agents.ts b/src/main/lib/trpc/routers/agents.ts index a2b19ced..2416f5b0 100644 --- a/src/main/lib/trpc/routers/agents.ts +++ b/src/main/lib/trpc/routers/agents.ts @@ -10,8 +10,6 @@ import { VALID_AGENT_MODELS, type FileAgent, } from "./agent-utils" -import { discoverInstalledPlugins, getPluginComponentPaths } from "../../plugins" -import { getEnabledPlugins } from "./claude-settings" // Shared procedure for listing agents const listAgentsProcedure = publicProcedure @@ -32,34 +30,12 @@ const listAgentsProcedure = publicProcedure projectAgentsPromise = scanAgentsDirectory(projectAgentsDir, "project", input.cwd) } - // Discover plugin agents - const [enabledPluginSources, installedPlugins] = await Promise.all([ - getEnabledPlugins(), - discoverInstalledPlugins(), + const [userAgents, projectAgents] = await Promise.all([ + userAgentsPromise, + projectAgentsPromise, ]) - const enabledPlugins = installedPlugins.filter( - (p) => enabledPluginSources.includes(p.source), - ) - const pluginAgentsPromises = enabledPlugins.map(async (plugin) => { - const paths = getPluginComponentPaths(plugin) - try { - const agents = await scanAgentsDirectory(paths.agents, "plugin") - return agents.map((agent) => ({ ...agent, pluginName: plugin.source })) - } catch { - return [] - } - }) - - // Scan all directories in parallel - const [userAgents, projectAgents, ...pluginAgentsArrays] = - await Promise.all([ - userAgentsPromise, - projectAgentsPromise, - ...pluginAgentsPromises, - ]) - const pluginAgents = pluginAgentsArrays.flat() - return [...projectAgents, ...userAgents, ...pluginAgents] + return [...projectAgents, ...userAgents] }) export const agentsRouter = router({ @@ -110,31 +86,6 @@ export const agentsRouter = router({ continue } } - - // Search in plugin directories - const [enabledPluginSources, installedPlugins] = await Promise.all([ - getEnabledPlugins(), - discoverInstalledPlugins(), - ]) - const enabledPlugins = installedPlugins.filter( - (p) => enabledPluginSources.includes(p.source), - ) - for (const plugin of enabledPlugins) { - const paths = getPluginComponentPaths(plugin) - const agentPath = path.join(paths.agents, `${input.name}.md`) - try { - const content = await fs.readFile(agentPath, "utf-8") - const parsed = parseAgentMd(content, `${input.name}.md`) - return { - ...parsed, - source: "plugin" as const, - pluginName: plugin.source, - path: agentPath, - } - } catch { - continue - } - } return null }), diff --git a/src/main/lib/trpc/routers/chats.ts b/src/main/lib/trpc/routers/chats.ts index c21c793e..99af10b4 100644 --- a/src/main/lib/trpc/routers/chats.ts +++ b/src/main/lib/trpc/routers/chats.ts @@ -716,13 +716,8 @@ export const chatsRouter = router({ // 4. Rollback git state first - if this fails, abort the whole operation if (chat?.worktreePath) { const res = await applyRollbackStash(chat.worktreePath, input.sdkMessageUuid) - if (!res.success) { - return { success: false, error: `Git rollback failed: ${res.error}` } - } - // If checkpoint wasn't found, we still fail because we can't safely rollback - // without reverting the git state to match the message history - if (!res.checkpointFound) { - return { success: false, error: "Checkpoint not found - cannot rollback git state" } + if (!res) { + return { success: false, error: `Git rollback failed` } } } diff --git a/src/main/lib/trpc/routers/claude-settings.ts b/src/main/lib/trpc/routers/claude-settings.ts index 6ab64905..aa23235a 100644 --- a/src/main/lib/trpc/routers/claude-settings.ts +++ b/src/main/lib/trpc/routers/claude-settings.ts @@ -6,30 +6,6 @@ import { router, publicProcedure } from "../index" const CLAUDE_SETTINGS_PATH = path.join(os.homedir(), ".claude", "settings.json") -// Cache for enabled plugins to avoid repeated filesystem reads -let enabledPluginsCache: { plugins: string[]; timestamp: number } | null = null -const ENABLED_PLUGINS_CACHE_TTL_MS = 5000 // 5 seconds - -// Cache for approved plugin MCP servers -let approvedMcpCache: { servers: string[]; timestamp: number } | null = null -const APPROVED_MCP_CACHE_TTL_MS = 5000 // 5 seconds - -/** - * Invalidate the enabled plugins cache - * Call this when enabledPlugins setting changes - */ -export function invalidateEnabledPluginsCache(): void { - enabledPluginsCache = null -} - -/** - * Invalidate the approved MCP servers cache - * Call this when approvedPluginMcpServers setting changes - */ -export function invalidateApprovedMcpCache(): void { - approvedMcpCache = null -} - /** * Read Claude settings.json file * Returns empty object if file doesn't exist @@ -44,55 +20,6 @@ async function readClaudeSettings(): Promise> { } } -/** - * Get list of enabled plugin identifiers from settings.json - * Plugins are DISABLED by default — only plugins explicitly in this list are active. - * Returns empty array if no plugins have been enabled. - * Results are cached for 5 seconds to reduce filesystem reads. - */ -export async function getEnabledPlugins(): Promise { - // Return cached result if still valid - if (enabledPluginsCache && Date.now() - enabledPluginsCache.timestamp < ENABLED_PLUGINS_CACHE_TTL_MS) { - return enabledPluginsCache.plugins - } - - const settings = await readClaudeSettings() - const plugins = Array.isArray(settings.enabledPlugins) ? settings.enabledPlugins as string[] : [] - - enabledPluginsCache = { plugins, timestamp: Date.now() } - return plugins -} - -/** - * Get list of approved plugin MCP server identifiers from settings.json - * Format: "{pluginSource}:{serverName}" e.g., "ccsetup:ccsetup:context7" - * Returns empty array if no approved servers - * Results are cached for 5 seconds to reduce filesystem reads - */ -export async function getApprovedPluginMcpServers(): Promise { - // Return cached result if still valid - if (approvedMcpCache && Date.now() - approvedMcpCache.timestamp < APPROVED_MCP_CACHE_TTL_MS) { - return approvedMcpCache.servers - } - - const settings = await readClaudeSettings() - const servers = Array.isArray(settings.approvedPluginMcpServers) - ? settings.approvedPluginMcpServers as string[] - : [] - - approvedMcpCache = { servers, timestamp: Date.now() } - return servers -} - -/** - * Check if a plugin MCP server is approved - */ -export async function isPluginMcpApproved(pluginSource: string, serverName: string): Promise { - const approved = await getApprovedPluginMcpServers() - const identifier = `${pluginSource}:${serverName}` - return approved.includes(identifier) -} - /** * Write Claude settings.json file * Creates the .claude directory if it doesn't exist @@ -134,143 +61,4 @@ export const claudeSettingsRouter = router({ await writeClaudeSettings(settings) return { success: true } }), - - /** - * Get list of enabled plugins - * Plugins are disabled by default — only explicitly enabled ones are active. - */ - getEnabledPlugins: publicProcedure.query(async () => { - return await getEnabledPlugins() - }), - - /** - * Set a plugin's enabled state - * Plugins are disabled by default — adding to enabledPlugins activates them. - */ - setPluginEnabled: publicProcedure - .input( - z.object({ - pluginSource: z.string(), - enabled: z.boolean(), - }) - ) - .mutation(async ({ input }) => { - const settings = await readClaudeSettings() - const enabledPlugins = Array.isArray(settings.enabledPlugins) - ? (settings.enabledPlugins as string[]) - : [] - - if (input.enabled && !enabledPlugins.includes(input.pluginSource)) { - enabledPlugins.push(input.pluginSource) - } else if (!input.enabled) { - const index = enabledPlugins.indexOf(input.pluginSource) - if (index > -1) enabledPlugins.splice(index, 1) - } - - settings.enabledPlugins = enabledPlugins - await writeClaudeSettings(settings) - invalidateEnabledPluginsCache() - return { success: true } - }), - - /** - * Get list of approved plugin MCP servers - */ - getApprovedPluginMcpServers: publicProcedure.query(async () => { - return await getApprovedPluginMcpServers() - }), - - /** - * Approve a plugin MCP server - * Identifier format: "{pluginSource}:{serverName}" - */ - approvePluginMcpServer: publicProcedure - .input(z.object({ identifier: z.string() })) - .mutation(async ({ input }) => { - const settings = await readClaudeSettings() - const approved = Array.isArray(settings.approvedPluginMcpServers) - ? (settings.approvedPluginMcpServers as string[]) - : [] - - if (!approved.includes(input.identifier)) { - approved.push(input.identifier) - } - - settings.approvedPluginMcpServers = approved - await writeClaudeSettings(settings) - invalidateApprovedMcpCache() - return { success: true } - }), - - /** - * Revoke approval for a plugin MCP server - * Identifier format: "{pluginSource}:{serverName}" - */ - revokePluginMcpServer: publicProcedure - .input(z.object({ identifier: z.string() })) - .mutation(async ({ input }) => { - const settings = await readClaudeSettings() - const approved = Array.isArray(settings.approvedPluginMcpServers) - ? (settings.approvedPluginMcpServers as string[]) - : [] - - const index = approved.indexOf(input.identifier) - if (index > -1) { - approved.splice(index, 1) - } - - settings.approvedPluginMcpServers = approved - await writeClaudeSettings(settings) - invalidateApprovedMcpCache() - return { success: true } - }), - - /** - * Approve all MCP servers from a plugin - * Takes the pluginSource (e.g., "ccsetup:ccsetup") and list of server names - */ - approveAllPluginMcpServers: publicProcedure - .input(z.object({ - pluginSource: z.string(), - serverNames: z.array(z.string()), - })) - .mutation(async ({ input }) => { - const settings = await readClaudeSettings() - const approved = Array.isArray(settings.approvedPluginMcpServers) - ? (settings.approvedPluginMcpServers as string[]) - : [] - - for (const serverName of input.serverNames) { - const identifier = `${input.pluginSource}:${serverName}` - if (!approved.includes(identifier)) { - approved.push(identifier) - } - } - - settings.approvedPluginMcpServers = approved - await writeClaudeSettings(settings) - invalidateApprovedMcpCache() - return { success: true } - }), - - /** - * Revoke all MCP servers from a plugin - * Removes all identifiers matching "{pluginSource}:*" - */ - revokeAllPluginMcpServers: publicProcedure - .input(z.object({ - pluginSource: z.string(), - })) - .mutation(async ({ input }) => { - const settings = await readClaudeSettings() - const approved = Array.isArray(settings.approvedPluginMcpServers) - ? (settings.approvedPluginMcpServers as string[]) - : [] - - const prefix = `${input.pluginSource}:` - settings.approvedPluginMcpServers = approved.filter((id) => !id.startsWith(prefix)) - await writeClaudeSettings(settings) - invalidateApprovedMcpCache() - return { success: true } - }), }) diff --git a/src/main/lib/trpc/routers/claude.ts b/src/main/lib/trpc/routers/claude.ts index 7be49b8b..7f0023e0 100644 --- a/src/main/lib/trpc/routers/claude.ts +++ b/src/main/lib/trpc/routers/claude.ts @@ -1,11 +1,11 @@ import { observable } from "@trpc/server/observable" import { eq } from "drizzle-orm" import { app, BrowserWindow, safeStorage } from "electron" +import { readFileSync } from "fs" import * as fs from "fs/promises" import * as os from "os" -import path from "path" +import path, { join } from "path" import { z } from "zod" -import { setConnectionMethod } from "../../analytics" import { buildClaudeEnv, checkOfflineFallback, @@ -15,19 +15,18 @@ import { logRawClaudeMessage, type UIMessageChunk, } from "../../claude" -import { getProjectMcpServers, GLOBAL_MCP_PATH, readClaudeConfig, removeMcpServerConfig, resolveProjectPathFromWorktree, updateClaudeConfigAtomic, updateMcpServerConfig, writeClaudeConfig, type McpServerConfig } from "../../claude-config" -import { discoverPluginMcpServers } from "../../plugins" -import { getEnabledPlugins, getApprovedPluginMcpServers } from "./claude-settings" +import { getProjectMcpServers, GLOBAL_MCP_PATH, readClaudeConfig, resolveProjectPathFromWorktree, type McpServerConfig } from "../../claude-config" import { chats, claudeCodeCredentials, getDatabase, subChats } from "../../db" import { createRollbackStash } from "../../git/stash" -import { ensureMcpTokensFresh, fetchMcpTools, fetchMcpToolsStdio, getMcpAuthStatus, startMcpOAuth, type McpToolInfo } from "../../mcp-auth" +import { ensureMcpTokensFresh, fetchMcpTools, fetchMcpToolsStdio, getMcpAuthStatus, startMcpOAuth } from "../../mcp-auth" import { fetchOAuthMetadata, getMcpBaseUrl } from "../../oauth" +import { setConnectionMethod } from "../../analytics" import { publicProcedure, router } from "../index" import { buildAgentsOption } from "./agent-utils" /** - * Parse @[agent:name], @[skill:name], and @[tool:servername] mentions from prompt text - * Returns the cleaned prompt and lists of mentioned agents/skills/MCP servers + * Parse @[agent:name], @[skill:name], and @[tool:name] mentions from prompt text + * Returns the cleaned prompt and lists of mentioned agents/skills/tools * * File mention formats: * - @[file:local:relative/path] - file inside project (relative path) @@ -69,8 +68,9 @@ function parseMentions(prompt: string): { folderMentions.push(name) break case "tool": - // Validate: server name (alphanumeric, underscore, hyphen) or full tool id (mcp__server__tool) - if (/^[a-zA-Z0-9_-]+$/.test(name) || /^mcp__[a-zA-Z0-9_-]+__[a-zA-Z0-9_-]+$/.test(name)) { + // Validate tool name format: only alphanumeric, underscore, hyphen allowed + // This prevents prompt injection via malicious tool names + if (/^[a-zA-Z0-9_-]+$/.test(name)) { toolMentions.push(name) } break @@ -94,18 +94,11 @@ function parseMentions(prompt: string): { .replace(/@\[folder:local:([^\]]+)\]/g, "$1") .replace(/@\[folder:external:([^\]]+)\]/g, "$1") - // Add usage hints for mentioned MCP servers or individual tools - // Names are already validated to contain only safe characters + // Add tool usage hints if tools were mentioned + // Tool names are already validated to contain only safe characters if (toolMentions.length > 0) { const toolHints = toolMentions - .map((t) => { - if (t.startsWith("mcp__")) { - // Individual tool mention (from MCP widget): "Use the mcp__server__tool tool" - return `Use the ${t} tool for this request.` - } - // Server mention (from @ dropdown): "Use tools from the X MCP server" - return `Use tools from the ${t} MCP server for this request.` - }) + .map((t) => `Use the ${t} tool for this request.`) .join(" ") cleanedPrompt = `${toolHints}\n\n${cleanedPrompt}` } @@ -179,7 +172,7 @@ function mcpCacheKey(scope: string | null, serverName: string): string { const symlinksCreated = new Set() // Cache for MCP config (avoid re-reading ~/.claude.json on every message) -const mcpConfigCache = new Map | undefined mtime: number }>() @@ -273,8 +266,8 @@ const MCP_FETCH_TIMEOUT_MS = 10_000 * Fetch tools from an MCP server (HTTP or stdio transport) * Times out after 10 seconds to prevent slow MCPs from blocking the cache update */ -async function fetchToolsForServer(serverConfig: McpServerConfig): Promise { - const timeoutPromise = new Promise((_, reject) => +async function fetchToolsForServer(serverConfig: McpServerConfig): Promise { + const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(new Error('Timeout')), MCP_FETCH_TIMEOUT_MS) ) @@ -334,7 +327,7 @@ export async function getAllMcpConfigHandler() { let status = getServerStatusFromConfig(serverConfig) const headers = serverConfig.headers as Record | undefined - let tools: McpToolInfo[] = [] + let tools: string[] = [] let needsAuth = false try { @@ -363,9 +356,6 @@ export async function getAllMcpConfigHandler() { if (needsAuth && !headers?.Authorization) { status = "needs-auth" - } else { - // No tools and doesn't need auth - server failed to connect or has no tools - status = "failed" } } @@ -381,7 +371,7 @@ export async function getAllMcpConfigHandler() { groupName: string projectPath: string | null promise: Promise<{ - mcpServers: Array<{ name: string; status: string; tools: McpToolInfo[]; needsAuth: boolean; config: Record }> + mcpServers: Array<{ name: string; status: string; tools: string[]; needsAuth: boolean; config: Record }> duration: number }> }> = [] @@ -455,79 +445,6 @@ export async function getAllMcpConfigHandler() { mcpServers })) - // Plugin MCPs (from installed plugins) - const [enabledPluginSources, pluginMcpConfigs, approvedServers] = await Promise.all([ - getEnabledPlugins(), - discoverPluginMcpServers(), - getApprovedPluginMcpServers(), - ]) - - for (const pluginConfig of pluginMcpConfigs) { - // Only show MCP servers from enabled plugins - if (!enabledPluginSources.includes(pluginConfig.pluginSource)) continue - - const globalServerNames = config.mcpServers ? Object.keys(config.mcpServers) : [] - if (Object.keys(pluginConfig.mcpServers).length > 0) { - const pluginMcpServers = (await Promise.all( - Object.entries(pluginConfig.mcpServers).map(async ([name, serverConfig]) => { - // Skip servers that have been promoted to ~/.claude.json (e.g., after OAuth) - if (globalServerNames.includes(name)) return null - - const configObj = serverConfig as Record - const identifier = `${pluginConfig.pluginSource}:${name}` - const isApproved = approvedServers.includes(identifier) - - if (!isApproved) { - return { name, status: "pending-approval", tools: [] as McpToolInfo[], needsAuth: false, config: configObj, isApproved } - } - - // Try to get status and tools for approved servers - let status = getServerStatusFromConfig(serverConfig) - const headers = serverConfig.headers as Record | undefined - let tools: McpToolInfo[] = [] - let needsAuth = false - - try { - tools = await fetchToolsForServer(serverConfig) - } catch (error) { - console.error(`[MCP] Failed to fetch tools for plugin ${name}:`, error) - } - - if (tools.length > 0) { - status = "connected" - } else { - // Same OAuth detection logic as regular MCP servers - if (serverConfig.url) { - try { - const baseUrl = getMcpBaseUrl(serverConfig.url) - const metadata = await fetchOAuthMetadata(baseUrl) - needsAuth = !!metadata && !!metadata.authorization_endpoint - } catch { - // If probe fails, assume no auth needed - } - } else if (serverConfig.authType === "oauth" || serverConfig.authType === "bearer") { - needsAuth = true - } - - if (needsAuth && !headers?.Authorization) { - status = "needs-auth" - } else { - status = "failed" - } - } - - return { name, status, tools, needsAuth, config: configObj, isApproved } - }) - )).filter((s): s is NonNullable => s !== null) - - groups.push({ - groupName: `Plugin: ${pluginConfig.pluginSource}`, - projectPath: null, - mcpServers: pluginMcpServers, - }) - } - } - return { groups } } catch (error) { console.error("[getAllMcpConfig] Error:", error) @@ -916,30 +833,7 @@ export const claudeRouter = router({ // getProjectMcpServers resolves worktree paths internally const globalServers = claudeConfig.mcpServers || {} const projectServers = getProjectMcpServers(claudeConfig, lookupPath) || {} - - // Load plugin MCP servers (filtered by enabled plugins and approval) - const [enabledPluginSources, pluginMcpConfigs, approvedServers] = await Promise.all([ - getEnabledPlugins(), - discoverPluginMcpServers(), - getApprovedPluginMcpServers(), - ]) - - const pluginServers: Record = {} - for (const config of pluginMcpConfigs) { - if (enabledPluginSources.includes(config.pluginSource)) { - for (const [name, serverConfig] of Object.entries(config.mcpServers)) { - if (!globalServers[name] && !projectServers[name]) { - const identifier = `${config.pluginSource}:${name}` - if (approvedServers.includes(identifier)) { - pluginServers[name] = serverConfig - } - } - } - } - } - - // Priority: project > global > plugin - const allServers = { ...pluginServers, ...globalServers, ...projectServers } + const allServers = { ...globalServers, ...projectServers } // Filter to only working MCPs using scoped cache keys if (workingMcpServers.size > 0) { @@ -949,10 +843,7 @@ export const claudeRouter = router({ for (const [name, config] of Object.entries(allServers)) { // Use resolved project scope if server is from project, otherwise global const scope = name in projectServers ? resolvedProjectPath : null - const cacheKey = mcpCacheKey(scope, name) - // Include server if it's marked working, or if it's not in cache at all - // (plugin servers won't be in the cache yet) - if (workingMcpServers.get(cacheKey) === true || !workingMcpServers.has(cacheKey)) { + if (workingMcpServers.get(mcpCacheKey(scope, name)) === true) { filtered[name] = config } } @@ -1089,20 +980,6 @@ export const claudeRouter = router({ }) } - // Read AGENTS.md from project root if it exists - let agentsMdContent: string | undefined - try { - const agentsMdPath = path.join(input.cwd, "AGENTS.md") - agentsMdContent = await fs.readFile(agentsMdPath, "utf-8") - if (agentsMdContent.trim()) { - console.log(`[claude] Found AGENTS.md at ${agentsMdPath} (${agentsMdContent.length} chars)`) - } else { - agentsMdContent = undefined - } - } catch { - // AGENTS.md doesn't exist or can't be read - that's fine - } - // For Ollama: embed context AND history directly in prompt // Ollama doesn't have server-side sessions, so we must include full history let finalQueryPrompt: string | AsyncIterable = prompt @@ -1200,11 +1077,7 @@ IMPORTANT: When using tools, use these EXACT parameter names: When asked about the project, use Glob to find files and Read to examine them. Be concise and helpful. -[/CONTEXT]${agentsMdContent ? ` - -[AGENTS.MD] -${agentsMdContent} -[/AGENTS.MD]` : ''} +[/CONTEXT] ${historyText}[CURRENT REQUEST] ${prompt} @@ -1214,17 +1087,10 @@ ${prompt} } // System prompt config - use preset for both Claude and Ollama - // If AGENTS.md exists, append its content to the system prompt - const systemPromptConfig = agentsMdContent - ? { - type: "preset" as const, - preset: "claude_code" as const, - append: `\n\n# AGENTS.md\nThe following are the project's AGENTS.md instructions:\n\n${agentsMdContent}`, - } - : { - type: "preset" as const, - preset: "claude_code" as const, - } + const systemPromptConfig = { + type: "preset" as const, + preset: "claude_code" as const, + } const queryOptions = { prompt: finalQueryPrompt, @@ -1638,7 +1504,7 @@ ${prompt} // When result arrives, assign the last assistant UUID to metadata // It will be emitted as part of the merged message-metadata chunk below - if (msgAny.type === "result" && historyEnabled && lastAssistantUuid && !abortController.signal.aborted) { + if (msgAny.type === "result" && historyEnabled && lastAssistantUuid) { metadata.sdkMessageUuid = lastAssistantUuid } @@ -1966,8 +1832,6 @@ ${prompt} parts.push({ type: "text", text: currentText }) } - const savedSessionId = metadata.sessionId - if (parts.length > 0) { const assistantMessage = { id: crypto.randomUUID(), @@ -1981,7 +1845,7 @@ ${prompt} db.update(subChats) .set({ messages: JSON.stringify(finalMessages), - sessionId: savedSessionId, + sessionId: metadata.sessionId, streamId: null, updatedAt: new Date(), }) @@ -1991,7 +1855,7 @@ ${prompt} // No assistant response - just clear streamId db.update(subChats) .set({ - sessionId: savedSessionId, + sessionId: metadata.sessionId, streamId: null, updatedAt: new Date(), }) @@ -2032,13 +1896,14 @@ ${prompt} activeSessions.delete(input.subChatId) clearPendingApprovals("Session ended.", input.subChatId) - // Clear streamId since we're no longer streaming. - // sessionId is NOT saved here — the save block in the async function - // handles it (saves on normal completion, clears on abort). This avoids - // a redundant DB write that the cancel mutation would then overwrite. + // Save sessionId on abort so conversation can be resumed + // Clear streamId since we're no longer streaming const db = getDatabase() db.update(subChats) - .set({ streamId: null }) + .set({ + streamId: null, + ...(currentSessionId && { sessionId: currentSessionId }) + }) .where(eq(subChats.id, input.subChatId)) .run() } @@ -2055,33 +1920,14 @@ ${prompt} .query(async ({ input }) => { try { const config = await readClaudeConfig() - const globalServers = config.mcpServers || {} - const projectMcpServers = getProjectMcpServers(config, input.projectPath) || {} - - // Merge global + project (project overrides global) - const merged = { ...globalServers, ...projectMcpServers } - - // Add plugin MCP servers (enabled + approved only) - const [enabledPluginSources, pluginMcpConfigs, approvedServers] = await Promise.all([ - getEnabledPlugins(), - discoverPluginMcpServers(), - getApprovedPluginMcpServers(), - ]) - - for (const pluginConfig of pluginMcpConfigs) { - if (!enabledPluginSources.includes(pluginConfig.pluginSource)) continue - for (const [name, serverConfig] of Object.entries(pluginConfig.mcpServers)) { - if (!merged[name]) { - const identifier = `${pluginConfig.pluginSource}:${name}` - if (approvedServers.includes(identifier)) { - merged[name] = serverConfig - } - } - } + const projectMcpServers = getProjectMcpServers(config, input.projectPath) + + if (!projectMcpServers) { + return { mcpServers: [], projectPath: input.projectPath } } // Convert to array format - determine status from config (no caching) - const mcpServers = Object.entries(merged).map(([name, serverConfig]) => { + const mcpServers = Object.entries(projectMcpServers).map(([name, serverConfig]) => { const configObj = serverConfig as Record const status = getServerStatusFromConfig(configObj) const hasUrl = !!configObj.url @@ -2118,10 +1964,9 @@ ${prompt} controller.abort() activeSessions.delete(input.subChatId) clearPendingApprovals("Session cancelled.", input.subChatId) + return { cancelled: true } } - - - return { cancelled: !!controller } + return { cancelled: false } }), /** @@ -2177,243 +2022,4 @@ ${prompt} .query(async ({ input }) => { return getMcpAuthStatus(input.serverName, input.projectPath) }), - - addMcpServer: publicProcedure - .input(z.object({ - name: z.string().min(1).regex(/^[a-zA-Z0-9_-]+$/, "Name must contain only letters, numbers, underscores, and hyphens"), - scope: z.enum(["global", "project"]), - projectPath: z.string().optional(), - transport: z.enum(["stdio", "http"]), - command: z.string().optional(), - args: z.array(z.string()).optional(), - env: z.record(z.string()).optional(), - url: z.string().url().optional(), - authType: z.enum(["none", "oauth", "bearer"]).optional(), - bearerToken: z.string().optional(), - })) - .mutation(async ({ input }) => { - const serverName = input.name.trim() - - if (input.transport === "stdio" && !input.command?.trim()) { - throw new Error("Command is required for stdio servers") - } - if (input.transport === "http" && !input.url?.trim()) { - throw new Error("URL is required for HTTP servers") - } - if (input.scope === "project" && !input.projectPath) { - throw new Error("Project path required for project-scoped servers") - } - - const serverConfig: McpServerConfig = {} - if (input.transport === "stdio") { - serverConfig.command = input.command!.trim() - if (input.args && input.args.length > 0) { - serverConfig.args = input.args - } - if (input.env && Object.keys(input.env).length > 0) { - serverConfig.env = input.env - } - } else { - serverConfig.url = input.url!.trim() - if (input.authType) { - serverConfig.authType = input.authType - } - if (input.bearerToken) { - serverConfig.headers = { Authorization: `Bearer ${input.bearerToken}` } - } - } - - // Check existence before writing - const existingConfig = await readClaudeConfig() - const projectPath = input.projectPath - if (input.scope === "project" && projectPath) { - if (existingConfig.projects?.[projectPath]?.mcpServers?.[serverName]) { - throw new Error(`Server "${serverName}" already exists in this project`) - } - } else { - if (existingConfig.mcpServers?.[serverName]) { - throw new Error(`Server "${serverName}" already exists`) - } - } - - const config = updateMcpServerConfig(existingConfig, input.scope === "project" ? projectPath ?? null : null, serverName, serverConfig) - await writeClaudeConfig(config) - - return { success: true, name: serverName } - }), - - updateMcpServer: publicProcedure - .input(z.object({ - name: z.string(), - scope: z.enum(["global", "project"]), - projectPath: z.string().optional(), - newName: z.string().regex(/^[a-zA-Z0-9_-]+$/).optional(), - command: z.string().optional(), - args: z.array(z.string()).optional(), - env: z.record(z.string()).optional(), - url: z.string().url().optional(), - authType: z.enum(["none", "oauth", "bearer"]).optional(), - bearerToken: z.string().optional(), - disabled: z.boolean().optional(), - })) - .mutation(async ({ input }) => { - const config = await readClaudeConfig() - const projectPath = input.scope === "project" ? input.projectPath : undefined - - // Check server exists - let servers: Record | undefined - if (projectPath) { - servers = config.projects?.[projectPath]?.mcpServers - } else { - servers = config.mcpServers - } - if (!servers?.[input.name]) { - throw new Error(`Server "${input.name}" not found`) - } - - const existing = servers[input.name] - - // Handle rename: create new, remove old - if (input.newName && input.newName !== input.name) { - if (servers[input.newName]) { - throw new Error(`Server "${input.newName}" already exists`) - } - const updated = removeMcpServerConfig(config, projectPath ?? null, input.name) - const finalConfig = updateMcpServerConfig(updated, projectPath ?? null, input.newName, existing) - await writeClaudeConfig(finalConfig) - return { success: true, name: input.newName } - } - - // Build update object from provided fields - const update: Partial = {} - if (input.command !== undefined) update.command = input.command - if (input.args !== undefined) update.args = input.args - if (input.env !== undefined) update.env = input.env - if (input.url !== undefined) update.url = input.url - if (input.disabled !== undefined) update.disabled = input.disabled - - // Handle bearer token - if (input.bearerToken) { - update.authType = "bearer" - update.headers = { Authorization: `Bearer ${input.bearerToken}` } - } - - // Handle authType changes - if (input.authType) { - update.authType = input.authType - if (input.authType === "none") { - // Clear auth-related fields - update.headers = undefined - update._oauth = undefined - } - } - - const merged = { ...existing, ...update } - const updatedConfig = updateMcpServerConfig(config, projectPath ?? null, input.name, merged) - await writeClaudeConfig(updatedConfig) - - return { success: true, name: input.name } - }), - - removeMcpServer: publicProcedure - .input(z.object({ - name: z.string(), - scope: z.enum(["global", "project"]), - projectPath: z.string().optional(), - })) - .mutation(async ({ input }) => { - const config = await readClaudeConfig() - const projectPath = input.scope === "project" ? input.projectPath : undefined - - // Check server exists - let servers: Record | undefined - if (projectPath) { - servers = config.projects?.[projectPath]?.mcpServers - } else { - servers = config.mcpServers - } - if (!servers?.[input.name]) { - throw new Error(`Server "${input.name}" not found`) - } - - const updated = removeMcpServerConfig(config, projectPath ?? null, input.name) - await writeClaudeConfig(updated) - - return { success: true } - }), - - setMcpBearerToken: publicProcedure - .input(z.object({ - name: z.string(), - scope: z.enum(["global", "project"]), - projectPath: z.string().optional(), - token: z.string(), - })) - .mutation(async ({ input }) => { - const config = await readClaudeConfig() - const projectPath = input.scope === "project" ? input.projectPath : undefined - - // Check server exists - let servers: Record | undefined - if (projectPath) { - servers = config.projects?.[projectPath]?.mcpServers - } else { - servers = config.mcpServers - } - if (!servers?.[input.name]) { - throw new Error(`Server "${input.name}" not found`) - } - - const existing = servers[input.name] - const updated: McpServerConfig = { - ...existing, - authType: "bearer", - headers: { Authorization: `Bearer ${input.token}` }, - } - - const updatedConfig = updateMcpServerConfig(config, projectPath ?? null, input.name, updated) - await writeClaudeConfig(updatedConfig) - - return { success: true } - }), - - getPendingPluginMcpApprovals: publicProcedure - .input(z.object({ projectPath: z.string().optional() })) - .query(async ({ input }) => { - const [enabledPluginSources, pluginMcpConfigs, approvedServers] = await Promise.all([ - getEnabledPlugins(), - discoverPluginMcpServers(), - getApprovedPluginMcpServers(), - ]) - - // Read global/project servers for conflict check - const config = await readClaudeConfig() - const globalServers = config.mcpServers || {} - const projectServers = input.projectPath ? getProjectMcpServers(config, input.projectPath) || {} : {} - - const pending: Array<{ - pluginSource: string - serverName: string - identifier: string - config: Record - }> = [] - - for (const pluginConfig of pluginMcpConfigs) { - if (!enabledPluginSources.includes(pluginConfig.pluginSource)) continue - - for (const [name, serverConfig] of Object.entries(pluginConfig.mcpServers)) { - const identifier = `${pluginConfig.pluginSource}:${name}` - if (!approvedServers.includes(identifier) && !globalServers[name] && !projectServers[name]) { - pending.push({ - pluginSource: pluginConfig.pluginSource, - serverName: name, - identifier, - config: serverConfig as Record, - }) - } - } - } - - return { pending } - }), }) diff --git a/src/main/lib/trpc/routers/commands.ts b/src/main/lib/trpc/routers/commands.ts index e2e9d8cd..329a6a9a 100644 --- a/src/main/lib/trpc/routers/commands.ts +++ b/src/main/lib/trpc/routers/commands.ts @@ -4,15 +4,12 @@ import * as fs from "fs/promises" import * as path from "path" import * as os from "os" import matter from "gray-matter" -import { discoverInstalledPlugins, getPluginComponentPaths } from "../../plugins" -import { getEnabledPlugins } from "./claude-settings" -export interface FileCommand { +interface FileCommand { name: string description: string argumentHint?: string - source: "user" | "project" | "plugin" - pluginName?: string + source: "user" | "project" path: string } @@ -22,7 +19,6 @@ export interface FileCommand { function parseCommandMd(content: string): { description?: string argumentHint?: string - name?: string } { try { const { data } = matter(content) @@ -33,7 +29,6 @@ function parseCommandMd(content: string): { typeof data["argument-hint"] === "string" ? data["argument-hint"] : undefined, - name: typeof data.name === "string" ? data.name : undefined, } } catch (err) { console.error("[commands] Failed to parse frontmatter:", err) @@ -54,7 +49,7 @@ function isValidEntryName(name: string): boolean { */ async function scanCommandsDirectory( dir: string, - source: "user" | "project" | "plugin", + source: "user" | "project", prefix = "", ): Promise { const commands: FileCommand[] = [] @@ -87,12 +82,11 @@ async function scanCommandsDirectory( commands.push(...nestedCommands) } else if (entry.isFile() && entry.name.endsWith(".md")) { const baseName = entry.name.replace(/\.md$/, "") - const fallbackName = prefix ? `${prefix}:${baseName}` : baseName + const commandName = prefix ? `${prefix}:${baseName}` : baseName try { const content = await fs.readFile(fullPath, "utf-8") const parsed = parseCommandMd(content) - const commandName = parsed.name || fallbackName commands.push({ name: commandName, @@ -144,35 +138,14 @@ export const commandsRouter = router({ ) } - // Discover plugin commands - const [enabledPluginSources, installedPlugins] = await Promise.all([ - getEnabledPlugins(), - discoverInstalledPlugins(), + // Scan both directories in parallel + const [userCommands, projectCommands] = await Promise.all([ + userCommandsPromise, + projectCommandsPromise, ]) - const enabledPlugins = installedPlugins.filter( - (p) => enabledPluginSources.includes(p.source), - ) - const pluginCommandsPromises = enabledPlugins.map(async (plugin) => { - const paths = getPluginComponentPaths(plugin) - try { - const commands = await scanCommandsDirectory(paths.commands, "plugin") - return commands.map((cmd) => ({ ...cmd, pluginName: plugin.source })) - } catch { - return [] - } - }) - - // Scan all directories in parallel - const [userCommands, projectCommands, ...pluginCommandsArrays] = - await Promise.all([ - userCommandsPromise, - projectCommandsPromise, - ...pluginCommandsPromises, - ]) - const pluginCommands = pluginCommandsArrays.flat() - - // Project commands first (more specific), then user commands, then plugin commands - return [...projectCommands, ...userCommands, ...pluginCommands] + + // Project commands first (more specific), then user commands + return [...projectCommands, ...userCommands] }), /** diff --git a/src/main/lib/trpc/routers/external.ts b/src/main/lib/trpc/routers/external.ts index 3a16b33d..c245addc 100644 --- a/src/main/lib/trpc/routers/external.ts +++ b/src/main/lib/trpc/routers/external.ts @@ -1,5 +1,5 @@ import { clipboard, shell } from "electron"; -import { execFileSync, spawn } from "node:child_process"; +import { spawn } from "node:child_process"; import * as os from "node:os"; import * as path from "node:path"; import { z } from "zod"; @@ -81,24 +81,19 @@ export const externalRouter = router({ }), ) .mutation(async ({ input }) => { - const { cwd } = input; - const filePath = input.path.startsWith("~") - ? input.path.replace("~", os.homedir()) - : input.path; + const { path, cwd } = input; // Try common code editors in order of preference const editors = [ - { cmd: "cursor", args: [filePath] }, // Cursor - { cmd: "code", args: [filePath] }, // VS Code - { cmd: "subl", args: [filePath] }, // Sublime Text - { cmd: "atom", args: [filePath] }, // Atom - { cmd: "open", args: ["-t", filePath] }, // macOS default text editor + { cmd: "code", args: [path] }, // VS Code + { cmd: "cursor", args: [path] }, // Cursor + { cmd: "subl", args: [path] }, // Sublime Text + { cmd: "atom", args: [path] }, // Atom + { cmd: "open", args: ["-t", path] }, // macOS default text editor ]; for (const editor of editors) { try { - // Check if the command exists first - execFileSync("which", [editor.cmd], { stdio: "ignore" }); const child = spawn(editor.cmd, editor.args, { cwd: cwd || undefined, detached: true, @@ -113,7 +108,7 @@ export const externalRouter = router({ } // Fallback: use shell.openPath which opens with default app - await shell.openPath(filePath); + await shell.openPath(path); return { success: true, editor: "default" }; }), diff --git a/src/main/lib/trpc/routers/files.ts b/src/main/lib/trpc/routers/files.ts index 04763d1f..061429ce 100644 --- a/src/main/lib/trpc/routers/files.ts +++ b/src/main/lib/trpc/routers/files.ts @@ -1,10 +1,8 @@ import { z } from "zod" import { router, publicProcedure } from "../index" import { readdir, stat, readFile, writeFile, mkdir } from "node:fs/promises" -import { join, relative, basename, extname } from "node:path" +import { join, relative, basename } from "node:path" import { app } from "electron" -import { watch } from "node:fs" -import { observable } from "@trpc/server/observable" // Directories to ignore when scanning const IGNORED_DIRS = new Set([ @@ -282,109 +280,6 @@ export const filesRouter = router({ } }), - /** - * Read a text file with size/binary validation - * Returns structured result with error reasons - */ - readTextFile: publicProcedure - .input(z.object({ filePath: z.string() })) - .query(async ({ input }) => { - const { filePath } = input - const MAX_SIZE = 2 * 1024 * 1024 // 2 MB - - try { - const fileStat = await stat(filePath) - - if (fileStat.size > MAX_SIZE) { - return { ok: false as const, reason: "too-large" as const, byteLength: fileStat.size } - } - - const buffer = await readFile(filePath) - - // Check if binary by looking for null bytes in first 8KB - const sample = buffer.subarray(0, 8192) - if (sample.includes(0)) { - return { ok: false as const, reason: "binary" as const, byteLength: fileStat.size } - } - - const content = buffer.toString("utf-8") - return { ok: true as const, content, byteLength: fileStat.size } - } catch (error) { - const msg = error instanceof Error ? error.message : "Unknown error" - if (msg.includes("ENOENT") || msg.includes("no such file")) { - return { ok: false as const, reason: "not-found" as const, byteLength: 0 } - } - throw new Error(`Failed to read file: ${msg}`) - } - }), - - /** - * Read a binary file as base64 (for images) - */ - readBinaryFile: publicProcedure - .input(z.object({ filePath: z.string() })) - .query(async ({ input }) => { - const { filePath } = input - const MAX_SIZE = 20 * 1024 * 1024 // 20 MB - - try { - const fileStat = await stat(filePath) - - if (fileStat.size > MAX_SIZE) { - return { ok: false as const, reason: "too-large" as const, byteLength: fileStat.size } - } - - const buffer = await readFile(filePath) - const ext = extname(filePath).toLowerCase() - - // Determine MIME type - const mimeMap: Record = { - ".png": "image/png", - ".jpg": "image/jpeg", - ".jpeg": "image/jpeg", - ".gif": "image/gif", - ".svg": "image/svg+xml", - ".webp": "image/webp", - ".ico": "image/x-icon", - ".bmp": "image/bmp", - } - const mimeType = mimeMap[ext] || "application/octet-stream" - - return { - ok: true as const, - data: buffer.toString("base64"), - mimeType, - byteLength: fileStat.size, - } - } catch (error) { - const msg = error instanceof Error ? error.message : "Unknown error" - if (msg.includes("ENOENT") || msg.includes("no such file")) { - return { ok: false as const, reason: "not-found" as const, byteLength: 0 } - } - throw new Error(`Failed to read binary file: ${msg}`) - } - }), - - /** - * Watch for file changes in a project directory - * Emits events when files are modified - */ - watchChanges: publicProcedure - .input(z.object({ projectPath: z.string() })) - .subscription(({ input }) => { - return observable<{ filename: string; eventType: string }>((emit) => { - const watcher = watch(input.projectPath, { recursive: true }, (eventType, filename) => { - if (filename) { - emit.next({ filename, eventType }) - } - }) - - return () => { - watcher.close() - } - }) - }), - /** * Write pasted text to a file in the session's pasted directory * Used for large text pastes that shouldn't be embedded inline diff --git a/src/main/lib/trpc/routers/index.ts b/src/main/lib/trpc/routers/index.ts index 833026a9..7f35a7a0 100644 --- a/src/main/lib/trpc/routers/index.ts +++ b/src/main/lib/trpc/routers/index.ts @@ -16,7 +16,6 @@ import { worktreeConfigRouter } from "./worktree-config" import { sandboxImportRouter } from "./sandbox-import" import { commandsRouter } from "./commands" import { voiceRouter } from "./voice" -import { pluginsRouter } from "./plugins" import { createGitRouter } from "../../git" import { BrowserWindow } from "electron" @@ -43,7 +42,6 @@ export function createAppRouter(getWindow: () => BrowserWindow | null) { sandboxImport: sandboxImportRouter, commands: commandsRouter, voice: voiceRouter, - plugins: pluginsRouter, // Git operations - named "changes" to match Superset API changes: createGitRouter(), }) diff --git a/src/main/lib/trpc/routers/plugins.ts b/src/main/lib/trpc/routers/plugins.ts deleted file mode 100644 index 3519144c..00000000 --- a/src/main/lib/trpc/routers/plugins.ts +++ /dev/null @@ -1,227 +0,0 @@ -import { router, publicProcedure } from "../index" -import * as fs from "fs/promises" -import * as path from "path" -import matter from "gray-matter" -import { - discoverInstalledPlugins, - getPluginComponentPaths, - discoverPluginMcpServers, - clearPluginCache, -} from "../../plugins" -import { getEnabledPlugins } from "./claude-settings" - -interface PluginComponent { - name: string - description?: string -} - -interface PluginWithComponents { - name: string - version: string - description?: string - path: string - source: string // e.g., "ccsetup:ccsetup" - marketplace: string - category?: string - homepage?: string - tags?: string[] - isDisabled: boolean - components: { - commands: PluginComponent[] - skills: PluginComponent[] - agents: PluginComponent[] - mcpServers: string[] - } -} - -/** - * Validate entry name for security (prevent path traversal) - */ -function isValidEntryName(name: string): boolean { - return !name.includes("..") && !name.includes("/") && !name.includes("\\") -} - -/** - * Scan commands directory and return component info - */ -async function scanPluginCommands(dir: string): Promise { - const components: PluginComponent[] = [] - - try { - await fs.access(dir) - } catch { - return components - } - - try { - const entries = await fs.readdir(dir, { withFileTypes: true }) - - for (const entry of entries) { - if (!isValidEntryName(entry.name)) continue - - const fullPath = path.join(dir, entry.name) - - if (entry.isDirectory()) { - // Recursively scan nested directories for namespaced commands - const nested = await scanPluginCommands(fullPath) - components.push(...nested) - } else if (entry.isFile() && entry.name.endsWith(".md")) { - try { - const content = await fs.readFile(fullPath, "utf-8") - const { data } = matter(content) - const baseName = entry.name.replace(/\.md$/, "") - components.push({ - name: typeof data.name === "string" ? data.name : baseName, - description: - typeof data.description === "string" ? data.description : undefined, - }) - } catch { - // Skip files that can't be read - } - } - } - } catch { - // Directory read failed - } - - return components -} - -/** - * Scan skills directory and return component info - */ -async function scanPluginSkills(dir: string): Promise { - const components: PluginComponent[] = [] - - try { - await fs.access(dir) - } catch { - return components - } - - try { - const entries = await fs.readdir(dir, { withFileTypes: true }) - - for (const entry of entries) { - if (!entry.isDirectory() || !isValidEntryName(entry.name)) continue - - const skillMdPath = path.join(dir, entry.name, "SKILL.md") - try { - const content = await fs.readFile(skillMdPath, "utf-8") - const { data } = matter(content) - components.push({ - name: typeof data.name === "string" ? data.name : entry.name, - description: - typeof data.description === "string" ? data.description : undefined, - }) - } catch { - // Skill directory doesn't have SKILL.md - skip - } - } - } catch { - // Directory read failed - } - - return components -} - -/** - * Scan agents directory and return component info - */ -async function scanPluginAgents(dir: string): Promise { - const components: PluginComponent[] = [] - - try { - await fs.access(dir) - } catch { - return components - } - - try { - const entries = await fs.readdir(dir, { withFileTypes: true }) - - for (const entry of entries) { - if (!entry.isFile() || !entry.name.endsWith(".md") || !isValidEntryName(entry.name)) - continue - - const fullPath = path.join(dir, entry.name) - try { - const content = await fs.readFile(fullPath, "utf-8") - const { data } = matter(content) - const baseName = entry.name.replace(/\.md$/, "") - components.push({ - name: typeof data.name === "string" ? data.name : baseName, - description: - typeof data.description === "string" ? data.description : undefined, - }) - } catch { - // Skip files that can't be read - } - } - } catch { - // Directory read failed - } - - return components -} - -export const pluginsRouter = router({ - /** - * List all installed plugins with their components and disabled status - */ - list: publicProcedure.query(async (): Promise => { - const [installedPlugins, enabledPlugins, mcpConfigs] = await Promise.all([ - discoverInstalledPlugins(), - getEnabledPlugins(), - discoverPluginMcpServers(), - ]) - - // Build a map of plugin source -> MCP server names - const pluginMcpMap = new Map() - for (const config of mcpConfigs) { - pluginMcpMap.set(config.pluginSource, Object.keys(config.mcpServers)) - } - - // Scan components for each plugin in parallel - const pluginsWithComponents = await Promise.all( - installedPlugins.map(async (plugin) => { - const paths = getPluginComponentPaths(plugin) - - const [commands, skills, agents] = await Promise.all([ - scanPluginCommands(paths.commands), - scanPluginSkills(paths.skills), - scanPluginAgents(paths.agents), - ]) - - return { - name: plugin.name, - version: plugin.version, - description: plugin.description, - path: plugin.path, - source: plugin.source, - marketplace: plugin.marketplace, - category: plugin.category, - homepage: plugin.homepage, - tags: plugin.tags, - isDisabled: !enabledPlugins.includes(plugin.source), - components: { - commands, - skills, - agents, - mcpServers: pluginMcpMap.get(plugin.source) || [], - }, - } - }) - ) - - return pluginsWithComponents - }), - - /** - * Clear plugin cache (forces re-scan on next list) - */ - clearCache: publicProcedure.mutation(async () => { - clearPluginCache() - return { success: true } - }), -}) diff --git a/src/main/lib/trpc/routers/projects.ts b/src/main/lib/trpc/routers/projects.ts index 106c6d36..d2f0c70c 100644 --- a/src/main/lib/trpc/routers/projects.ts +++ b/src/main/lib/trpc/routers/projects.ts @@ -7,8 +7,7 @@ import { basename, join } from "path" import { exec } from "node:child_process" import { promisify } from "node:util" import { existsSync } from "node:fs" -import { mkdir, copyFile, unlink } from "node:fs/promises" -import { extname } from "node:path" +import { mkdir } from "node:fs/promises" import { getGitRemoteInfo } from "../../git" import { trackProjectOpened } from "../../analytics" import { getLaunchDirectory } from "../../cli" @@ -483,67 +482,4 @@ export const projectsRouter = router({ const targetPath = join(result.filePaths[0], input.suggestedName) return { success: true as const, targetPath } }), - - /** - * Upload a custom icon for a project (opens file picker for images) - */ - uploadIcon: publicProcedure - .input(z.object({ id: z.string() })) - .mutation(async ({ input, ctx }) => { - const window = ctx.getWindow?.() ?? BrowserWindow.getFocusedWindow() - if (!window) return null - - if (!window.isFocused()) { - window.focus() - await new Promise((resolve) => setTimeout(resolve, 100)) - } - - const result = await dialog.showOpenDialog(window, { - properties: ["openFile"], - title: "Select Project Icon", - buttonLabel: "Set Icon", - filters: [ - { name: "Images", extensions: ["png", "jpg", "jpeg", "svg", "webp", "ico"] }, - ], - }) - - if (result.canceled || !result.filePaths[0]) return null - - const sourcePath = result.filePaths[0] - const ext = extname(sourcePath) - const iconsDir = join(app.getPath("userData"), "project-icons") - await mkdir(iconsDir, { recursive: true }) - - const destPath = join(iconsDir, `${input.id}${ext}`) - await copyFile(sourcePath, destPath) - - const db = getDatabase() - return db - .update(projects) - .set({ iconPath: destPath, updatedAt: new Date() }) - .where(eq(projects.id, input.id)) - .returning() - .get() - }), - - /** - * Remove custom icon for a project - */ - removeIcon: publicProcedure - .input(z.object({ id: z.string() })) - .mutation(async ({ input }) => { - const db = getDatabase() - const project = db.select().from(projects).where(eq(projects.id, input.id)).get() - - if (project?.iconPath && existsSync(project.iconPath)) { - try { await unlink(project.iconPath) } catch {} - } - - return db - .update(projects) - .set({ iconPath: null, updatedAt: new Date() }) - .where(eq(projects.id, input.id)) - .returning() - .get() - }), }) diff --git a/src/main/lib/trpc/routers/skills.ts b/src/main/lib/trpc/routers/skills.ts index bddc1688..73adc193 100644 --- a/src/main/lib/trpc/routers/skills.ts +++ b/src/main/lib/trpc/routers/skills.ts @@ -4,32 +4,27 @@ import * as fs from "fs/promises" import * as path from "path" import * as os from "os" import matter from "gray-matter" -import { discoverInstalledPlugins, getPluginComponentPaths } from "../../plugins" -import { getEnabledPlugins } from "./claude-settings" -export interface FileSkill { +interface FileSkill { name: string description: string - source: "user" | "project" | "plugin" - pluginName?: string + source: "user" | "project" path: string - content: string } /** * Parse SKILL.md frontmatter to extract name and description */ -function parseSkillMd(rawContent: string): { name?: string; description?: string; content: string } { +function parseSkillMd(content: string): { name?: string; description?: string } { try { - const { data, content } = matter(rawContent) + const { data } = matter(content) return { name: typeof data.name === "string" ? data.name : undefined, description: typeof data.description === "string" ? data.description : undefined, - content: content.trim(), } } catch (err) { console.error("[skills] Failed to parse frontmatter:", err) - return { content: rawContent.trim() } + return {} } } @@ -38,7 +33,7 @@ function parseSkillMd(rawContent: string): { name?: string; description?: string */ async function scanSkillsDirectory( dir: string, - source: "user" | "project" | "plugin", + source: "user" | "project", basePath?: string, // For project skills, the cwd to make paths relative to ): Promise { const skills: FileSkill[] = [] @@ -54,19 +49,7 @@ async function scanSkillsDirectory( const entries = await fs.readdir(dir, { withFileTypes: true }) for (const entry of entries) { - // Check if entry is a directory or a symlink pointing to a directory - let isDir = entry.isDirectory() - if (!isDir && entry.isSymbolicLink()) { - try { - const targetPath = path.join(dir, entry.name) - const stat = await fs.stat(targetPath) // stat() follows symlinks - isDir = stat.isDirectory() - } catch { - // Symlink target doesn't exist or is inaccessible - skip it - continue - } - } - if (!isDir) continue + if (!entry.isDirectory()) continue // Validate entry name for security (prevent path traversal) if (entry.name.includes("..") || entry.name.includes("/") || entry.name.includes("\\")) { @@ -98,7 +81,6 @@ async function scanSkillsDirectory( description: parsed.description || "", source, path: displayPath, - content: parsed.content, }) } catch (err) { // Skill directory doesn't have SKILL.md or read failed - skip it @@ -130,58 +112,15 @@ const listSkillsProcedure = publicProcedure projectSkillsPromise = scanSkillsDirectory(projectSkillsDir, "project", input.cwd) } - // Discover plugin skills - const [enabledPluginSources, installedPlugins] = await Promise.all([ - getEnabledPlugins(), - discoverInstalledPlugins(), + // Scan both directories in parallel + const [userSkills, projectSkills] = await Promise.all([ + userSkillsPromise, + projectSkillsPromise, ]) - const enabledPlugins = installedPlugins.filter( - (p) => enabledPluginSources.includes(p.source), - ) - const pluginSkillsPromises = enabledPlugins.map(async (plugin) => { - const paths = getPluginComponentPaths(plugin) - try { - const skills = await scanSkillsDirectory(paths.skills, "plugin") - return skills.map((skill) => ({ ...skill, pluginName: plugin.source })) - } catch { - return [] - } - }) - - // Scan all directories in parallel - const [userSkills, projectSkills, ...pluginSkillsArrays] = - await Promise.all([ - userSkillsPromise, - projectSkillsPromise, - ...pluginSkillsPromises, - ]) - const pluginSkills = pluginSkillsArrays.flat() - return [...projectSkills, ...userSkills, ...pluginSkills] + return [...projectSkills, ...userSkills] }) -/** - * Generate SKILL.md content from name, description, and body - */ -function generateSkillMd(skill: { name: string; description: string; content: string }): string { - const frontmatter: string[] = [] - frontmatter.push(`name: ${skill.name}`) - if (skill.description) { - frontmatter.push(`description: ${skill.description}`) - } - return `---\n${frontmatter.join("\n")}\n---\n\n${skill.content}` -} - -/** - * Resolve the absolute filesystem path of a skill given its display path - */ -function resolveSkillPath(displayPath: string): string { - if (displayPath.startsWith("~")) { - return path.join(os.homedir(), displayPath.slice(1)) - } - return displayPath -} - export const skillsRouter = router({ /** * List all skills from filesystem @@ -194,96 +133,4 @@ export const skillsRouter = router({ * Alias for list - used by @ mention */ listEnabled: listSkillsProcedure, - - /** - * Create a new skill - */ - create: publicProcedure - .input( - z.object({ - name: z.string(), - description: z.string(), - content: z.string(), - source: z.enum(["user", "project"]), - cwd: z.string().optional(), - }) - ) - .mutation(async ({ input }) => { - const safeName = input.name.toLowerCase().replace(/[^a-z0-9-]/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "") - if (!safeName) { - throw new Error("Skill name must contain at least one alphanumeric character") - } - - let targetDir: string - if (input.source === "project") { - if (!input.cwd) { - throw new Error("Project path (cwd) required for project skills") - } - targetDir = path.join(input.cwd, ".claude", "skills") - } else { - targetDir = path.join(os.homedir(), ".claude", "skills") - } - - const skillDir = path.join(targetDir, safeName) - const skillMdPath = path.join(skillDir, "SKILL.md") - - // Check if already exists - try { - await fs.access(skillMdPath) - throw new Error(`Skill "${safeName}" already exists`) - } catch (err) { - if ((err as NodeJS.ErrnoException).code !== "ENOENT") { - throw err - } - } - - // Create directory and write SKILL.md - await fs.mkdir(skillDir, { recursive: true }) - - const fileContent = generateSkillMd({ - name: safeName, - description: input.description, - content: input.content, - }) - - await fs.writeFile(skillMdPath, fileContent, "utf-8") - - return { - name: safeName, - path: skillMdPath, - source: input.source, - } - }), - - /** - * Update a skill's SKILL.md content - */ - update: publicProcedure - .input( - z.object({ - path: z.string(), - name: z.string(), - description: z.string(), - content: z.string(), - cwd: z.string().optional(), - }) - ) - .mutation(async ({ input }) => { - const absolutePath = input.cwd && !input.path.startsWith("~") && !input.path.startsWith("/") - ? path.join(input.cwd, input.path) - : resolveSkillPath(input.path) - - // Verify file exists before writing - await fs.access(absolutePath) - - const fileContent = generateSkillMd({ - name: input.name, - description: input.description, - content: input.content, - }) - - await fs.writeFile(absolutePath, fileContent, "utf-8") - - return { success: true } - }), }) diff --git a/src/main/windows/main.ts b/src/main/windows/main.ts index 36c88af3..e0c03154 100644 --- a/src/main/windows/main.ts +++ b/src/main/windows/main.ts @@ -540,7 +540,8 @@ export function createWindow(options?: { chatId?: string; subChatId?: string }): title: "1Code", backgroundColor: nativeTheme.shouldUseDarkColors ? "#09090b" : "#ffffff", // hiddenInset shows native traffic lights inset in the window - // hiddenInset hides the native title bar but keeps traffic lights visible + // Start with traffic lights off-screen (custom ones shown in normal mode) + // Native lights will be moved on-screen in fullscreen mode titleBarStyle: process.platform === "darwin" ? "hiddenInset" : "default", trafficLightPosition: process.platform === "darwin" ? { x: 15, y: 12 } : undefined, @@ -583,7 +584,7 @@ export function createWindow(options?: { chatId?: string; subChatId?: string }): // Show window when ready window.on("ready-to-show", () => { console.log("[Main] Window", window.id, "ready to show") - // Always show native macOS traffic lights + // Ensure native traffic lights are visible by default (login page, loading states) if (process.platform === "darwin") { window.setWindowButtonVisibility(true) } @@ -599,7 +600,7 @@ export function createWindow(options?: { chatId?: string; subChatId?: string }): window.webContents.send("window:fullscreen-change", true) }) window.on("leave-full-screen", () => { - // Show native traffic lights when exiting fullscreen + // Show native traffic lights when exiting fullscreen (TrafficLights component will manage after mount) if (process.platform === "darwin") { window.setWindowButtonVisibility(true) } @@ -689,7 +690,7 @@ export function createWindow(options?: { chatId?: string; subChatId?: string }): } } - // Ensure native traffic lights are visible after page load + // Ensure traffic lights are visible after page load (covers reload/Cmd+R case) window.webContents.on("did-finish-load", () => { console.log("[Main] Page finished loading in window", window.id) if (process.platform === "darwin") { diff --git a/src/preload/index.ts b/src/preload/index.ts index 5152745d..3c21458f 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -33,8 +33,6 @@ contextBridge.exposeInMainWorld("desktopApi", { checkForUpdates: (force?: boolean) => ipcRenderer.invoke("update:check", force), downloadUpdate: () => ipcRenderer.invoke("update:download"), installUpdate: () => ipcRenderer.invoke("update:install"), - setUpdateChannel: (channel: "latest" | "beta") => ipcRenderer.invoke("update:set-channel", channel), - getUpdateChannel: () => ipcRenderer.invoke("update:get-channel") as Promise<"latest" | "beta">, // Auto-update event listeners onUpdateChecking: (callback: () => void) => { @@ -218,6 +216,13 @@ contextBridge.exposeInMainWorld("desktopApi", { subscribeToGitWatcher: (worktreePath: string) => ipcRenderer.invoke("git:subscribe-watcher", worktreePath), unsubscribeFromGitWatcher: (worktreePath: string) => ipcRenderer.invoke("git:unsubscribe-watcher", worktreePath), + // Claude config change events (from ~/.claude.json watcher) + onClaudeConfigChanged: (callback: () => void) => { + const handler = () => callback() + ipcRenderer.on("claude-config-changed", handler) + return () => ipcRenderer.removeListener("claude-config-changed", handler) + }, + // VS Code theme scanning scanVSCodeThemes: () => ipcRenderer.invoke("vscode:scan-themes"), loadVSCodeTheme: (themePath: string) => ipcRenderer.invoke("vscode:load-theme", themePath), @@ -266,11 +271,9 @@ export interface DesktopApi { getVersion: () => Promise isPackaged: () => Promise // Auto-update - checkForUpdates: (force?: boolean) => Promise + checkForUpdates: () => Promise downloadUpdate: () => Promise installUpdate: () => void - setUpdateChannel: (channel: "latest" | "beta") => Promise - getUpdateChannel: () => Promise<"latest" | "beta"> onUpdateChecking: (callback: () => void) => () => void onUpdateAvailable: (callback: (info: UpdateInfo) => void) => () => void onUpdateNotAvailable: (callback: () => void) => () => void @@ -351,6 +354,8 @@ export interface DesktopApi { onGitStatusChanged: (callback: (data: { worktreePath: string; changes: Array<{ path: string; type: "add" | "change" | "unlink" }> }) => void) => () => void subscribeToGitWatcher: (worktreePath: string) => Promise unsubscribeFromGitWatcher: (worktreePath: string) => Promise + // Claude config changes (from ~/.claude.json watcher) + onClaudeConfigChanged: (callback: () => void) => () => void // VS Code theme scanning scanVSCodeThemes: () => Promise loadVSCodeTheme: (themePath: string) => Promise diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 9e3c9010..d8175f02 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -14,6 +14,7 @@ import { BillingMethodPage, SelectRepoPage, } from "./features/onboarding" +import { useClaudeConfigWatcher } from "./lib/hooks/use-file-change-listener" import { identify, initAnalytics, shutdown } from "./lib/analytics" import { anthropicOnboardingCompletedAtom, apiKeyOnboardingCompletedAtom, @@ -54,6 +55,9 @@ function AppContent() { const setSelectedChatId = useSetAtom(selectedAgentChatIdAtom) const { setActiveSubChat, addToOpenSubChats, setChatId } = useAgentSubChatStore() + // Watch ~/.claude.json for changes and auto-refresh MCP config + useClaudeConfigWatcher() + // Apply initial window params (chatId/subChatId) when opening via "Open in new window" useEffect(() => { const params = getInitialWindowParams() diff --git a/src/renderer/components/dialogs/agents-settings-dialog.tsx b/src/renderer/components/dialogs/agents-settings-dialog.tsx new file mode 100644 index 00000000..7bb5e13e --- /dev/null +++ b/src/renderer/components/dialogs/agents-settings-dialog.tsx @@ -0,0 +1,603 @@ +import { useAtom } from "jotai" +import { ChevronLeft, ChevronRight, FolderOpen, X } from "lucide-react" +import { AnimatePresence, motion } from "motion/react" +import { useCallback, useEffect, useMemo, useRef, useState } from "react" +import { createPortal } from "react-dom" +import { + EyeOpenFilledIcon, + ProfileIconFilled, + SlidersFilledIcon +} from "../../icons" +import { agentsSettingsDialogActiveTabAtom, devToolsUnlockedAtom, type SettingsTab } from "../../lib/atoms" +import { trpc } from "../../lib/trpc" +import { cn } from "../../lib/utils" +import { BrainFilledIcon, BugFilledIcon, CustomAgentIconFilled, FlaskFilledIcon, KeyboardFilledIcon, OriginalMCPIcon, SkillIconFilled } from "../ui/icons" +import { AgentsAppearanceTab } from "./settings-tabs/agents-appearance-tab" +import { AgentsBetaTab } from "./settings-tabs/agents-beta-tab" +import { AgentsCustomAgentsTab } from "./settings-tabs/agents-custom-agents-tab" +import { AgentsDebugTab } from "./settings-tabs/agents-debug-tab" +import { AgentsKeyboardTab } from "./settings-tabs/agents-keyboard-tab" +import { AgentsMcpTab } from "./settings-tabs/agents-mcp-tab" +import { AgentsModelsTab } from "./settings-tabs/agents-models-tab" +import { AgentsPreferencesTab } from "./settings-tabs/agents-preferences-tab" +import { AgentsProfileTab } from "./settings-tabs/agents-profile-tab" +import { AgentsProjectWorktreeTab } from "./settings-tabs/agents-project-worktree-tab" +import { AgentsSkillsTab } from "./settings-tabs/agents-skills-tab" + +// GitHub avatar icon with loading placeholder +function GitHubAvatarIcon({ gitOwner, className }: { gitOwner: string; className?: string }) { + const [isLoaded, setIsLoaded] = useState(false) + const [hasError, setHasError] = useState(false) + + const handleLoad = useCallback(() => setIsLoaded(true), []) + const handleError = useCallback(() => setHasError(true), []) + + if (hasError) { + return + } + + return ( +
+ {/* Placeholder background while loading */} + {!isLoaded && ( +
+ )} + {gitOwner} +
+ ) +} + +// Hook to detect narrow screen +function useIsNarrowScreen(): boolean { + const [isNarrow, setIsNarrow] = useState(false) + + useEffect(() => { + const checkWidth = () => { + setIsNarrow(window.innerWidth <= 768) + } + + checkWidth() + window.addEventListener("resize", checkWidth) + return () => window.removeEventListener("resize", checkWidth) + }, []) + + return isNarrow +} + +// Check if we're in development mode (use import.meta.env.DEV for Vite) +const isDevelopment = import.meta.env.DEV + +// Clicks required to unlock devtools in production +const DEVTOOLS_UNLOCK_CLICKS = 5 + +interface AgentsSettingsDialogProps { + isOpen: boolean + onClose: () => void +} + +// Main settings tabs +const MAIN_TABS = [ + { + id: "profile" as SettingsTab, + label: "Account", + icon: ProfileIconFilled, + description: "Manage your account settings", + }, + { + id: "appearance" as SettingsTab, + label: "Appearance", + icon: EyeOpenFilledIcon, + description: "Theme settings", + }, + { + id: "keyboard" as SettingsTab, + label: "Keyboard", + icon: KeyboardFilledIcon, + description: "Customize keyboard shortcuts", + }, + { + id: "preferences" as SettingsTab, + label: "Preferences", + icon: SlidersFilledIcon, + description: "Claude behavior settings", + }, + { + id: "models" as SettingsTab, + label: "Models", + icon: BrainFilledIcon, + description: "Model overrides and Claude Code auth", + }, +] + +// Advanced/experimental tabs (base - without Debug) +const ADVANCED_TABS_BASE = [ + { + id: "skills" as SettingsTab, + label: "Skills", + icon: SkillIconFilled, + description: "Custom Claude skills", + }, + { + id: "agents" as SettingsTab, + label: "Custom Agents", + icon: CustomAgentIconFilled, + description: "Manage custom Claude agents", + }, + { + id: "mcp" as SettingsTab, + label: "MCP Servers", + icon: OriginalMCPIcon, + description: "Model Context Protocol servers", + }, + { + id: "beta" as SettingsTab, + label: "Beta", + icon: FlaskFilledIcon, + description: "Experimental features", + }, +] + +// Debug tab definition +const DEBUG_TAB = { + id: "debug" as SettingsTab, + label: "Debug", + icon: BugFilledIcon, + description: "Test first-time user experience", +} + +interface TabButtonProps { + tab: { + id: SettingsTab + label: string + icon: React.ComponentType<{ className?: string }> | any + description?: string + beta?: boolean + } + isActive: boolean + onClick: () => void + isNarrow?: boolean +} + +function TabButton({ tab, isActive, onClick, isNarrow }: TabButtonProps) { + const Icon = tab.icon + const isBeta = "beta" in tab && tab.beta + // Check if this is a project tab (has projectId property) + const isProjectTab = "projectId" in tab + + return ( + + ) +} + +export function AgentsSettingsDialog({ + isOpen, + onClose, +}: AgentsSettingsDialogProps) { + const [activeTab, setActiveTab] = useAtom(agentsSettingsDialogActiveTabAtom) + const [devToolsUnlocked, setDevToolsUnlocked] = useAtom(devToolsUnlockedAtom) + const [mounted, setMounted] = useState(false) + const [portalTarget, setPortalTarget] = useState(null) + const isNarrowScreen = useIsNarrowScreen() + + // Beta tab click counter for unlocking devtools + const betaClickCountRef = useRef(0) + const betaClickTimeoutRef = useRef(null) + + // Get projects list for dynamic tabs + const { data: projects } = trpc.projects.list.useQuery() + + // Generate dynamic project tabs + const projectTabs = useMemo(() => { + if (!projects || projects.length === 0) { + return [] + } + + return projects.map((project) => ({ + id: `project-${project.id}` as SettingsTab, + label: project.name, + icon: (project.gitOwner && project.gitProvider === 'github') + ? ({ className }: { className?: string }) => ( + + ) + : FolderOpen, + description: `Worktree setup for ${project.name}`, + projectId: project.id, + })) + }, [projects]) + + // Show debug tab if in development OR if devtools are unlocked + const showDebugTab = isDevelopment || devToolsUnlocked + + // Build advanced tabs with optional debug tab + const ADVANCED_TABS = useMemo(() => { + if (showDebugTab) { + return [...ADVANCED_TABS_BASE, DEBUG_TAB] + } + return ADVANCED_TABS_BASE + }, [showDebugTab]) + + // All tabs combined for lookups + const ALL_TABS = useMemo( + () => [...MAIN_TABS, ...ADVANCED_TABS, ...projectTabs], + [ADVANCED_TABS, projectTabs] + ) + + // Helper to get tab label from tab id + const getTabLabel = (tabId: SettingsTab): string => { + return ALL_TABS.find((t) => t.id === tabId)?.label ?? "Settings" + } + + // Narrow screen: track whether we're showing tab list or content + const [showContent, setShowContent] = useState(false) + + // Reset content view when dialog closes + useEffect(() => { + if (!isOpen) { + setShowContent(false) + } + }, [isOpen]) + + // Handle keyboard navigation + useEffect(() => { + if (!isOpen) return + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") { + event.preventDefault() + if (isNarrowScreen && showContent) { + setShowContent(false) + } else { + onClose() + } + } + } + + document.addEventListener("keydown", handleKeyDown) + return () => document.removeEventListener("keydown", handleKeyDown) + }, [isOpen, onClose, isNarrowScreen, showContent]) + + // Ensure portal target only accessed on client + useEffect(() => { + setMounted(true) + if (typeof document !== "undefined") { + setPortalTarget(document.body) + } + }, []) + + const handleTabClick = (tabId: SettingsTab) => { + // Handle Beta tab clicks for devtools unlock + // Works in both dev and production - the unlock just reveals the Debug tab + if (tabId === "beta" && !devToolsUnlocked) { + betaClickCountRef.current++ + console.log(`[Settings] Beta click ${betaClickCountRef.current}/${DEVTOOLS_UNLOCK_CLICKS}`) + + // Reset counter after 2 seconds of no clicks + if (betaClickTimeoutRef.current) { + clearTimeout(betaClickTimeoutRef.current) + } + betaClickTimeoutRef.current = setTimeout(() => { + betaClickCountRef.current = 0 + }, 2000) + + // Unlock devtools after required clicks + if (betaClickCountRef.current >= DEVTOOLS_UNLOCK_CLICKS) { + setDevToolsUnlocked(true) + betaClickCountRef.current = 0 + // Notify main process to rebuild menu with DevTools option + window.desktopApi?.unlockDevTools() + console.log("[Settings] DevTools unlocked!") + } + } + + setActiveTab(tabId) + if (isNarrowScreen) { + setShowContent(true) + } + } + + const renderTabContent = () => { + // Handle dynamic project tabs + if (activeTab.startsWith('project-')) { + const projectId = activeTab.replace('project-', '') + return + } + + // Handle static tabs + switch (activeTab) { + case "profile": + return + case "appearance": + return + case "keyboard": + return + case "preferences": + return + case "models": + return + case "skills": + return + case "agents": + return + case "mcp": + return + case "beta": + return + case "debug": + return showDebugTab ? : null + default: + return null + } + } + + const renderTabList = () => ( +
+ {/* Main tabs */} +
+ {MAIN_TABS.map((tab) => ( + handleTabClick(tab.id)} + isNarrow={isNarrowScreen} + /> + ))} +
+ + {/* Separator */} +
+ + {/* Advanced tabs */} +
+ {ADVANCED_TABS.map((tab) => ( + handleTabClick(tab.id)} + isNarrow={isNarrowScreen} + /> + ))} +
+ + {/* Project tabs */} + {projectTabs.length > 0 && ( + <> + {/* Separator */} +
+ +
+ {projectTabs.map((tab) => ( + handleTabClick(tab.id)} + isNarrow={isNarrowScreen} + /> + ))} +
+ + )} +
+ ) + + if (!mounted || !portalTarget) return null + + // Narrow screen: Full-screen overlay with two-screen navigation + if (isNarrowScreen) { + if (!isOpen) return null + + return createPortal( + <> + {/* Full-screen settings panel */} +
+ {/* Header */} +
+ {showContent && ( + + )} +

+ {showContent ? getTabLabel(activeTab) : "Settings"} +

+ +
+ + {/* Content */} +
+ {showContent ? ( +
+ {renderTabContent()} +
+ ) : ( +
+ {renderTabList()} +
+ )} +
+
+ , + portalTarget, + ) + } + + // Wide screen: Centered modal with sidebar + return createPortal( + + {isOpen && ( + <> + {/* Custom Overlay */} + + + {/* Settings Dialog */} +
+ +

+ Settings +

+ +
+ {/* Left Sidebar - Tabs */} +
+

+ Settings +

+ + {/* Main Tabs */} +
+ {MAIN_TABS.map((tab) => ( + setActiveTab(tab.id)} + /> + ))} +
+ + {/* Separator */} +
+ + {/* Advanced Tabs */} +
+ {ADVANCED_TABS.map((tab) => ( + setActiveTab(tab.id)} + /> + ))} +
+ + {/* Project Tabs */} + {projectTabs.length > 0 && ( + <> + {/* Separator */} +
+ +
+ {projectTabs.map((tab) => ( + setActiveTab(tab.id)} + /> + ))} +
+ + )} +
+ + {/* Right Content Area */} +
+
+ {renderTabContent()} +
+
+
+ + {/* Close Button */} + + +
+ + )} + , + portalTarget, + ) +} diff --git a/src/renderer/components/dialogs/index.ts b/src/renderer/components/dialogs/index.ts index a8d8284a..2efb77e9 100644 --- a/src/renderer/components/dialogs/index.ts +++ b/src/renderer/components/dialogs/index.ts @@ -1,3 +1,6 @@ +// Dialogs +export { AgentsSettingsDialog } from "./agents-settings-dialog" + // Settings tabs export { AgentsAppearanceTab } from "./settings-tabs/agents-appearance-tab" export { AgentsProfileTab } from "./settings-tabs/agents-profile-tab" diff --git a/src/renderer/components/dialogs/mcp-approval-dialog.tsx b/src/renderer/components/dialogs/mcp-approval-dialog.tsx deleted file mode 100644 index c6b733b4..00000000 --- a/src/renderer/components/dialogs/mcp-approval-dialog.tsx +++ /dev/null @@ -1,187 +0,0 @@ -"use client" - -import { useAtom } from "jotai" -import { Shield } from "lucide-react" -import { - AlertDialog, - AlertDialogContent, - AlertDialogHeader, - AlertDialogBody, - AlertDialogFooter, - AlertDialogTitle, - AlertDialogDescription, -} from "../ui/alert-dialog" -import { Button } from "../ui/button" -import { trpc } from "../../lib/trpc" -import { toast } from "sonner" -import { - mcpApprovalDialogOpenAtom, - pendingMcpApprovalsAtom, -} from "../../lib/atoms" - -export function McpApprovalDialog() { - const [isOpen, setIsOpen] = useAtom(mcpApprovalDialogOpenAtom) - const [pendingApprovals, setPendingApprovals] = useAtom( - pendingMcpApprovalsAtom, - ) - - const approveMutation = - trpc.claudeSettings.approvePluginMcpServer.useMutation() - const approveAllMutation = - trpc.claudeSettings.approveAllPluginMcpServers.useMutation() - - const currentApproval = pendingApprovals[0] - - const handleAllow = async () => { - if (!currentApproval) return - - try { - await approveMutation.mutateAsync({ - identifier: currentApproval.identifier, - }) - toast.success("MCP server approved", { - description: currentApproval.serverName, - }) - } catch (error) { - const message = - error instanceof Error ? error.message : "Failed to approve" - toast.error(message) - } - - advance() - } - - const handleAllowAll = async () => { - if (!currentApproval) return - - // Approve all pending from the same plugin - const samePlugin = pendingApprovals.filter( - (a) => a.pluginSource === currentApproval.pluginSource, - ) - - try { - await approveAllMutation.mutateAsync({ - pluginSource: currentApproval.pluginSource, - serverNames: samePlugin.map((a) => a.serverName), - }) - toast.success("All MCP servers approved", { - description: `${samePlugin.length} server(s) from ${currentApproval.pluginSource}`, - }) - } catch (error) { - const message = - error instanceof Error ? error.message : "Failed to approve" - toast.error(message) - } - - // Remove all from same plugin - const remaining = pendingApprovals.filter( - (a) => a.pluginSource !== currentApproval.pluginSource, - ) - setPendingApprovals(remaining) - if (remaining.length === 0) { - setIsOpen(false) - } - } - - const handleDeny = () => { - advance() - } - - const advance = () => { - const remaining = pendingApprovals.slice(1) - setPendingApprovals(remaining) - if (remaining.length === 0) { - setIsOpen(false) - } - } - - if (!currentApproval) return null - - const config = currentApproval.config - const url = config.url as string | undefined - const command = config.command as string | undefined - const args = config.args as string[] | undefined - - return ( - - - -
-
- -
-
- MCP Server Approval - - A plugin wants to connect to an MCP server - -
-
-
- - -
-
-
- - Plugin - - - {currentApproval.pluginSource} - -
-
- - Server - - - {currentApproval.serverName} - -
- {command && ( -
- - Command - - - {command} - {args && args.length > 0 ? ` ${args.join(" ")}` : ""} - -
- )} - {url && ( -
- - URL - - - {url} - -
- )} -
- - {pendingApprovals.length > 1 && ( -

- +{pendingApprovals.length - 1} more approval - {pendingApprovals.length - 1 !== 1 ? "s" : ""} pending -

- )} -
-
- - - - - - -
-
- ) -} diff --git a/src/renderer/components/dialogs/settings-tabs/agents-beta-tab.tsx b/src/renderer/components/dialogs/settings-tabs/agents-beta-tab.tsx index df286431..619abf62 100644 --- a/src/renderer/components/dialogs/settings-tabs/agents-beta-tab.tsx +++ b/src/renderer/components/dialogs/settings-tabs/agents-beta-tab.tsx @@ -6,7 +6,6 @@ import { autoOfflineModeAtom, betaAutomationsEnabledAtom, betaKanbanEnabledAtom, - betaUpdatesEnabledAtom, enableTasksAtom, historyEnabledAtom, selectedOllamaModelAtom, @@ -55,7 +54,6 @@ export function AgentsBetaTab() { const [kanbanEnabled, setKanbanEnabled] = useAtom(betaKanbanEnabledAtom) const [automationsEnabled, setAutomationsEnabled] = useAtom(betaAutomationsEnabledAtom) const [enableTasks, setEnableTasks] = useAtom(enableTasksAtom) - const [betaUpdatesEnabled, setBetaUpdatesEnabled] = useAtom(betaUpdatesEnabledAtom) // Check subscription to gate automations behind paid plan const { data: subscription } = useQuery({ @@ -70,12 +68,9 @@ export function AgentsBetaTab() { const [updateVersion, setUpdateVersion] = useState(null) const [currentVersion, setCurrentVersion] = useState(null) - // Get current version on mount and sync update channel state + // Get current version on mount useEffect(() => { window.desktopApi?.getVersion().then(setCurrentVersion) - window.desktopApi?.getUpdateChannel().then((ch) => { - setBetaUpdatesEnabled(ch === "beta") - }) }, []) // Check for updates with force flag to bypass cache @@ -130,91 +125,93 @@ export function AgentsBetaTab() { {/* Beta Features Section */}
- {/* Rollback Toggle */} -
-
- - Rollback - - - Allow rolling back to previous messages and restoring files. - +
+ {/* Rollback Toggle */} +
+
+ + Rollback + + + Allow rolling back to previous messages and restoring files. + +
+
- -
- {/* Offline Mode Toggle */} -
-
- - Offline Mode - - - Enable offline mode UI and Ollama integration. - + {/* Offline Mode Toggle */} +
+
+ + Offline Mode + + + Enable offline mode UI and Ollama integration. + +
+
- -
- {/* Kanban Board Toggle */} -
-
- - Kanban Board - - - View workspaces as a Kanban board organized by status. - + {/* Kanban Board Toggle */} +
+
+ + Kanban Board + + + View workspaces as a Kanban board organized by status. + +
+
- -
- {/* Automations & Inbox Toggle */} -
-
- - Automations & Inbox - - - {canEnableAutomations - ? "Automate workflows with GitHub and Linear triggers, and manage inbox notifications." - : "Requires a paid plan. Upgrade to enable automations and inbox."} - + {/* Automations & Inbox Toggle */} +
+
+ + Automations & Inbox + + + {canEnableAutomations + ? "Automate workflows with GitHub and Linear triggers, and manage inbox notifications." + : "Requires a paid plan. Upgrade to enable automations and inbox."} + +
+ { + if (canEnableAutomations) { + setAutomationsEnabled(checked) + } + }} + disabled={!canEnableAutomations} + />
- { - if (canEnableAutomations) { - setAutomationsEnabled(checked) - } - }} - disabled={!canEnableAutomations} - /> -
- {/* Agent Tasks Toggle */} -
-
- - Agent Tasks - - - Enable Task instead of legacy Todo system. - + {/* Agent Tasks Toggle */} +
+
+ + Agent Tasks + + + Enable Task instead of legacy Todo system. + +
+
-
@@ -368,27 +365,7 @@ export function AgentsBetaTab() {
- {/* Early Access Toggle */} -
-
- - Early Access - - - Receive beta versions before they're released to everyone. Beta versions may be less stable. - -
- { - setBetaUpdatesEnabled(checked) - window.desktopApi?.setUpdateChannel(checked ? "beta" : "latest") - }} - /> -
- - {/* Version & Check */} -
+
diff --git a/src/renderer/components/dialogs/settings-tabs/agents-custom-agents-tab.tsx b/src/renderer/components/dialogs/settings-tabs/agents-custom-agents-tab.tsx index 4e2d9df1..a994ce15 100644 --- a/src/renderer/components/dialogs/settings-tabs/agents-custom-agents-tab.tsx +++ b/src/renderer/components/dialogs/settings-tabs/agents-custom-agents-tab.tsx @@ -1,18 +1,26 @@ -import { useEffect, useMemo, useRef, useState, useCallback } from "react" -import { useListKeyboardNav } from "./use-list-keyboard-nav" -import { useAtomValue } from "jotai" -import { selectedProjectAtom, settingsAgentsSidebarWidthAtom } from "../../../features/agents/atoms" +import { useState, useEffect } from "react" +import { ChevronRight } from "lucide-react" +import { motion, AnimatePresence } from "motion/react" import { trpc } from "../../../lib/trpc" import { cn } from "../../../lib/utils" -import { Plus } from "lucide-react" -import { CustomAgentIconFilled } from "../../ui/icons" -import { Input } from "../../ui/input" -import { Label } from "../../ui/label" -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../../ui/select" -import { Textarea } from "../../ui/textarea" -import { Button } from "../../ui/button" -import { ResizableSidebar } from "../../ui/resizable-sidebar" -import { toast } from "sonner" +import { AgentIcon } from "../../ui/icons" + +// Hook to detect narrow screen +function useIsNarrowScreen(): boolean { + const [isNarrow, setIsNarrow] = useState(false) + + useEffect(() => { + const checkWidth = () => { + setIsNarrow(window.innerWidth <= 768) + } + + checkWidth() + window.addEventListener("resize", checkWidth) + return () => window.removeEventListener("resize", checkWidth) + }, []) + + return isNarrow +} interface FileAgent { name: string @@ -25,562 +33,250 @@ interface FileAgent { path: string } -// --- Detail Panel (Editable) --- -function AgentDetail({ - agent, - onSave, - isSaving, -}: { - agent: FileAgent - onSave: (data: { description: string; prompt: string; model?: "sonnet" | "opus" | "haiku" | "inherit" }) => void - isSaving: boolean -}) { - const [description, setDescription] = useState(agent.description) - const [prompt, setPrompt] = useState(agent.prompt) - const [model, setModel] = useState(agent.model || "inherit") +export function AgentsCustomAgentsTab() { + const isNarrowScreen = useIsNarrowScreen() + const [expandedAgentName, setExpandedAgentName] = useState(null) - // Reset local state when agent changes - useEffect(() => { - setDescription(agent.description) - setPrompt(agent.prompt) - setModel(agent.model || "inherit") - }, [agent.name, agent.description, agent.prompt, agent.model]) + const { data: agents = [], isLoading } = trpc.agents.list.useQuery(undefined) - const hasChanges = - description !== agent.description || - prompt !== agent.prompt || - model !== (agent.model || "inherit") + const openInFinderMutation = trpc.external.openInFinder.useMutation() - const handleSave = useCallback(() => { - if ( - description !== agent.description || - prompt !== agent.prompt || - model !== (agent.model || "inherit") - ) { - onSave({ - description, - prompt, - model: model as FileAgent["model"], - }) - } - }, [description, prompt, model, agent.description, agent.prompt, agent.model, onSave]) + const userAgents = agents.filter((a) => a.source === "user") + const projectAgents = agents.filter((a) => a.source === "project") - const handleBlur = useCallback(() => { - if ( - description !== agent.description || - prompt !== agent.prompt || - model !== (agent.model || "inherit") - ) { - onSave({ - description, - prompt, - model: model as FileAgent["model"], - }) - } - }, [description, prompt, model, agent.description, agent.prompt, agent.model, onSave]) + const handleExpandAgent = (agentName: string) => { + setExpandedAgentName(expandedAgentName === agentName ? null : agentName) + } - const handleModelChange = useCallback((value: string) => { - setModel(value) - // Auto-save with new model value - if ( - description !== agent.description || - prompt !== agent.prompt || - value !== (agent.model || "inherit") - ) { - onSave({ - description, - prompt, - model: value as FileAgent["model"], - }) - } - }, [description, prompt, agent.description, agent.prompt, agent.model, onSave]) + const handleOpenInFinder = (path: string) => { + openInFinderMutation.mutate(path) + } return ( -
-
- {/* Header */} -
-
-

{agent.name}

-

{agent.path}

+
+ {/* Header - hidden on narrow screens */} + {!isNarrowScreen && ( +
+
+

Custom Agents

+ + Beta +
- {hasChanges && ( - - )} + + Documentation +
+ )} - {/* Description */} -
- - setDescription(e.target.value)} - onBlur={handleBlur} - placeholder="Agent description..." - /> -
- - {/* Model */} -
- - -
- - {/* Tools (read-only) */} - {agent.tools && agent.tools.length > 0 && ( -
- -
- {agent.tools.map((tool) => ( - - {tool} - - ))} -
+ {/* Agents List */} +
+ {isLoading ? ( +
+ Loading agents...
- )} - - {/* Disallowed Tools (read-only) */} - {agent.disallowedTools && agent.disallowedTools.length > 0 && ( -
- -
- {agent.disallowedTools.map((tool) => ( - - {tool} - - ))} -
+ ) : agents.length === 0 ? ( +
+ +

+ No custom agents found +

+

+ Add .md files to ~/.claude/agents/ +

- )} + ) : ( + <> + {/* User Agents */} + {userAgents.length > 0 && ( +
+
+ ~/.claude/agents/ +
+
+
+ {userAgents.map((agent) => ( + handleExpandAgent(agent.name)} + onOpenInFinder={() => handleOpenInFinder(agent.path)} + /> + ))} +
+
+
+ )} - {/* System Prompt */} -
- -