@pixelbyte-software/pixcode 1.30.2 → 1.31.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (202) hide show
  1. package/LICENSE +718 -718
  2. package/README.de.md +248 -248
  3. package/README.ja.md +240 -240
  4. package/README.ko.md +240 -240
  5. package/README.md +295 -285
  6. package/README.ru.md +248 -248
  7. package/README.tr.md +250 -250
  8. package/README.zh-CN.md +240 -240
  9. package/dist/api-docs.html +879 -879
  10. package/dist/assets/index-BRRJ47XQ.css +32 -0
  11. package/dist/assets/index-EQohwyiC.js +837 -0
  12. package/dist/clear-cache.html +85 -85
  13. package/dist/convert-icons.md +52 -52
  14. package/dist/favicon.png +0 -0
  15. package/dist/favicon.svg +7 -8
  16. package/dist/generate-icons.js +48 -48
  17. package/dist/icons/codex-white.svg +3 -3
  18. package/dist/icons/codex.svg +3 -3
  19. package/dist/icons/cursor-white.svg +11 -11
  20. package/dist/icons/icon-128x128.png +0 -0
  21. package/dist/icons/icon-128x128.svg +9 -12
  22. package/dist/icons/icon-144x144.png +0 -0
  23. package/dist/icons/icon-144x144.svg +9 -12
  24. package/dist/icons/icon-152x152.png +0 -0
  25. package/dist/icons/icon-152x152.svg +9 -12
  26. package/dist/icons/icon-192x192.png +0 -0
  27. package/dist/icons/icon-192x192.svg +9 -12
  28. package/dist/icons/icon-384x384.png +0 -0
  29. package/dist/icons/icon-384x384.svg +9 -12
  30. package/dist/icons/icon-512x512.png +0 -0
  31. package/dist/icons/icon-512x512.svg +9 -12
  32. package/dist/icons/icon-72x72.png +0 -0
  33. package/dist/icons/icon-72x72.svg +9 -12
  34. package/dist/icons/icon-96x96.png +0 -0
  35. package/dist/icons/icon-96x96.svg +9 -12
  36. package/dist/icons/icon-template.svg +9 -12
  37. package/dist/icons/qwen-ai-icon.png +0 -0
  38. package/dist/index.html +59 -49
  39. package/dist/logo.png +0 -0
  40. package/dist/logo.svg +11 -16
  41. package/dist/manifest.json +60 -60
  42. package/dist/sw.js +124 -124
  43. package/dist-server/server/cli.js +100 -97
  44. package/dist-server/server/cli.js.map +1 -1
  45. package/dist-server/server/daemon/manager.js +33 -33
  46. package/dist-server/server/daemon-manager.js +62 -62
  47. package/dist-server/server/database/db.js +114 -22
  48. package/dist-server/server/database/db.js.map +1 -1
  49. package/dist-server/server/database/schema.js +122 -89
  50. package/dist-server/server/database/schema.js.map +1 -1
  51. package/dist-server/server/gemini-cli.js +6 -1
  52. package/dist-server/server/gemini-cli.js.map +1 -1
  53. package/dist-server/server/index.js +234 -64
  54. package/dist-server/server/index.js.map +1 -1
  55. package/dist-server/server/modules/providers/list/claude/claude-auth.provider.js +29 -2
  56. package/dist-server/server/modules/providers/list/claude/claude-auth.provider.js.map +1 -1
  57. package/dist-server/server/modules/providers/list/codex/codex-auth.provider.js +22 -2
  58. package/dist-server/server/modules/providers/list/codex/codex-auth.provider.js.map +1 -1
  59. package/dist-server/server/modules/providers/list/cursor/cursor-auth.provider.js +2 -2
  60. package/dist-server/server/modules/providers/list/cursor/cursor-auth.provider.js.map +1 -1
  61. package/dist-server/server/modules/providers/list/gemini/gemini-auth.provider.js +14 -2
  62. package/dist-server/server/modules/providers/list/gemini/gemini-auth.provider.js.map +1 -1
  63. package/dist-server/server/modules/providers/list/qwen/qwen-auth.provider.js +132 -0
  64. package/dist-server/server/modules/providers/list/qwen/qwen-auth.provider.js.map +1 -0
  65. package/dist-server/server/modules/providers/list/qwen/qwen-mcp.provider.js +87 -0
  66. package/dist-server/server/modules/providers/list/qwen/qwen-mcp.provider.js.map +1 -0
  67. package/dist-server/server/modules/providers/list/qwen/qwen-sessions.provider.js +201 -0
  68. package/dist-server/server/modules/providers/list/qwen/qwen-sessions.provider.js.map +1 -0
  69. package/dist-server/server/modules/providers/list/qwen/qwen.provider.js +19 -0
  70. package/dist-server/server/modules/providers/list/qwen/qwen.provider.js.map +1 -0
  71. package/dist-server/server/modules/providers/provider.registry.js +2 -0
  72. package/dist-server/server/modules/providers/provider.registry.js.map +1 -1
  73. package/dist-server/server/modules/providers/provider.routes.js +310 -1
  74. package/dist-server/server/modules/providers/provider.routes.js.map +1 -1
  75. package/dist-server/server/projects.js +197 -6
  76. package/dist-server/server/projects.js.map +1 -1
  77. package/dist-server/server/qwen-code-cli.js +350 -0
  78. package/dist-server/server/qwen-code-cli.js.map +1 -0
  79. package/dist-server/server/qwen-response-handler.js +70 -0
  80. package/dist-server/server/qwen-response-handler.js.map +1 -0
  81. package/dist-server/server/routes/commands.js +25 -25
  82. package/dist-server/server/routes/git.js +17 -17
  83. package/dist-server/server/routes/network.js +116 -0
  84. package/dist-server/server/routes/network.js.map +1 -0
  85. package/dist-server/server/routes/projects.js +43 -0
  86. package/dist-server/server/routes/projects.js.map +1 -1
  87. package/dist-server/server/routes/qwen.js +23 -0
  88. package/dist-server/server/routes/qwen.js.map +1 -0
  89. package/dist-server/server/routes/taskmaster.js +419 -419
  90. package/dist-server/server/routes/telegram.js +119 -0
  91. package/dist-server/server/routes/telegram.js.map +1 -0
  92. package/dist-server/server/services/external-access.js +228 -0
  93. package/dist-server/server/services/external-access.js.map +1 -0
  94. package/dist-server/server/services/install-jobs.js +394 -0
  95. package/dist-server/server/services/install-jobs.js.map +1 -0
  96. package/dist-server/server/services/notification-orchestrator.js +19 -5
  97. package/dist-server/server/services/notification-orchestrator.js.map +1 -1
  98. package/dist-server/server/services/provider-credentials.js +154 -0
  99. package/dist-server/server/services/provider-credentials.js.map +1 -0
  100. package/dist-server/server/services/provider-models.js +218 -0
  101. package/dist-server/server/services/provider-models.js.map +1 -0
  102. package/dist-server/server/services/telegram/bot.js +259 -0
  103. package/dist-server/server/services/telegram/bot.js.map +1 -0
  104. package/dist-server/server/services/telegram/translations.js +160 -0
  105. package/dist-server/server/services/telegram/translations.js.map +1 -0
  106. package/dist-server/server/utils/port-access.js +196 -0
  107. package/dist-server/server/utils/port-access.js.map +1 -0
  108. package/dist-server/shared/modelConstants.js +18 -0
  109. package/dist-server/shared/modelConstants.js.map +1 -1
  110. package/package.json +177 -168
  111. package/scripts/fix-node-pty.js +67 -67
  112. package/server/claude-sdk.js +834 -834
  113. package/server/cli.js +940 -937
  114. package/server/constants/config.js +4 -4
  115. package/server/cursor-cli.js +342 -342
  116. package/server/daemon/manager.js +564 -564
  117. package/server/daemon-manager.js +920 -920
  118. package/server/database/db.js +696 -593
  119. package/server/database/schema.js +138 -102
  120. package/server/gemini-cli.js +475 -469
  121. package/server/gemini-response-handler.js +79 -79
  122. package/server/index.js +2730 -2556
  123. package/server/load-env.js +34 -34
  124. package/server/middleware/auth.js +132 -132
  125. package/server/modules/providers/list/claude/claude-auth.provider.ts +145 -123
  126. package/server/modules/providers/list/claude/claude-mcp.provider.ts +135 -135
  127. package/server/modules/providers/list/claude/claude-sessions.provider.ts +306 -306
  128. package/server/modules/providers/list/claude/claude.provider.ts +15 -15
  129. package/server/modules/providers/list/codex/codex-auth.provider.ts +115 -100
  130. package/server/modules/providers/list/codex/codex-mcp.provider.ts +135 -135
  131. package/server/modules/providers/list/codex/codex-sessions.provider.ts +319 -319
  132. package/server/modules/providers/list/codex/codex.provider.ts +15 -15
  133. package/server/modules/providers/list/cursor/cursor-auth.provider.ts +143 -143
  134. package/server/modules/providers/list/cursor/cursor-mcp.provider.ts +108 -108
  135. package/server/modules/providers/list/cursor/cursor-sessions.provider.ts +421 -421
  136. package/server/modules/providers/list/cursor/cursor.provider.ts +15 -15
  137. package/server/modules/providers/list/gemini/gemini-auth.provider.ts +163 -151
  138. package/server/modules/providers/list/gemini/gemini-mcp.provider.ts +110 -110
  139. package/server/modules/providers/list/gemini/gemini-sessions.provider.ts +227 -227
  140. package/server/modules/providers/list/gemini/gemini.provider.ts +15 -15
  141. package/server/modules/providers/list/qwen/qwen-auth.provider.ts +145 -0
  142. package/server/modules/providers/list/qwen/qwen-mcp.provider.ts +114 -0
  143. package/server/modules/providers/list/qwen/qwen-sessions.provider.ts +218 -0
  144. package/server/modules/providers/list/qwen/qwen.provider.ts +21 -0
  145. package/server/modules/providers/provider.registry.ts +38 -36
  146. package/server/modules/providers/provider.routes.ts +583 -217
  147. package/server/modules/providers/services/mcp.service.ts +94 -94
  148. package/server/modules/providers/services/provider-auth.service.ts +26 -26
  149. package/server/modules/providers/services/sessions.service.ts +45 -45
  150. package/server/modules/providers/shared/base/abstract.provider.ts +20 -20
  151. package/server/modules/providers/shared/mcp/mcp.provider.ts +151 -151
  152. package/server/modules/providers/tests/mcp.test.ts +293 -293
  153. package/server/openai-codex.js +426 -426
  154. package/server/projects.js +2993 -2792
  155. package/server/qwen-code-cli.js +392 -0
  156. package/server/qwen-response-handler.js +73 -0
  157. package/server/routes/agent.js +1245 -1245
  158. package/server/routes/auth.js +134 -134
  159. package/server/routes/codex.js +19 -19
  160. package/server/routes/commands.js +554 -554
  161. package/server/routes/cursor.js +52 -52
  162. package/server/routes/gemini.js +24 -24
  163. package/server/routes/git.js +1488 -1488
  164. package/server/routes/mcp-utils.js +31 -31
  165. package/server/routes/messages.js +61 -61
  166. package/server/routes/network.js +128 -0
  167. package/server/routes/plugins.js +307 -307
  168. package/server/routes/projects.js +675 -627
  169. package/server/routes/qwen.js +27 -0
  170. package/server/routes/settings.js +286 -286
  171. package/server/routes/taskmaster.js +1471 -1471
  172. package/server/routes/telegram.js +125 -0
  173. package/server/routes/user.js +123 -123
  174. package/server/services/external-access.js +240 -0
  175. package/server/services/install-jobs.js +410 -0
  176. package/server/services/notification-orchestrator.js +242 -227
  177. package/server/services/provider-credentials.js +151 -0
  178. package/server/services/provider-models.js +225 -0
  179. package/server/services/telegram/bot.js +280 -0
  180. package/server/services/telegram/translations.js +170 -0
  181. package/server/services/vapid-keys.js +35 -35
  182. package/server/sessionManager.js +225 -225
  183. package/server/shared/interfaces.ts +54 -54
  184. package/server/shared/types.ts +172 -172
  185. package/server/shared/utils.ts +193 -193
  186. package/server/tsconfig.json +36 -36
  187. package/server/utils/colors.js +21 -21
  188. package/server/utils/commandParser.js +303 -303
  189. package/server/utils/frontmatter.js +18 -18
  190. package/server/utils/gitConfig.js +34 -34
  191. package/server/utils/mcp-detector.js +147 -147
  192. package/server/utils/plugin-loader.js +457 -457
  193. package/server/utils/plugin-process-manager.js +184 -184
  194. package/server/utils/port-access.js +209 -0
  195. package/server/utils/runtime-paths.js +37 -37
  196. package/server/utils/taskmaster-websocket.js +128 -128
  197. package/server/utils/url-detection.js +71 -71
  198. package/server/vite-daemon.js +78 -78
  199. package/shared/modelConstants.js +117 -97
  200. package/shared/networkHosts.js +22 -22
  201. package/dist/assets/index-C2c9QNwK.css +0 -32
  202. package/dist/assets/index-DyXDZED-.js +0 -1277
@@ -1,217 +1,583 @@
1
- import express, { type Request, type Response } from 'express';
2
-
3
- import { providerAuthService } from '@/modules/providers/services/provider-auth.service.js';
4
- import { providerMcpService } from '@/modules/providers/services/mcp.service.js';
5
- import type { LLMProvider, McpScope, McpTransport, UpsertProviderMcpServerInput } from '@/shared/types.js';
6
- import { AppError, asyncHandler, createApiSuccessResponse } from '@/shared/utils.js';
7
-
8
- const router = express.Router();
9
-
10
- const readPathParam = (value: unknown, name: string): string => {
11
- if (typeof value === 'string') {
12
- return value;
13
- }
14
-
15
- if (Array.isArray(value) && typeof value[0] === 'string') {
16
- return value[0];
17
- }
18
-
19
- throw new AppError(`${name} path parameter is invalid.`, {
20
- code: 'INVALID_PATH_PARAMETER',
21
- statusCode: 400,
22
- });
23
- };
24
-
25
- const normalizeProviderParam = (value: unknown): string =>
26
- readPathParam(value, 'provider').trim().toLowerCase();
27
-
28
- const readOptionalQueryString = (value: unknown): string | undefined => {
29
- if (typeof value !== 'string') {
30
- return undefined;
31
- }
32
-
33
- const normalized = value.trim();
34
- return normalized.length > 0 ? normalized : undefined;
35
- };
36
-
37
- const parseMcpScope = (value: unknown): McpScope | undefined => {
38
- if (value === undefined) {
39
- return undefined;
40
- }
41
-
42
- const normalized = readOptionalQueryString(value);
43
- if (!normalized) {
44
- return undefined;
45
- }
46
-
47
- if (normalized === 'user' || normalized === 'local' || normalized === 'project') {
48
- return normalized;
49
- }
50
-
51
- throw new AppError(`Unsupported MCP scope "${normalized}".`, {
52
- code: 'INVALID_MCP_SCOPE',
53
- statusCode: 400,
54
- });
55
- };
56
-
57
- const parseMcpTransport = (value: unknown): McpTransport => {
58
- const normalized = readOptionalQueryString(value);
59
- if (!normalized) {
60
- throw new AppError('transport is required.', {
61
- code: 'MCP_TRANSPORT_REQUIRED',
62
- statusCode: 400,
63
- });
64
- }
65
-
66
- if (normalized === 'stdio' || normalized === 'http' || normalized === 'sse') {
67
- return normalized;
68
- }
69
-
70
- throw new AppError(`Unsupported MCP transport "${normalized}".`, {
71
- code: 'INVALID_MCP_TRANSPORT',
72
- statusCode: 400,
73
- });
74
- };
75
-
76
- const parseMcpUpsertPayload = (payload: unknown): UpsertProviderMcpServerInput => {
77
- if (!payload || typeof payload !== 'object') {
78
- throw new AppError('Request body must be an object.', {
79
- code: 'INVALID_REQUEST_BODY',
80
- statusCode: 400,
81
- });
82
- }
83
-
84
- const body = payload as Record<string, unknown>;
85
- const name = readOptionalQueryString(body.name);
86
- if (!name) {
87
- throw new AppError('name is required.', {
88
- code: 'MCP_NAME_REQUIRED',
89
- statusCode: 400,
90
- });
91
- }
92
-
93
- const transport = parseMcpTransport(body.transport);
94
- const scope = parseMcpScope(body.scope);
95
- const workspacePath = readOptionalQueryString(body.workspacePath);
96
-
97
- return {
98
- name,
99
- transport,
100
- scope,
101
- workspacePath,
102
- command: readOptionalQueryString(body.command),
103
- args: Array.isArray(body.args) ? body.args.filter((entry): entry is string => typeof entry === 'string') : undefined,
104
- env: typeof body.env === 'object' && body.env !== null
105
- ? Object.fromEntries(
106
- Object.entries(body.env as Record<string, unknown>).filter(
107
- (entry): entry is [string, string] => typeof entry[1] === 'string',
108
- ),
109
- )
110
- : undefined,
111
- cwd: readOptionalQueryString(body.cwd),
112
- url: readOptionalQueryString(body.url),
113
- headers: typeof body.headers === 'object' && body.headers !== null
114
- ? Object.fromEntries(
115
- Object.entries(body.headers as Record<string, unknown>).filter(
116
- (entry): entry is [string, string] => typeof entry[1] === 'string',
117
- ),
118
- )
119
- : undefined,
120
- envVars: Array.isArray(body.envVars)
121
- ? body.envVars.filter((entry): entry is string => typeof entry === 'string')
122
- : undefined,
123
- bearerTokenEnvVar: readOptionalQueryString(body.bearerTokenEnvVar),
124
- envHttpHeaders: typeof body.envHttpHeaders === 'object' && body.envHttpHeaders !== null
125
- ? Object.fromEntries(
126
- Object.entries(body.envHttpHeaders as Record<string, unknown>).filter(
127
- (entry): entry is [string, string] => typeof entry[1] === 'string',
128
- ),
129
- )
130
- : undefined,
131
- };
132
- };
133
-
134
- const parseProvider = (value: unknown): LLMProvider => {
135
- const normalized = normalizeProviderParam(value);
136
- if (normalized === 'claude' || normalized === 'codex' || normalized === 'cursor' || normalized === 'gemini') {
137
- return normalized;
138
- }
139
-
140
- throw new AppError(`Unsupported provider "${normalized}".`, {
141
- code: 'UNSUPPORTED_PROVIDER',
142
- statusCode: 400,
143
- });
144
- };
145
-
146
- router.get(
147
- '/:provider/auth/status',
148
- asyncHandler(async (req: Request, res: Response) => {
149
- const provider = parseProvider(req.params.provider);
150
- const status = await providerAuthService.getProviderAuthStatus(provider);
151
- res.json(createApiSuccessResponse(status));
152
- }),
153
- );
154
-
155
- router.get(
156
- '/:provider/mcp/servers',
157
- asyncHandler(async (req: Request, res: Response) => {
158
- const provider = parseProvider(req.params.provider);
159
- const workspacePath = readOptionalQueryString(req.query.workspacePath);
160
- const scope = parseMcpScope(req.query.scope);
161
-
162
- if (scope) {
163
- const servers = await providerMcpService.listProviderMcpServersForScope(provider, scope, { workspacePath });
164
- res.json(createApiSuccessResponse({ provider, scope, servers }));
165
- return;
166
- }
167
-
168
- const groupedServers = await providerMcpService.listProviderMcpServers(provider, { workspacePath });
169
- res.json(createApiSuccessResponse({ provider, scopes: groupedServers }));
170
- }),
171
- );
172
-
173
- router.post(
174
- '/:provider/mcp/servers',
175
- asyncHandler(async (req: Request, res: Response) => {
176
- const provider = parseProvider(req.params.provider);
177
- const payload = parseMcpUpsertPayload(req.body);
178
- const server = await providerMcpService.upsertProviderMcpServer(provider, payload);
179
- res.status(201).json(createApiSuccessResponse({ server }));
180
- }),
181
- );
182
-
183
- router.delete(
184
- '/:provider/mcp/servers/:name',
185
- asyncHandler(async (req: Request, res: Response) => {
186
- const provider = parseProvider(req.params.provider);
187
- const scope = parseMcpScope(req.query.scope);
188
- const workspacePath = readOptionalQueryString(req.query.workspacePath);
189
- const result = await providerMcpService.removeProviderMcpServer(provider, {
190
- name: readPathParam(req.params.name, 'name'),
191
- scope,
192
- workspacePath,
193
- });
194
- res.json(createApiSuccessResponse(result));
195
- }),
196
- );
197
-
198
- router.post(
199
- '/mcp/servers/global',
200
- asyncHandler(async (req: Request, res: Response) => {
201
- const payload = parseMcpUpsertPayload(req.body);
202
- if (payload.scope === 'local') {
203
- throw new AppError('Global MCP add supports only "user" or "project" scopes.', {
204
- code: 'INVALID_GLOBAL_MCP_SCOPE',
205
- statusCode: 400,
206
- });
207
- }
208
-
209
- const results = await providerMcpService.addMcpServerToAllProviders({
210
- ...payload,
211
- scope: payload.scope === 'user' ? 'user' : 'project',
212
- });
213
- res.status(201).json(createApiSuccessResponse({ results }));
214
- }),
215
- );
216
-
217
- export default router;
1
+ import express, { type Request, type Response } from 'express';
2
+
3
+ import { providerAuthService } from '@/modules/providers/services/provider-auth.service.js';
4
+ import { providerMcpService } from '@/modules/providers/services/mcp.service.js';
5
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
6
+ // @ts-ignore plain-JS service, typed via inference
7
+ import {
8
+ applyProviderCredentialsToEnv,
9
+ listProviderCredentialSummaries,
10
+ setProviderCredentials,
11
+ PROVIDER_ENV_VARS,
12
+ } from '@/services/provider-credentials.js';
13
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
14
+ // @ts-ignore — plain-JS service
15
+ import { getProviderModels, clearProviderModelCache } from '@/services/provider-models.js';
16
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
17
+ // @ts-ignore — plain-JS service
18
+ import {
19
+ createInstallJob,
20
+ getInstallJob,
21
+ cancelInstallJob,
22
+ snapshotDonePayload,
23
+ } from '@/services/install-jobs.js';
24
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
25
+ // @ts-ignore plain-JS shared module
26
+ import {
27
+ CLAUDE_MODELS,
28
+ CODEX_MODELS,
29
+ GEMINI_MODELS,
30
+ QWEN_MODELS,
31
+ CURSOR_MODELS,
32
+ } from '../../../shared/modelConstants.js';
33
+
34
+ const STATIC_MODELS_BY_PROVIDER: Record<LLMProvider, Array<{ value: string; label: string }>> = {
35
+ claude: CLAUDE_MODELS.OPTIONS,
36
+ codex: CODEX_MODELS.OPTIONS,
37
+ cursor: CURSOR_MODELS.OPTIONS,
38
+ gemini: GEMINI_MODELS.OPTIONS,
39
+ qwen: QWEN_MODELS.OPTIONS,
40
+ };
41
+ import type { LLMProvider, McpScope, McpTransport, UpsertProviderMcpServerInput } from '@/shared/types.js';
42
+ import { AppError, asyncHandler, createApiSuccessResponse } from '@/shared/utils.js';
43
+ import http from 'node:http';
44
+
45
+ /**
46
+ * npm-global install command per provider. Used by POST
47
+ * /api/providers/:p/install to run the install directly from Pixcode so
48
+ * users don't have to drop into a shell just to get a CLI on the host.
49
+ * Cursor uses its own install script, not npm.
50
+ */
51
+ /**
52
+ * npm package name per provider. The in-app installer drops these into
53
+ * ~/.pixcode/cli-bin/ as LOCAL deps (no -g, no sudo). A sibling string
54
+ * for display ("npm install -g …") is surfaced in the UI so users who
55
+ * prefer to install manually still see a recognizable command.
56
+ */
57
+ const PROVIDER_INSTALL_PACKAGES: Record<LLMProvider, string | null> = {
58
+ claude: '@anthropic-ai/claude-code',
59
+ codex: '@openai/codex',
60
+ gemini: '@google/gemini-cli',
61
+ qwen: '@qwen-code/qwen-code',
62
+ // Cursor ships via a bash script hosted at cursor.com; safer to ask
63
+ // users to run it themselves than to pipe-to-bash from our server.
64
+ cursor: null,
65
+ };
66
+
67
+ const PROVIDER_INSTALL_COMMANDS: Record<LLMProvider, string | null> = {
68
+ claude: 'npm install -g @anthropic-ai/claude-code',
69
+ codex: 'npm install -g @openai/codex',
70
+ gemini: 'npm install -g @google/gemini-cli',
71
+ qwen: 'npm install -g @qwen-code/qwen-code',
72
+ cursor: null,
73
+ };
74
+
75
+ const router = express.Router();
76
+
77
+ const readPathParam = (value: unknown, name: string): string => {
78
+ if (typeof value === 'string') {
79
+ return value;
80
+ }
81
+
82
+ if (Array.isArray(value) && typeof value[0] === 'string') {
83
+ return value[0];
84
+ }
85
+
86
+ throw new AppError(`${name} path parameter is invalid.`, {
87
+ code: 'INVALID_PATH_PARAMETER',
88
+ statusCode: 400,
89
+ });
90
+ };
91
+
92
+ const normalizeProviderParam = (value: unknown): string =>
93
+ readPathParam(value, 'provider').trim().toLowerCase();
94
+
95
+ const readOptionalQueryString = (value: unknown): string | undefined => {
96
+ if (typeof value !== 'string') {
97
+ return undefined;
98
+ }
99
+
100
+ const normalized = value.trim();
101
+ return normalized.length > 0 ? normalized : undefined;
102
+ };
103
+
104
+ const parseMcpScope = (value: unknown): McpScope | undefined => {
105
+ if (value === undefined) {
106
+ return undefined;
107
+ }
108
+
109
+ const normalized = readOptionalQueryString(value);
110
+ if (!normalized) {
111
+ return undefined;
112
+ }
113
+
114
+ if (normalized === 'user' || normalized === 'local' || normalized === 'project') {
115
+ return normalized;
116
+ }
117
+
118
+ throw new AppError(`Unsupported MCP scope "${normalized}".`, {
119
+ code: 'INVALID_MCP_SCOPE',
120
+ statusCode: 400,
121
+ });
122
+ };
123
+
124
+ const parseMcpTransport = (value: unknown): McpTransport => {
125
+ const normalized = readOptionalQueryString(value);
126
+ if (!normalized) {
127
+ throw new AppError('transport is required.', {
128
+ code: 'MCP_TRANSPORT_REQUIRED',
129
+ statusCode: 400,
130
+ });
131
+ }
132
+
133
+ if (normalized === 'stdio' || normalized === 'http' || normalized === 'sse') {
134
+ return normalized;
135
+ }
136
+
137
+ throw new AppError(`Unsupported MCP transport "${normalized}".`, {
138
+ code: 'INVALID_MCP_TRANSPORT',
139
+ statusCode: 400,
140
+ });
141
+ };
142
+
143
+ const parseMcpUpsertPayload = (payload: unknown): UpsertProviderMcpServerInput => {
144
+ if (!payload || typeof payload !== 'object') {
145
+ throw new AppError('Request body must be an object.', {
146
+ code: 'INVALID_REQUEST_BODY',
147
+ statusCode: 400,
148
+ });
149
+ }
150
+
151
+ const body = payload as Record<string, unknown>;
152
+ const name = readOptionalQueryString(body.name);
153
+ if (!name) {
154
+ throw new AppError('name is required.', {
155
+ code: 'MCP_NAME_REQUIRED',
156
+ statusCode: 400,
157
+ });
158
+ }
159
+
160
+ const transport = parseMcpTransport(body.transport);
161
+ const scope = parseMcpScope(body.scope);
162
+ const workspacePath = readOptionalQueryString(body.workspacePath);
163
+
164
+ return {
165
+ name,
166
+ transport,
167
+ scope,
168
+ workspacePath,
169
+ command: readOptionalQueryString(body.command),
170
+ args: Array.isArray(body.args) ? body.args.filter((entry): entry is string => typeof entry === 'string') : undefined,
171
+ env: typeof body.env === 'object' && body.env !== null
172
+ ? Object.fromEntries(
173
+ Object.entries(body.env as Record<string, unknown>).filter(
174
+ (entry): entry is [string, string] => typeof entry[1] === 'string',
175
+ ),
176
+ )
177
+ : undefined,
178
+ cwd: readOptionalQueryString(body.cwd),
179
+ url: readOptionalQueryString(body.url),
180
+ headers: typeof body.headers === 'object' && body.headers !== null
181
+ ? Object.fromEntries(
182
+ Object.entries(body.headers as Record<string, unknown>).filter(
183
+ (entry): entry is [string, string] => typeof entry[1] === 'string',
184
+ ),
185
+ )
186
+ : undefined,
187
+ envVars: Array.isArray(body.envVars)
188
+ ? body.envVars.filter((entry): entry is string => typeof entry === 'string')
189
+ : undefined,
190
+ bearerTokenEnvVar: readOptionalQueryString(body.bearerTokenEnvVar),
191
+ envHttpHeaders: typeof body.envHttpHeaders === 'object' && body.envHttpHeaders !== null
192
+ ? Object.fromEntries(
193
+ Object.entries(body.envHttpHeaders as Record<string, unknown>).filter(
194
+ (entry): entry is [string, string] => typeof entry[1] === 'string',
195
+ ),
196
+ )
197
+ : undefined,
198
+ };
199
+ };
200
+
201
+ const parseProvider = (value: unknown): LLMProvider => {
202
+ const normalized = normalizeProviderParam(value);
203
+ if (
204
+ normalized === 'claude' ||
205
+ normalized === 'codex' ||
206
+ normalized === 'cursor' ||
207
+ normalized === 'gemini' ||
208
+ normalized === 'qwen'
209
+ ) {
210
+ return normalized;
211
+ }
212
+
213
+ throw new AppError(`Unsupported provider "${normalized}".`, {
214
+ code: 'UNSUPPORTED_PROVIDER',
215
+ statusCode: 400,
216
+ });
217
+ };
218
+
219
+ router.get(
220
+ '/:provider/auth/status',
221
+ asyncHandler(async (req: Request, res: Response) => {
222
+ const provider = parseProvider(req.params.provider);
223
+ const status = await providerAuthService.getProviderAuthStatus(provider);
224
+ res.json(createApiSuccessResponse(status));
225
+ }),
226
+ );
227
+
228
+ router.get(
229
+ '/:provider/mcp/servers',
230
+ asyncHandler(async (req: Request, res: Response) => {
231
+ const provider = parseProvider(req.params.provider);
232
+ const workspacePath = readOptionalQueryString(req.query.workspacePath);
233
+ const scope = parseMcpScope(req.query.scope);
234
+
235
+ if (scope) {
236
+ const servers = await providerMcpService.listProviderMcpServersForScope(provider, scope, { workspacePath });
237
+ res.json(createApiSuccessResponse({ provider, scope, servers }));
238
+ return;
239
+ }
240
+
241
+ const groupedServers = await providerMcpService.listProviderMcpServers(provider, { workspacePath });
242
+ res.json(createApiSuccessResponse({ provider, scopes: groupedServers }));
243
+ }),
244
+ );
245
+
246
+ router.post(
247
+ '/:provider/mcp/servers',
248
+ asyncHandler(async (req: Request, res: Response) => {
249
+ const provider = parseProvider(req.params.provider);
250
+ const payload = parseMcpUpsertPayload(req.body);
251
+ const server = await providerMcpService.upsertProviderMcpServer(provider, payload);
252
+ res.status(201).json(createApiSuccessResponse({ server }));
253
+ }),
254
+ );
255
+
256
+ router.delete(
257
+ '/:provider/mcp/servers/:name',
258
+ asyncHandler(async (req: Request, res: Response) => {
259
+ const provider = parseProvider(req.params.provider);
260
+ const scope = parseMcpScope(req.query.scope);
261
+ const workspacePath = readOptionalQueryString(req.query.workspacePath);
262
+ const result = await providerMcpService.removeProviderMcpServer(provider, {
263
+ name: readPathParam(req.params.name, 'name'),
264
+ scope,
265
+ workspacePath,
266
+ });
267
+ res.json(createApiSuccessResponse(result));
268
+ }),
269
+ );
270
+
271
+ /**
272
+ * GET /api/providers/credentials
273
+ * Summary for every provider (hasKey + baseUrl + updatedAt). Used by the
274
+ * Settings UI to pre-fill the "API Key" tab.
275
+ */
276
+ router.get(
277
+ '/credentials',
278
+ asyncHandler(async (_req: Request, res: Response) => {
279
+ const summaries = await listProviderCredentialSummaries();
280
+ res.json(createApiSuccessResponse(summaries));
281
+ }),
282
+ );
283
+
284
+ /**
285
+ * POST /api/providers/:provider/auth/api-key
286
+ * Body: { apiKey: string, baseUrl?: string }. Stores the credentials in
287
+ * ~/.pixcode/provider-credentials.json and applies them to process.env
288
+ * so the next CLI spawn/SDK call picks them up. Empty apiKey clears.
289
+ */
290
+ router.post(
291
+ '/:provider/auth/api-key',
292
+ asyncHandler(async (req: Request, res: Response) => {
293
+ const provider = parseProvider(req.params.provider);
294
+ if (!(provider in PROVIDER_ENV_VARS)) {
295
+ throw new AppError(`Provider "${provider}" does not accept API-key auth.`, {
296
+ code: 'PROVIDER_NO_API_KEY',
297
+ statusCode: 400,
298
+ });
299
+ }
300
+ const body = (req.body ?? {}) as Record<string, unknown>;
301
+ const apiKey = typeof body.apiKey === 'string' ? body.apiKey : '';
302
+ const baseUrl = typeof body.baseUrl === 'string' ? body.baseUrl : '';
303
+
304
+ await setProviderCredentials(provider, { apiKey, baseUrl });
305
+ await applyProviderCredentialsToEnv(provider);
306
+
307
+ res.json(createApiSuccessResponse({ provider, stored: Boolean(apiKey.trim()) }));
308
+ }),
309
+ );
310
+
311
+ /**
312
+ * POST /api/providers/:provider/oauth-paste
313
+ * Body: { callbackUrl: string }.
314
+ *
315
+ * When the CLI starts an OAuth flow it spins up a local HTTP server on
316
+ * 127.0.0.1:<PORT> and expects the OAuth provider to redirect the user's
317
+ * browser to `http://127.0.0.1:<PORT>/callback?code=...`. On remote VPS
318
+ * setups that redirect hits the user's laptop localhost (which has nothing
319
+ * listening), not the server running the CLI. This endpoint is the escape
320
+ * hatch: the user copies the dead callback URL from their browser and
321
+ * posts it here; we parse out the port + code and forward the original
322
+ * GET to the VPS-side 127.0.0.1:PORT so the CLI's local handler completes
323
+ * the token exchange.
324
+ */
325
+ router.post(
326
+ '/:provider/oauth-paste',
327
+ asyncHandler(async (req: Request, res: Response) => {
328
+ parseProvider(req.params.provider); // validate id but we don't use it further
329
+ const body = (req.body ?? {}) as Record<string, unknown>;
330
+ const raw = typeof body.callbackUrl === 'string' ? body.callbackUrl.trim() : '';
331
+ if (!raw) {
332
+ throw new AppError('callbackUrl is required.', {
333
+ code: 'OAUTH_PASTE_URL_REQUIRED',
334
+ statusCode: 400,
335
+ });
336
+ }
337
+
338
+ let parsed: URL;
339
+ try {
340
+ parsed = new URL(raw);
341
+ } catch {
342
+ throw new AppError('callbackUrl must be a valid URL.', {
343
+ code: 'OAUTH_PASTE_URL_INVALID',
344
+ statusCode: 400,
345
+ });
346
+ }
347
+
348
+ // Accept localhost / 127.0.0.1 callbacks — reject anything else so we
349
+ // never proxy arbitrary outbound requests on behalf of a user.
350
+ const host = parsed.hostname;
351
+ if (host !== '127.0.0.1' && host !== 'localhost' && host !== '::1') {
352
+ throw new AppError('Only local CLI callback URLs are accepted.', {
353
+ code: 'OAUTH_PASTE_URL_NOT_LOCAL',
354
+ statusCode: 400,
355
+ });
356
+ }
357
+
358
+ const port = Number(parsed.port);
359
+ if (!port || port < 1 || port > 65535) {
360
+ throw new AppError('Callback URL must include the CLI callback port.', {
361
+ code: 'OAUTH_PASTE_PORT_INVALID',
362
+ statusCode: 400,
363
+ });
364
+ }
365
+
366
+ const pathAndQuery = parsed.pathname + parsed.search;
367
+ await new Promise<void>((resolve, reject) => {
368
+ const forwardReq = http.request(
369
+ {
370
+ host: '127.0.0.1',
371
+ port,
372
+ method: 'GET',
373
+ path: pathAndQuery,
374
+ timeout: 10000,
375
+ },
376
+ (forwardRes) => {
377
+ forwardRes.resume(); // drain
378
+ forwardRes.on('end', () => resolve());
379
+ },
380
+ );
381
+ forwardReq.on('timeout', () => {
382
+ forwardReq.destroy(new Error('CLI callback server did not respond within 10s'));
383
+ });
384
+ forwardReq.on('error', (err) => reject(err));
385
+ forwardReq.end();
386
+ });
387
+
388
+ res.json(createApiSuccessResponse({ forwarded: true, port }));
389
+ }),
390
+ );
391
+
392
+ /**
393
+ * GET /api/providers/:provider/models?refresh=1
394
+ * Merged model catalog: hardcoded defaults + live API discovery when an
395
+ * API key is configured. Ships a stable baseline so dropdowns never sit
396
+ * empty, then overlays whatever the upstream API reports so users get
397
+ * new models without a Pixcode release. 6-hour cache; pass `refresh=1`
398
+ * to force an upstream hit.
399
+ */
400
+ router.get(
401
+ '/:provider/models',
402
+ asyncHandler(async (req: Request, res: Response) => {
403
+ const provider = parseProvider(req.params.provider);
404
+ const forceRefresh = String(req.query.refresh || '').toLowerCase() === '1';
405
+ const result = await getProviderModels(provider, {
406
+ forceRefresh,
407
+ staticList: STATIC_MODELS_BY_PROVIDER[provider] ?? [],
408
+ });
409
+ res.json(createApiSuccessResponse(result));
410
+ }),
411
+ );
412
+
413
+ router.delete(
414
+ '/:provider/models/cache',
415
+ asyncHandler(async (req: Request, res: Response) => {
416
+ const provider = parseProvider(req.params.provider);
417
+ await clearProviderModelCache(provider);
418
+ res.json(createApiSuccessResponse({ cleared: true, provider }));
419
+ }),
420
+ );
421
+
422
+ /**
423
+ * POST /api/providers/:provider/install
424
+ * Kicks off the install in the background and immediately returns
425
+ * `{ jobId }`. The actual log stream is fetched separately via
426
+ * GET /install/:jobId/stream (EventSource). This split solves the
427
+ * "Client disconnected before install finished" class of errors,
428
+ * where a single long-lived POST SSE would get torn down by dev
429
+ * proxies, service-worker reloads, or Vite HMR and short-circuit
430
+ * an in-flight install. The child now outlives the request.
431
+ */
432
+ router.post(
433
+ '/:provider/install',
434
+ asyncHandler(async (req: Request, res: Response) => {
435
+ const parsed = parseProvider(req.params.provider);
436
+ const packageName = PROVIDER_INSTALL_PACKAGES[parsed];
437
+ const installCmd = PROVIDER_INSTALL_COMMANDS[parsed];
438
+ if (!packageName || !installCmd) {
439
+ throw new AppError(
440
+ `${parsed} cannot be installed automatically — please follow the documented install steps.`,
441
+ { code: 'PROVIDER_NOT_AUTO_INSTALLABLE', statusCode: 400 },
442
+ );
443
+ }
444
+
445
+ const job = createInstallJob({ provider: parsed, installCmd, packageName });
446
+ res.json(createApiSuccessResponse({
447
+ jobId: job.id,
448
+ provider: parsed,
449
+ installCmd,
450
+ startedAt: job.startedAt,
451
+ }));
452
+ }),
453
+ );
454
+
455
+ /**
456
+ * GET /api/providers/:provider/install/:jobId/stream
457
+ * SSE endpoint (EventSource-friendly). Replays every buffered log line
458
+ * to the new subscriber, then forwards live stdout/stderr until the
459
+ * child exits. Clients can reconnect freely — reconnects replay from
460
+ * the start, so you never miss output, even if the browser dropped
461
+ * the previous connection while npm was mid-download.
462
+ *
463
+ * EventSource can't set custom headers, so this endpoint also accepts
464
+ * ?token=... as a fallback auth channel (same pattern the search
465
+ * endpoint uses).
466
+ */
467
+ router.get(
468
+ '/:provider/install/:jobId/stream',
469
+ asyncHandler(async (req: Request, res: Response) => {
470
+ const parsed = parseProvider(req.params.provider);
471
+ const jobId = readPathParam(req.params.jobId, 'jobId');
472
+ const job = getInstallJob(jobId);
473
+ if (!job || job.provider !== parsed) {
474
+ throw new AppError('Install job not found or already expired.', {
475
+ code: 'INSTALL_JOB_NOT_FOUND',
476
+ statusCode: 404,
477
+ });
478
+ }
479
+
480
+ res.setHeader('Content-Type', 'text/event-stream');
481
+ res.setHeader('Cache-Control', 'no-cache, no-transform');
482
+ res.setHeader('Connection', 'keep-alive');
483
+ res.setHeader('X-Accel-Buffering', 'no');
484
+ if (typeof res.flushHeaders === 'function') res.flushHeaders();
485
+ try {
486
+ (res.socket as NodeJS.Socket & { setNoDelay?: (on: boolean) => void })?.setNoDelay?.(true);
487
+ } catch { /* noop */ }
488
+
489
+ let closed = false;
490
+ const write = (event: string, payload: unknown) => {
491
+ if (closed) return;
492
+ try {
493
+ res.write(`event: ${event}\n`);
494
+ res.write(`data: ${JSON.stringify(payload)}\n\n`);
495
+ } catch { /* socket gone */ }
496
+ };
497
+
498
+ // Immediate primer + heartbeat, same as before — keeps intermediary
499
+ // proxies from treating the connection as idle.
500
+ try { res.write(': start\n\n'); } catch { /* noop */ }
501
+ const heartbeat = setInterval(() => {
502
+ if (closed) return;
503
+ try { res.write(': ping\n\n'); } catch { /* noop */ }
504
+ }, 5000);
505
+
506
+ // Replay the buffered transcript first so late subscribers see
507
+ // every line npm has already produced.
508
+ for (const entry of job.logs) {
509
+ write('log', { stream: entry.stream, chunk: entry.chunk });
510
+ }
511
+
512
+ const onLog = (entry: { stream: string; chunk: string }) => {
513
+ write('log', { stream: entry.stream, chunk: entry.chunk });
514
+ };
515
+ const onDone = (payload: Record<string, unknown>) => {
516
+ write('done', payload);
517
+ cleanup();
518
+ try { res.end(); } catch { /* noop */ }
519
+ };
520
+
521
+ const cleanup = () => {
522
+ if (closed) return;
523
+ closed = true;
524
+ clearInterval(heartbeat);
525
+ job.emitter.off('log', onLog);
526
+ job.emitter.off('done', onDone);
527
+ };
528
+
529
+ if (job.status !== 'running') {
530
+ // Job already finished — replay the terminal done frame and exit.
531
+ write('done', snapshotDonePayload(job));
532
+ cleanup();
533
+ try { res.end(); } catch { /* noop */ }
534
+ return;
535
+ }
536
+
537
+ job.emitter.on('log', onLog);
538
+ job.emitter.once('done', onDone);
539
+
540
+ req.on('close', () => {
541
+ // Client walked away. DO NOT cancel the install — detaching is fine.
542
+ cleanup();
543
+ });
544
+ }),
545
+ );
546
+
547
+ router.delete(
548
+ '/:provider/install/:jobId',
549
+ asyncHandler(async (req: Request, res: Response) => {
550
+ const parsed = parseProvider(req.params.provider);
551
+ const jobId = readPathParam(req.params.jobId, 'jobId');
552
+ const job = getInstallJob(jobId);
553
+ if (!job || job.provider !== parsed) {
554
+ throw new AppError('Install job not found.', {
555
+ code: 'INSTALL_JOB_NOT_FOUND',
556
+ statusCode: 404,
557
+ });
558
+ }
559
+ const cancelled = cancelInstallJob(jobId);
560
+ res.json(createApiSuccessResponse({ cancelled }));
561
+ }),
562
+ );
563
+
564
+ router.post(
565
+ '/mcp/servers/global',
566
+ asyncHandler(async (req: Request, res: Response) => {
567
+ const payload = parseMcpUpsertPayload(req.body);
568
+ if (payload.scope === 'local') {
569
+ throw new AppError('Global MCP add supports only "user" or "project" scopes.', {
570
+ code: 'INVALID_GLOBAL_MCP_SCOPE',
571
+ statusCode: 400,
572
+ });
573
+ }
574
+
575
+ const results = await providerMcpService.addMcpServerToAllProviders({
576
+ ...payload,
577
+ scope: payload.scope === 'user' ? 'user' : 'project',
578
+ });
579
+ res.status(201).json(createApiSuccessResponse({ results }));
580
+ }),
581
+ );
582
+
583
+ export default router;