diff --git a/bun.lock b/bun.lock index db583128..dd8834c1 100644 --- a/bun.lock +++ b/bun.lock @@ -1,15 +1,15 @@ { "lockfileVersion": 1, + "configVersion": 0, "workspaces": { "": { "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.12", "@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", @@ -41,7 +41,6 @@ "@xterm/addon-web-links": "^0.12.0", "@xterm/addon-webgl": "^0.19.0", "ai": "^6.0.14", - "async-mutex": "^0.5.0", "better-sqlite3": "^11.8.1", "chokidar": "^5.0.0", "class-variance-authority": "^0.7.1", @@ -52,10 +51,8 @@ "electron-updater": "^6.7.3", "gray-matter": "^4.0.3", "jotai": "^2.11.1", - "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 +63,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,8 +87,8 @@ "@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": "33.4.5", @@ -119,7 +117,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.15", "", { "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-KN3jrHR5tIcAfLbplK5xHqNyUS3XnG8DMnImGeVEv64Z8NxfxIWtJTxtuBRWjyYzo36PEhK4r2SkX97A2iG+ng=="], "@apm-js-collab/code-transformer": ["@apm-js-collab/code-transformer@0.8.2", "", {}, "sha512-YRjJjNq5KFSjDUoqu5pFUWrrsvGOxl6c3bu+uMFc9HNNptZ2rNU/TI2nLw4jnhQNtka972Ee2m3uqbvDQtPeCA=="], @@ -159,6 +157,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 +335,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 +751,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=="], @@ -781,8 +781,6 @@ "@vue/shared": ["@vue/shared@3.5.27", "", {}, "sha512-dXr/3CgqXsJkZ0n9F3I4elY8wM9jMJpP3pvRG52r6m0tu/MsAFIe6JpXVGeNMd/D9F4hQynWT8Rfuj0bdm9kFQ=="], - "@welldone-software/why-did-you-render": ["@welldone-software/why-did-you-render@10.0.1", "", { "dependencies": { "lodash": "^4" }, "peerDependencies": { "react": "^19" } }, "sha512-tMgGkt30iVYeLMUKExNmtm019QgyjLtA7lwB0QAizYNEuihlCG2eoAWBBaz/bDeI7LeqAJ9msC6hY3vX+JB97g=="], - "@xmldom/xmldom": ["@xmldom/xmldom@0.8.11", "", {}, "sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw=="], "@xterm/addon-canvas": ["@xterm/addon-canvas@0.7.0", "", { "peerDependencies": { "@xterm/xterm": "^5.0.0" } }, "sha512-LF5LYcfvefJuJ7QotNRdRSPc9YASAVDeoT5uyXS/nZshZXjYplGXRECBGiznwvhNL2I8bq1Lf5MzRwstsYQ2Iw=="], @@ -855,8 +853,6 @@ "async-exit-hook": ["async-exit-hook@2.0.1", "", {}, "sha512-NW2cX8m1Q7KPA7a5M2ULQeZ2wR5qI5PAbw5L0UOMxdioVk9PMZ0h1TmyZEkPYrCvYjDlFICusOu1dlEKAAeXBw=="], - "async-mutex": ["async-mutex@0.5.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-1A94B18jkJ3DYq284ohPxoXbfTA5HsQ7/Mf4DEhcyLx3Bz27Rh59iScbB6EPiP+B+joue6YCxcMXSbFC1tZKwA=="], - "asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="], "at-least-node": ["at-least-node@1.0.0", "", {}, "sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg=="], @@ -1251,6 +1247,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 +1267,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 +1359,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=="], @@ -1401,7 +1403,7 @@ "inline-style-parser": ["inline-style-parser@0.2.7", "", {}, "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA=="], - "internmap": ["internmap@1.0.1", "", {}, "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw=="], + "internmap": ["internmap@2.0.3", "", {}, "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg=="], "ip-address": ["ip-address@10.1.0", "", {}, "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q=="], @@ -1477,8 +1479,6 @@ "json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], - "jsonc-parser": ["jsonc-parser@3.3.1", "", {}, "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ=="], - "jsonfile": ["jsonfile@6.2.0", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg=="], "katex": ["katex@0.16.27", "", { "dependencies": { "commander": "^8.3.0" }, "bin": { "katex": "cli.js" } }, "sha512-aeQoDkuRWSqQN6nSvVCEFvfXdqo1OQiCmmW1kc9xSdjutPv7BGO7pqY9sQRJpMOGrEdfDgF2TfRXe5eUAD2Waw=="], @@ -1681,8 +1681,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 +1831,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 +1881,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 +1897,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 +2035,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 +2411,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 +2419,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=="], @@ -2523,6 +2525,8 @@ "cytoscape-fcose/cose-base/layout-base": ["layout-base@2.0.1", "", {}, "sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg=="], + "d3-sankey/d3-array/internmap": ["internmap@1.0.1", "", {}, "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw=="], + "d3-sankey/d3-shape/d3-path": ["d3-path@1.0.9", "", {}, "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg=="], "dir-compare/minimatch/brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="], diff --git a/bun.lockb b/bun.lockb index 6601c57c..28431551 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..5005aa1a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "21st-desktop", - "version": "0.0.54", + "version": "0.0.48", "private": true, "description": "1Code - UI for parallel work with AI agents", "author": { @@ -19,9 +19,9 @@ "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", - "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", + "claude:download": "node scripts/download-claude-binary.mjs", + "claude:download:all": "node scripts/download-claude-binary.mjs --all", + "release": "rm -rf release && 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", "icon:generate": "node scripts/generate-icon.mjs", @@ -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.12", "@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,21 +69,18 @@ "@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", "gray-matter": "^4.0.3", "jotai": "^2.11.1", - "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 +91,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 +112,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 +243,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..7c8aa053 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1,5 +1,5 @@ import * as Sentry from "@sentry/electron/main" -import { app, BrowserWindow, Menu, session } from "electron" +import { app, BrowserWindow, Menu, net, session } from "electron" import { existsSync, readFileSync, readlinkSync, unlinkSync } from "fs" import { createServer } from "http" import { join } from "path" @@ -554,6 +554,124 @@ if (gotTheLock) { // Register protocol handler (must be after app is ready) initialRegistration = registerProtocol() + // Register local-file:// protocol for media file preview + // Supports Range requests for video/audio streaming + const mainSession = session.fromPartition("persist:main") + mainSession.protocol.handle("local-file", async (request) => { + try { + // Parse URL: local-file://localhost/C:/path/to/file.png + const url = new URL(request.url) + let filePath = decodeURIComponent(url.pathname) + + // Windows path handling: /C:/path -> C:/path or /D:/path -> D:/path + if (process.platform === "win32" && filePath.startsWith("/")) { + filePath = filePath.slice(1) + } + + // Convert forward slashes to backslashes on Windows + if (process.platform === "win32") { + filePath = filePath.replace(/\//g, "\\") + } + + // Verify file exists + if (!existsSync(filePath)) { + console.error("[local-file] File not found:", filePath) + return new Response("File not found", { status: 404 }) + } + + // Get file stats for size + const { statSync, createReadStream } = await import("fs") + const stats = statSync(filePath) + const fileSize = stats.size + + // Determine MIME type + const ext = filePath.split(".").pop()?.toLowerCase() || "" + const mimeTypes: Record = { + // Images + png: "image/png", + jpg: "image/jpeg", + jpeg: "image/jpeg", + gif: "image/gif", + webp: "image/webp", + svg: "image/svg+xml", + ico: "image/x-icon", + bmp: "image/bmp", + // Video + mp4: "video/mp4", + webm: "video/webm", + ogg: "video/ogg", + mov: "video/quicktime", + avi: "video/x-msvideo", + mkv: "video/x-matroska", + // Audio + mp3: "audio/mpeg", + wav: "audio/wav", + flac: "audio/flac", + aac: "audio/aac", + m4a: "audio/mp4", + // Documents + pdf: "application/pdf", + } + const contentType = mimeTypes[ext] || "application/octet-stream" + + // Check for Range header (needed for video/audio seeking) + const rangeHeader = request.headers.get("range") + + if (rangeHeader) { + // Parse Range header: bytes=start-end + const match = rangeHeader.match(/bytes=(\d*)-(\d*)/) + if (match) { + const start = match[1] ? parseInt(match[1], 10) : 0 + const end = match[2] ? parseInt(match[2], 10) : fileSize - 1 + const chunkSize = end - start + 1 + + // Create readable stream for the range + const stream = createReadStream(filePath, { start, end }) + const readable = new ReadableStream({ + start(controller) { + stream.on("data", (chunk: Buffer) => controller.enqueue(chunk)) + stream.on("end", () => controller.close()) + stream.on("error", (err) => controller.error(err)) + }, + }) + + return new Response(readable, { + status: 206, + headers: { + "Content-Type": contentType, + "Content-Length": String(chunkSize), + "Content-Range": `bytes ${start}-${end}/${fileSize}`, + "Accept-Ranges": "bytes", + }, + }) + } + } + + // No Range header - return full file + const stream = createReadStream(filePath) + const readable = new ReadableStream({ + start(controller) { + stream.on("data", (chunk: Buffer) => controller.enqueue(chunk)) + stream.on("end", () => controller.close()) + stream.on("error", (err) => controller.error(err)) + }, + }) + + return new Response(readable, { + status: 200, + headers: { + "Content-Type": contentType, + "Content-Length": String(fileSize), + "Accept-Ranges": "bytes", + }, + }) + } catch (error) { + console.error("[local-file] Protocol error:", error) + return new Response("Internal error", { status: 500 }) + } + }) + console.log("[Protocol] Registered local-file:// protocol handler") + // Handle deep link on macOS (app already running) app.on("open-url", (event, url) => { console.log("[Protocol] open-url event received:", url) 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.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/env.ts b/src/main/lib/claude/env.ts index e181e789..07ecee08 100644 --- a/src/main/lib/claude/env.ts +++ b/src/main/lib/claude/env.ts @@ -1,13 +1,15 @@ -import { app } from "electron" import { execSync } from "node:child_process" import fs from "node:fs" -import os from "node:os" import path from "node:path" +import os from "node:os" +import { app } from "electron" import { stripVTControlCharacters } from "node:util" import { + platform, + buildExtendedPath, getDefaultShell, isWindows, - platform + isMacOS, } from "../platform" // Cache the shell environment @@ -213,7 +215,6 @@ export function getClaudeShellEnvironment(): Record { export function buildClaudeEnv(options?: { ghToken?: string customEnv?: Record - enableTasks?: boolean }): Record { const env: Record = {} @@ -265,8 +266,6 @@ export function buildClaudeEnv(options?: { // 5. Mark as SDK entry env.CLAUDE_CODE_ENTRYPOINT = "sdk-ts" - // Enable/disable task management tools based on user preference (default: enabled) - env.CLAUDE_CODE_ENABLE_TASKS = options?.enableTasks !== false ? "true" : "false" return env } 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..880b5d86 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}` } @@ -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) @@ -561,7 +478,6 @@ export const claudeRouter = router({ images: z.array(imageAttachmentSchema).optional(), // Image attachments historyEnabled: z.boolean().optional(), offlineModeEnabled: z.boolean().optional(), // Whether offline mode (Ollama) is enabled in settings - enableTasks: z.boolean().optional(), // Enable task management tools (TodoWrite, Task agents) }), ) .subscription(({ input }) => { @@ -823,15 +739,16 @@ export const claudeRouter = router({ } // Build full environment for Claude SDK (includes HOME, PATH, etc.) - const claudeEnv = buildClaudeEnv({ - ...(finalCustomConfig && { - customEnv: { - ANTHROPIC_AUTH_TOKEN: finalCustomConfig.token, - ANTHROPIC_BASE_URL: finalCustomConfig.baseUrl, - }, - }), - enableTasks: input.enableTasks ?? true, - }) + const claudeEnv = buildClaudeEnv( + finalCustomConfig + ? { + customEnv: { + ANTHROPIC_AUTH_TOKEN: finalCustomConfig.token, + ANTHROPIC_BASE_URL: finalCustomConfig.baseUrl, + }, + } + : undefined, + ) // Debug logging in dev if (process.env.NODE_ENV !== "production") { @@ -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..607e7192 100644 --- a/src/main/lib/trpc/routers/external.ts +++ b/src/main/lib/trpc/routers/external.ts @@ -1,14 +1,9 @@ -import { clipboard, shell } from "electron"; -import { execFileSync, spawn } from "node:child_process"; +import { shell } from "electron"; +import { spawn } from "node:child_process"; import * as os from "node:os"; import * as path from "node:path"; import { z } from "zod"; import { publicProcedure, router } from "../index"; -import { - APP_META, - externalAppSchema, - type ExternalApp, -} from "../../../../shared/external-apps"; function expandTilde(filePath: string): string { if (filePath.startsWith("~/") || filePath === "~") { @@ -17,31 +12,6 @@ function expandTilde(filePath: string): string { return filePath; } -function spawnAsync(command: string, args: string[]): Promise { - return new Promise((resolve, reject) => { - const child = spawn(command, args, { - detached: true, - stdio: "ignore", - }); - child.unref(); - child.on("error", reject); - // Resolve immediately — we just need to launch the app - resolve(); - }); -} - -function openPathInApp(app: ExternalApp, targetPath: string): Promise { - const expandedPath = expandTilde(targetPath); - - if (app === "finder") { - shell.showItemInFolder(expandedPath); - return Promise.resolve(); - } - - const meta = APP_META[app]; - return spawnAsync("open", ["-a", meta.macAppName, expandedPath]); -} - /** * External router for shell operations (open in finder, open in editor, etc.) */ @@ -54,25 +24,6 @@ export const externalRouter = router({ return { success: true }; }), - openInApp: publicProcedure - .input( - z.object({ - path: z.string(), - app: externalAppSchema, - }), - ) - .mutation(async ({ input }) => { - await openPathInApp(input.app, input.path); - return { success: true }; - }), - - copyPath: publicProcedure - .input(z.string()) - .mutation(({ input: inputPath }) => { - clipboard.writeText(inputPath); - return { success: true }; - }), - openFileInEditor: publicProcedure .input( z.object({ @@ -81,24 +32,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 +59,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..1317901d 100644 --- a/src/main/lib/trpc/routers/files.ts +++ b/src/main/lib/trpc/routers/files.ts @@ -1,10 +1,10 @@ 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, posix } from "node:path" +import { spawn } from "node:child_process" +import { platform } from "node:os" 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([ @@ -65,6 +65,16 @@ interface FileEntry { type: "file" | "folder" } +// Content search result type +interface ContentSearchResult { + file: string + line: number + column: number + text: string + beforeContext?: string[] + afterContext?: string[] +} + // Cache for file and folder listings const fileListCache = new Map() const CACHE_TTL = 5000 // 5 seconds @@ -96,7 +106,8 @@ async function scanDirectory( if (entry.name.startsWith(".") && !entry.name.startsWith(".github") && !entry.name.startsWith(".vscode")) continue // Add the folder itself to results - entries.push({ path: relativePath, type: "folder" }) + // Normalize path separators to forward slashes for cross-platform consistency + entries.push({ path: relativePath.replace(/\\/g, "/"), type: "folder" }) // Recurse into subdirectory const subEntries = await scanDirectory(rootPath, fullPath, depth + 1, maxDepth) @@ -112,7 +123,8 @@ async function scanDirectory( if (!ALLOWED_LOCK_FILES.has(entry.name)) continue } - entries.push({ path: relativePath, type: "file" }) + // Normalize path separators to forward slashes for cross-platform consistency + entries.push({ path: relativePath.replace(/\\/g, "/"), type: "file" }) } } } catch (error) { @@ -178,7 +190,7 @@ function filterEntries( const bStarts = bName.startsWith(queryLower) if (aStarts && !bStarts) return -1 if (!aStarts && bStarts) return 1 - + // Priority 3: If both start with query, shorter name = better match if (aStarts && bStarts) { if (aName.length !== bName.length) { @@ -239,7 +251,7 @@ export const filesRouter = router({ // Get entry list (cached or fresh scan) const entries = await getEntryList(projectPath) - + // Debug: log folder count const folderCount = entries.filter(e => e.type === "folder").length const fileCount = entries.filter(e => e.type === "file").length @@ -266,123 +278,546 @@ export const filesRouter = router({ }), /** - * Read file contents from filesystem + * List contents of a specific directory (non-recursive, for lazy loading) */ - readFile: publicProcedure - .input(z.object({ filePath: z.string() })) + listDirectory: publicProcedure + .input( + z.object({ + projectPath: z.string(), + relativePath: z.string().default(""), + }) + ) .query(async ({ input }) => { - const { filePath } = input + const { projectPath, relativePath } = input + + if (!projectPath) { + return [] + } try { - const content = await readFile(filePath, "utf-8") - return content + const targetPath = relativePath ? join(projectPath, relativePath) : projectPath + + // Verify the path exists and is a directory + const pathStat = await stat(targetPath) + if (!pathStat.isDirectory()) { + console.warn(`[files] Not a directory: ${targetPath}`) + return [] + } + + const dirEntries = await readdir(targetPath, { withFileTypes: true }) + const results: Array<{ name: string; path: string; type: "file" | "folder" }> = [] + + for (const entry of dirEntries) { + // Use forward slash for cross-platform consistency + const entryRelativePath = relativePath ? `${relativePath}/${entry.name}` : entry.name + + if (entry.isDirectory()) { + // Skip ignored directories + if (IGNORED_DIRS.has(entry.name)) continue + // Skip hidden directories (except .github, .vscode, etc.) + if (entry.name.startsWith(".") && !entry.name.startsWith(".github") && !entry.name.startsWith(".vscode")) continue + + results.push({ + name: entry.name, + path: entryRelativePath, + type: "folder", + }) + } else if (entry.isFile()) { + // Skip ignored files + if (IGNORED_FILES.has(entry.name)) continue + + // Check extension + const ext = entry.name.includes(".") ? "." + entry.name.split(".").pop()?.toLowerCase() : "" + if (IGNORED_EXTENSIONS.has(ext)) { + // Allow specific lock files + if (!ALLOWED_LOCK_FILES.has(entry.name)) continue + } + + results.push({ + name: entry.name, + path: entryRelativePath, + type: "file", + }) + } + } + + // Sort: folders first, then alphabetically + results.sort((a, b) => { + if (a.type !== b.type) { + return a.type === "folder" ? -1 : 1 + } + return a.name.localeCompare(b.name) + }) + + return results } catch (error) { - console.error(`[files] Error reading file ${filePath}:`, error) - throw new Error(`Failed to read file: ${error instanceof Error ? error.message : "Unknown error"}`) + console.error(`[files] Error listing directory:`, error) + return [] } }), /** - * Read a text file with size/binary validation - * Returns structured result with error reasons + * Read file content (for preview) */ - readTextFile: publicProcedure - .input(z.object({ filePath: z.string() })) + readFile: publicProcedure + .input( + z.object({ + path: z.string(), + maxSize: z.number().default(1024 * 1024), // 1MB default max + }) + ) .query(async ({ input }) => { - const { filePath } = input - const MAX_SIZE = 2 * 1024 * 1024 // 2 MB + const { path: filePath, maxSize } = input try { + // Check file exists and get size const fileStat = await stat(filePath) - if (fileStat.size > MAX_SIZE) { - return { ok: false as const, reason: "too-large" as const, byteLength: fileStat.size } + if (!fileStat.isFile()) { + throw new Error("Not a file") } - 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 } + if (fileStat.size > maxSize) { + throw new Error(`File too large (${Math.round(fileStat.size / 1024)}KB > ${Math.round(maxSize / 1024)}KB limit)`) } - const content = buffer.toString("utf-8") - return { ok: true as const, content, byteLength: fileStat.size } + // Read file content + const content = await readFile(filePath, "utf-8") + return content } 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}`) + console.error(`[files] Error reading file:`, error) + throw error } }), /** - * Read a binary file as base64 (for images) + * Read binary file as base64 (for images, PDFs, etc.) */ readBinaryFile: publicProcedure - .input(z.object({ filePath: z.string() })) + .input( + z.object({ + path: z.string(), + maxSize: z.number().default(10 * 1024 * 1024), // 10MB default max for binary files + }) + ) .query(async ({ input }) => { - const { filePath } = input - const MAX_SIZE = 20 * 1024 * 1024 // 20 MB + const { path: filePath, maxSize } = input try { + // Check file exists and get size const fileStat = await stat(filePath) - if (fileStat.size > MAX_SIZE) { - return { ok: false as const, reason: "too-large" as const, byteLength: fileStat.size } + if (!fileStat.isFile()) { + throw new Error("Not a file") + } + + if (fileStat.size > maxSize) { + throw new Error(`File too large (${Math.round(fileStat.size / 1024)}KB > ${Math.round(maxSize / 1024)}KB limit)`) } + // Read file as buffer and convert to base64 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 base64 = buffer.toString("base64") + + // Determine MIME type from extension + const ext = filePath.split(".").pop()?.toLowerCase() || "" + const mimeTypes: Record = { + // Images + png: "image/png", + jpg: "image/jpeg", + jpeg: "image/jpeg", + gif: "image/gif", + webp: "image/webp", + svg: "image/svg+xml", + ico: "image/x-icon", + bmp: "image/bmp", + avif: "image/avif", + // Documents + pdf: "application/pdf", } - const mimeType = mimeMap[ext] || "application/octet-stream" + const mimeType = mimeTypes[ext] || "application/octet-stream" return { - ok: true as const, - data: buffer.toString("base64"), + base64, mimeType, - byteLength: fileStat.size, + size: 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}`) + console.error(`[files] Error reading binary file:`, error) + throw error } }), /** - * Watch for file changes in a project directory - * Emits events when files are modified + * Search for files matching a filename pattern (returns all matching paths for auto-expand) */ - 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 }) + searchFiles: publicProcedure + .input( + z.object({ + projectPath: z.string(), + query: z.string(), + limit: z.number().min(1).max(500).default(100), + }) + ) + .query(async ({ input }) => { + const { projectPath, query, limit } = input + + if (!projectPath) { + return { results: [], parentPaths: [] } + } + + try { + const entries = await getEntryList(projectPath) + + // If no query, return all folder paths for expand all functionality + if (!query) { + const allFolders = entries.filter((entry) => entry.type === "folder") + return { + results: [], + parentPaths: allFolders.map((f) => f.path), } + } + + const queryLower = query.toLowerCase() + + // Find matching files + const matchingFiles = entries.filter((entry) => { + const name = basename(entry.path).toLowerCase() + return name.includes(queryLower) }) - return () => { - watcher.close() + // Sort by relevance + matchingFiles.sort((a, b) => { + const aName = basename(a.path).toLowerCase() + const bName = basename(b.path).toLowerCase() + + // Exact match first + if (aName === queryLower && bName !== queryLower) return -1 + if (bName === queryLower && aName !== queryLower) return 1 + + // Starts with query + if (aName.startsWith(queryLower) && !bName.startsWith(queryLower)) return -1 + if (bName.startsWith(queryLower) && !aName.startsWith(queryLower)) return 1 + + // Shorter name = better match + return aName.length - bName.length + }) + + const limited = matchingFiles.slice(0, limit) + + // Collect all parent directories that need to be expanded + // Use posix.dirname since paths are normalized to forward slashes + const parentPaths = new Set() + for (const entry of limited) { + let currentPath = posix.dirname(entry.path) + while (currentPath && currentPath !== ".") { + parentPaths.add(currentPath) + currentPath = posix.dirname(currentPath) + } } + + return { + results: limited.map((entry) => ({ + path: entry.path, + type: entry.type, + name: basename(entry.path), + })), + parentPaths: Array.from(parentPaths), + } + } catch (error) { + console.error(`[files] Error searching files:`, error) + return { results: [], parentPaths: [] } + } + }), + + /** + * Search file contents using ripgrep (with grep/findstr fallback) + */ + searchContent: publicProcedure + .input( + z.object({ + projectPath: z.string(), + query: z.string(), + filePattern: z.string().optional(), + caseSensitive: z.boolean().default(false), + limit: z.number().min(1).max(500).default(100), }) + ) + .mutation(async ({ input }) => { + const { projectPath, query, filePattern, caseSensitive, limit } = input + + if (!projectPath || !query) { + return { results: [], tool: "none" } + } + + const isWindows = platform() === "win32" + + return new Promise<{ results: ContentSearchResult[]; tool: string }>((resolve) => { + // Try ripgrep first, then fall back to grep/findstr + const rgPaths = isWindows + ? ["rg", "C:\\Program Files\\ripgrep\\rg.exe", "C:\\ProgramData\\scoop\\shims\\rg.exe"] + : ["rg", "/opt/homebrew/bin/rg", "/usr/local/bin/rg", "/usr/bin/rg"] + + let rgPathIndex = 0 + + const tryRipgrep = () => { + if (rgPathIndex >= rgPaths.length) { + // No ripgrep found, try fallback + tryFallback() + return + } + + const rgPath = rgPaths[rgPathIndex] + rgPathIndex++ + + console.log(`[files] Trying ripgrep at: ${rgPath} for content search: "${query}" in ${projectPath}`) + + const args = [ + "--json", + "--line-number", + "--column", + "-C", "2", // 2 lines of context + ] + + // Add case sensitivity flag + if (!caseSensitive) { + args.push("-i") + } + + // Add file pattern if provided + if (filePattern) { + args.push("-g", filePattern) + } + + // Add ignored directories + for (const dir of IGNORED_DIRS) { + args.push("-g", `!${dir}/**`) + } + + args.push("--", query, projectPath) + + const rg = spawn(rgPath, args) + let output = "" + + rg.stdout.on("data", (data) => { + output += data.toString() + }) + + rg.on("close", (code) => { + if (code === null || (code !== 0 && code !== 1)) { + // ripgrep error, try next path + tryRipgrep() + return + } + + // Parse JSON output + const lines = output.split("\n").filter(Boolean) + const matchMap = new Map() + + for (const line of lines) { + try { + const json = JSON.parse(line) + if (json.type === "match") { + const data = json.data + const file = relative(projectPath, data.path.text).replace(/\\/g, "/") + const lineNum = data.line_number + + // Skip ignored directories + if (IGNORED_DIRS.has(file.split("/")[0])) continue + + const key = `${file}:${lineNum}` + if (!matchMap.has(key)) { + matchMap.set(key, { + file, + line: lineNum, + column: data.submatches?.[0]?.start || 0, + text: data.lines.text.trim(), + beforeContext: [], + afterContext: [], + }) + } + } + } catch { + // Skip non-JSON lines + } + } + + const results = Array.from(matchMap.values()).slice(0, limit) + resolve({ + results, + tool: "ripgrep", + }) + }) + + rg.on("error", () => { + // This ripgrep path not found, try next path + tryRipgrep() + }) + } + + const tryFallback = () => { + if (isWindows) { + tryFindstr() + } else { + tryGrep() + } + } + + const tryFindstr = () => { + console.log(`[files] Trying findstr for content search: "${query}" in ${projectPath}`) + + // Windows findstr command + // /S = search subdirectories + // /N = print line numbers + // /I = case insensitive (if needed) + // /P = skip binary files + const args = ["/S", "/N", "/P"] + + if (!caseSensitive) { + args.push("/I") + } + + // Add search pattern + args.push(query) + + // Add file pattern + if (filePattern) { + args.push(filePattern) + } else { + args.push("*.*") + } + + const findstr = spawn("findstr", args, { cwd: projectPath }) + const results: ContentSearchResult[] = [] + let output = "" + + findstr.stdout.on("data", (data) => { + output += data.toString() + }) + + findstr.on("close", (code) => { + // findstr returns 1 when no matches found, 0 on success, 2 on error + if (code === 2) { + console.error(`[files] findstr failed`) + resolve({ results: [], tool: "findstr-failed" }) + return + } + + // Parse findstr output: filename:line:content + const lines = output.split("\r\n").filter(Boolean) + + for (const line of lines) { + if (results.length >= limit) break + + const match = line.match(/^(.+?):(\d+):(.*)$/) + if (match) { + let filePath = match[1] + // Normalize path separators to forward slashes + filePath = filePath.replace(/\\/g, "/") + // Skip ignored directories + if (IGNORED_DIRS.has(filePath.split("/")[0])) continue + if (filePath.includes("/node_modules/") || filePath.includes("/.git/")) continue + + results.push({ + file: filePath, + line: parseInt(match[2], 10), + column: 0, + text: match[3].trim(), + }) + } + } + + resolve({ results, tool: "findstr" }) + }) + + findstr.on("error", (err) => { + console.error(`[files] findstr spawn error:`, err) + resolve({ results: [], tool: "findstr-error" }) + }) + } + + const tryGrep = () => { + console.log(`[files] Trying grep for content search: "${query}" in ${projectPath}`) + + const grepPath = "/usr/bin/grep" + const args = ["-r", "-n", "-H"] + + if (filePattern) { + args.push("--include=" + filePattern) + } + + if (!caseSensitive) { + args.push("-i") + } + + for (const dir of IGNORED_DIRS) { + args.push(`--exclude-dir=${dir}`) + } + + args.push("--", query, projectPath) + + const grep = spawn(grepPath, args) + const results: ContentSearchResult[] = [] + let output = "" + + grep.stdout.on("data", (data) => { + output += data.toString() + }) + + grep.on("close", (code) => { + if (code === null || code > 1) { + resolve({ results: [], tool: "grep-failed" }) + return + } + + const lines = output.split("\n").filter(Boolean) + for (const line of lines) { + if (results.length >= limit) break + + const match = line.match(/^(.+?):(\d+):(.*)$/) + if (match) { + results.push({ + file: relative(projectPath, match[1]).replace(/\\/g, "/"), + line: parseInt(match[2], 10), + column: 0, + text: match[3].trim(), + }) + } + } + + resolve({ results, tool: "grep" }) + }) + + grep.on("error", () => { + resolve({ results: [], tool: "grep-error" }) + }) + } + + tryRipgrep() + }) + }), + + /** + * Write file content (for editing files) + */ + writeFile: publicProcedure + .input( + z.object({ + path: z.string(), + content: z.string(), + }) + ) + .mutation(async ({ input }) => { + const { path: filePath, content } = input + + try { + await writeFile(filePath, content, "utf-8") + console.log(`[files] Wrote file: ${filePath} (${content.length} bytes)`) + return { success: true } + } catch (error) { + console.error(`[files] Error writing file:`, error) + throw error + } }), /** 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/lib/vscode-theme-scanner.ts b/src/main/lib/vscode-theme-scanner.ts index f2468e8f..b06d877e 100644 --- a/src/main/lib/vscode-theme-scanner.ts +++ b/src/main/lib/vscode-theme-scanner.ts @@ -9,7 +9,6 @@ import * as fs from "fs/promises" import * as path from "path" import * as os from "os" import { ipcMain } from "electron" -import { parse as parseJsonc } from "jsonc-parser" /** * Source editor type @@ -161,8 +160,12 @@ async function scanExtensionsDir(extensionsDir: string, source: EditorSource): P let actualThemeName: string | undefined try { const themeContent = await fs.readFile(themePath, "utf-8") - // Use proper JSONC parser (handles comments and trailing commas) - const themeData = parseJsonc(themeContent) + // Handle JSONC (JSON with comments and trailing commas) + const jsonContent = themeContent + .replace(/\/\/.*$/gm, "") // Remove single-line comments + .replace(/\/\*[\s\S]*?\*\//g, "") // Remove multi-line comments + .replace(/,(\s*[}\]])/g, "$1") // Remove trailing commas + const themeData = JSON.parse(jsonContent) actualThemeName = themeData.name } catch { continue @@ -232,8 +235,13 @@ export async function scanVSCodeThemes(): Promise { export async function loadThemeFromPath(themePath: string): Promise { const content = await fs.readFile(themePath, "utf-8") - // Use proper JSONC parser (handles comments and trailing commas) - const theme = parseJsonc(content) + // Handle JSONC (JSON with comments and trailing commas) - VS Code theme files use this format + const jsonContent = content + .replace(/\/\/.*$/gm, "") // Remove single-line comments + .replace(/\/\*[\s\S]*?\*\//g, "") // Remove multi-line comments + .replace(/,(\s*[}\]])/g, "$1") // Remove trailing commas + + const theme = JSON.parse(jsonContent) // Generate unique ID based on path and timestamp const id = `imported-${Date.now()}-${Math.random().toString(36).slice(2, 8)}` 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..236b4003 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) => { @@ -266,11 +264,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 diff --git a/src/renderer/assets/app-icons/appcode.svg b/src/renderer/assets/app-icons/appcode.svg deleted file mode 100644 index adc28914..00000000 --- a/src/renderer/assets/app-icons/appcode.svg +++ /dev/null @@ -1,32 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/renderer/assets/app-icons/clion.svg b/src/renderer/assets/app-icons/clion.svg deleted file mode 100644 index c14657bc..00000000 --- a/src/renderer/assets/app-icons/clion.svg +++ /dev/null @@ -1,19 +0,0 @@ - - - - - - - - - - - - - - - - - - - diff --git a/src/renderer/assets/app-icons/cursor.svg b/src/renderer/assets/app-icons/cursor.svg deleted file mode 100644 index c074bf27..00000000 --- a/src/renderer/assets/app-icons/cursor.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/src/renderer/assets/app-icons/datagrip.svg b/src/renderer/assets/app-icons/datagrip.svg deleted file mode 100644 index 4af9320a..00000000 --- a/src/renderer/assets/app-icons/datagrip.svg +++ /dev/null @@ -1,19 +0,0 @@ - - - - - - - - - - - - - - - - - - - diff --git a/src/renderer/assets/app-icons/finder.png b/src/renderer/assets/app-icons/finder.png deleted file mode 100644 index 2cff579e..00000000 Binary files a/src/renderer/assets/app-icons/finder.png and /dev/null differ diff --git a/src/renderer/assets/app-icons/fleet.svg b/src/renderer/assets/app-icons/fleet.svg deleted file mode 100644 index 3ab51b6e..00000000 --- a/src/renderer/assets/app-icons/fleet.svg +++ /dev/null @@ -1,50 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/renderer/assets/app-icons/ghostty.svg b/src/renderer/assets/app-icons/ghostty.svg deleted file mode 100644 index a4b0637b..00000000 --- a/src/renderer/assets/app-icons/ghostty.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/src/renderer/assets/app-icons/goland.svg b/src/renderer/assets/app-icons/goland.svg deleted file mode 100644 index c3ef8653..00000000 --- a/src/renderer/assets/app-icons/goland.svg +++ /dev/null @@ -1,20 +0,0 @@ - - - - - - - - - - - - - - - - - - - - diff --git a/src/renderer/assets/app-icons/intellij.svg b/src/renderer/assets/app-icons/intellij.svg deleted file mode 100644 index 46cc4abe..00000000 --- a/src/renderer/assets/app-icons/intellij.svg +++ /dev/null @@ -1,19 +0,0 @@ - - - - - - - - - - - - - - - - - - - diff --git a/src/renderer/assets/app-icons/iterm.png b/src/renderer/assets/app-icons/iterm.png deleted file mode 100644 index 19cfd018..00000000 Binary files a/src/renderer/assets/app-icons/iterm.png and /dev/null differ diff --git a/src/renderer/assets/app-icons/jetbrains.svg b/src/renderer/assets/app-icons/jetbrains.svg deleted file mode 100644 index 367c4ffa..00000000 --- a/src/renderer/assets/app-icons/jetbrains.svg +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - - - - - - - diff --git a/src/renderer/assets/app-icons/phpstorm.svg b/src/renderer/assets/app-icons/phpstorm.svg deleted file mode 100644 index 9b1e48d8..00000000 --- a/src/renderer/assets/app-icons/phpstorm.svg +++ /dev/null @@ -1,19 +0,0 @@ - - - - - - - - - - - - - - - - - - - diff --git a/src/renderer/assets/app-icons/pycharm.svg b/src/renderer/assets/app-icons/pycharm.svg deleted file mode 100644 index 0092bf3c..00000000 --- a/src/renderer/assets/app-icons/pycharm.svg +++ /dev/null @@ -1,19 +0,0 @@ - - - - - - - - - - - - - - - - - - - diff --git a/src/renderer/assets/app-icons/rider.svg b/src/renderer/assets/app-icons/rider.svg deleted file mode 100644 index 97060ecb..00000000 --- a/src/renderer/assets/app-icons/rider.svg +++ /dev/null @@ -1,19 +0,0 @@ - - - - - - - - - - - - - - - - - - - diff --git a/src/renderer/assets/app-icons/rubymine.svg b/src/renderer/assets/app-icons/rubymine.svg deleted file mode 100644 index dcd769ec..00000000 --- a/src/renderer/assets/app-icons/rubymine.svg +++ /dev/null @@ -1,19 +0,0 @@ - - - - - - - - - - - - - - - - - - - diff --git a/src/renderer/assets/app-icons/rustrover.svg b/src/renderer/assets/app-icons/rustrover.svg deleted file mode 100644 index 301a1ee6..00000000 --- a/src/renderer/assets/app-icons/rustrover.svg +++ /dev/null @@ -1,19 +0,0 @@ - - - - - - - - - - - - - - - - - - - diff --git a/src/renderer/assets/app-icons/sublime.svg b/src/renderer/assets/app-icons/sublime.svg deleted file mode 100644 index e70b2473..00000000 --- a/src/renderer/assets/app-icons/sublime.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/src/renderer/assets/app-icons/terminal.png b/src/renderer/assets/app-icons/terminal.png deleted file mode 100644 index 5cd898dd..00000000 Binary files a/src/renderer/assets/app-icons/terminal.png and /dev/null differ diff --git a/src/renderer/assets/app-icons/trae.svg b/src/renderer/assets/app-icons/trae.svg deleted file mode 100644 index dda49308..00000000 --- a/src/renderer/assets/app-icons/trae.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/src/renderer/assets/app-icons/vscode-insiders.svg b/src/renderer/assets/app-icons/vscode-insiders.svg deleted file mode 100644 index 5067415c..00000000 --- a/src/renderer/assets/app-icons/vscode-insiders.svg +++ /dev/null @@ -1,56 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/src/renderer/assets/app-icons/vscode.svg b/src/renderer/assets/app-icons/vscode.svg deleted file mode 100644 index 0557c2cb..00000000 --- a/src/renderer/assets/app-icons/vscode.svg +++ /dev/null @@ -1,41 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/renderer/assets/app-icons/warp.png b/src/renderer/assets/app-icons/warp.png deleted file mode 100644 index 997c7a15..00000000 Binary files a/src/renderer/assets/app-icons/warp.png and /dev/null differ diff --git a/src/renderer/assets/app-icons/webstorm.svg b/src/renderer/assets/app-icons/webstorm.svg deleted file mode 100644 index 4c0c8044..00000000 --- a/src/renderer/assets/app-icons/webstorm.svg +++ /dev/null @@ -1,19 +0,0 @@ - - - - - - - - - - - - - - - - - - - diff --git a/src/renderer/assets/app-icons/windsurf.svg b/src/renderer/assets/app-icons/windsurf.svg deleted file mode 100644 index 7d60a073..00000000 --- a/src/renderer/assets/app-icons/windsurf.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/src/renderer/assets/app-icons/xcode.svg b/src/renderer/assets/app-icons/xcode.svg deleted file mode 100644 index cf5ae82e..00000000 --- a/src/renderer/assets/app-icons/xcode.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/src/renderer/assets/app-icons/zed.png b/src/renderer/assets/app-icons/zed.png deleted file mode 100644 index 08b6d8af..00000000 Binary files a/src/renderer/assets/app-icons/zed.png and /dev/null differ 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..28166541 100644 --- a/src/renderer/components/dialogs/settings-tabs/agents-beta-tab.tsx +++ b/src/renderer/components/dialogs/settings-tabs/agents-beta-tab.tsx @@ -1,22 +1,14 @@ -import { useAtom } from "jotai" -import { Check, Copy, RefreshCw } from "lucide-react" -import { useEffect, useState } from "react" -import { useQuery } from "@tanstack/react-query" +import { useAtom, useAtomValue } from "jotai" +import { useState, useEffect } from "react" import { - autoOfflineModeAtom, - betaAutomationsEnabledAtom, - betaKanbanEnabledAtom, - betaUpdatesEnabledAtom, - enableTasksAtom, historyEnabledAtom, - selectedOllamaModelAtom, showOfflineModeFeaturesAtom, + autoOfflineModeAtom, + selectedOllamaModelAtom, + betaKanbanEnabledAtom, } from "../../../lib/atoms" import { trpc } from "../../../lib/trpc" -import { remoteTrpc } from "../../../lib/remote-trpc" -import { cn } from "../../../lib/utils" -import { Button } from "../../ui/button" -import { ExternalLinkIcon } from "../../ui/icons" +import { Switch } from "../../ui/switch" import { Select, SelectContent, @@ -24,7 +16,10 @@ import { SelectTrigger, SelectValue, } from "../../ui/select" -import { Switch } from "../../ui/switch" +import { ExternalLinkIcon } from "../../ui/icons" +import { Copy, Check, RefreshCw } from "lucide-react" +import { Button } from "../../ui/button" +import { cn } from "../../../lib/utils" // Hook to detect narrow screen function useIsNarrowScreen(): boolean { @@ -53,29 +48,14 @@ export function AgentsBetaTab() { const [autoOffline, setAutoOffline] = useAtom(autoOfflineModeAtom) const [selectedOllamaModel, setSelectedOllamaModel] = useAtom(selectedOllamaModelAtom) 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({ - queryKey: ["agents", "subscription"], - queryFn: () => remoteTrpc.agents.getAgentsSubscription.query(), - }) - const isPaidPlan = subscription?.type !== "free" && !!subscription?.type - const isDev = process.env.NODE_ENV === "development" - const canEnableAutomations = isPaidPlan || isDev const [copied, setCopied] = useState(false) const [updateStatus, setUpdateStatus] = useState<"idle" | "checking" | "available" | "not-available" | "error">("idle") 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 +110,54 @@ export function AgentsBetaTab() { {/* Beta Features Section */}
- {/* Rollback Toggle */} -
-
- - Rollback - - - Allow rolling back to previous messages and restoring files. - -
- -
- - {/* 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. - +
+ {/* Rollback Toggle */} +
+
+ + Rollback + + + Allow rolling back to previous messages and restoring files. + +
+
- -
- {/* 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."} - + {/* Offline Mode Toggle */} +
+
+ + Offline Mode + + + Enable offline mode UI and Ollama integration. + +
+
- { - if (canEnableAutomations) { - setAutomationsEnabled(checked) - } - }} - disabled={!canEnableAutomations} - /> -
- {/* Agent Tasks Toggle */} -
-
- - Agent Tasks - - - Enable Task instead of legacy Todo system. - + {/* Kanban Board Toggle */} +
+
+ + Kanban Board + + + View workspaces as a Kanban board organized by status. + +
+
-
@@ -368,27 +311,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 */} -
- -