@pixelbyte-software/pixcode 1.31.0 → 1.31.2

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.
@@ -40,7 +40,16 @@ const STATIC_MODELS_BY_PROVIDER: Record<LLMProvider, Array<{ value: string; labe
40
40
  };
41
41
  import type { LLMProvider, McpScope, McpTransport, UpsertProviderMcpServerInput } from '@/shared/types.js';
42
42
  import { AppError, asyncHandler, createApiSuccessResponse } from '@/shared/utils.js';
43
+ import fs from 'node:fs/promises';
43
44
  import http from 'node:http';
45
+ import os from 'node:os';
46
+ import path from 'node:path';
47
+
48
+ import {
49
+ MAX_CONFIG_FILE_SIZE_BYTES,
50
+ PROVIDER_CONFIG_FILES,
51
+ type ProviderConfigFile,
52
+ } from '@/modules/providers/shared/provider-configs.js';
44
53
 
45
54
  /**
46
55
  * npm-global install command per provider. Used by POST
@@ -580,4 +589,193 @@ router.post(
580
589
  }),
581
590
  );
582
591
 
592
+ // ============================================================================
593
+ // Provider config files — read / edit the per-CLI settings/env files from
594
+ // inside Pixcode rather than making the user open a text editor themselves.
595
+ // The registry at server/modules/providers/shared/provider-configs.ts is the
596
+ // single source of truth for which files exist; the client pulls this list
597
+ // via GET /config-files and then reads/writes individual files by id.
598
+ // ============================================================================
599
+
600
+ // Resolve a config descriptor from (provider, fileId). Throws a 404
601
+ // AppError if either isn't registered so the client sees a clear failure
602
+ // instead of a generic 500.
603
+ const resolveConfigFile = (provider: string, fileId: string): { descriptor: ProviderConfigFile; absolutePath: string } => {
604
+ const list = PROVIDER_CONFIG_FILES[provider];
605
+ if (!list) {
606
+ throw new AppError(`No config files registered for provider "${provider}"`, {
607
+ code: 'PROVIDER_CONFIG_UNKNOWN_PROVIDER',
608
+ statusCode: 404,
609
+ });
610
+ }
611
+ const descriptor = list.find((entry) => entry.id === fileId);
612
+ if (!descriptor) {
613
+ throw new AppError(`Unknown config file "${fileId}" for provider "${provider}"`, {
614
+ code: 'PROVIDER_CONFIG_UNKNOWN_FILE',
615
+ statusCode: 404,
616
+ });
617
+ }
618
+ // Always resolve relative to the server's os.homedir() — we never trust
619
+ // the client for any part of the path. `path.resolve` then normalises
620
+ // out any `..` segments the registry might accidentally contain.
621
+ const absolutePath = path.resolve(os.homedir(), descriptor.relativePath);
622
+ return { descriptor, absolutePath };
623
+ };
624
+
625
+ router.get(
626
+ '/:provider/config-files',
627
+ asyncHandler(async (req: Request, res: Response) => {
628
+ const provider = String(req.params.provider);
629
+ const list = PROVIDER_CONFIG_FILES[provider];
630
+ if (!list) {
631
+ throw new AppError(`No config files registered for provider "${provider}"`, {
632
+ code: 'PROVIDER_CONFIG_UNKNOWN_PROVIDER',
633
+ statusCode: 404,
634
+ });
635
+ }
636
+ const files = await Promise.all(
637
+ list.map(async (entry: ProviderConfigFile) => {
638
+ const absolutePath = path.resolve(os.homedir(), entry.relativePath);
639
+ let exists = false;
640
+ let size: number | null = null;
641
+ let updatedAt: string | null = null;
642
+ try {
643
+ const stat = await fs.stat(absolutePath);
644
+ exists = stat.isFile();
645
+ size = stat.size;
646
+ updatedAt = stat.mtime.toISOString();
647
+ } catch (err) {
648
+ // ENOENT is the expected path for "user hasn't created this yet".
649
+ // Anything else (EACCES, EISDIR, …) we surface as a hint rather
650
+ // than blow up the whole list response.
651
+ if ((err as NodeJS.ErrnoException).code !== 'ENOENT') {
652
+ console.warn(`[provider-configs] stat ${absolutePath}:`, (err as Error).message);
653
+ }
654
+ }
655
+ return {
656
+ id: entry.id,
657
+ label: entry.label,
658
+ format: entry.format,
659
+ readonly: Boolean(entry.readonly),
660
+ description: entry.description ?? null,
661
+ relativePath: entry.relativePath,
662
+ absolutePath,
663
+ exists,
664
+ size,
665
+ updatedAt,
666
+ };
667
+ }),
668
+ );
669
+ res.json(createApiSuccessResponse({ provider, files }));
670
+ }),
671
+ );
672
+
673
+ router.get(
674
+ '/:provider/config-files/:fileId',
675
+ asyncHandler(async (req: Request, res: Response) => {
676
+ const provider = String(req.params.provider);
677
+ const fileId = String(req.params.fileId);
678
+ const { descriptor, absolutePath } = resolveConfigFile(provider, fileId);
679
+
680
+ try {
681
+ const stat = await fs.stat(absolutePath);
682
+ if (!stat.isFile()) {
683
+ throw new AppError(`${absolutePath} is not a regular file`, {
684
+ code: 'PROVIDER_CONFIG_NOT_FILE',
685
+ statusCode: 409,
686
+ });
687
+ }
688
+ if (stat.size > MAX_CONFIG_FILE_SIZE_BYTES) {
689
+ throw new AppError(
690
+ `Config file is larger than ${MAX_CONFIG_FILE_SIZE_BYTES} bytes — refusing to load`,
691
+ { code: 'PROVIDER_CONFIG_TOO_LARGE', statusCode: 413 },
692
+ );
693
+ }
694
+ const contents = await fs.readFile(absolutePath, 'utf8');
695
+ res.json(createApiSuccessResponse({
696
+ id: descriptor.id,
697
+ label: descriptor.label,
698
+ format: descriptor.format,
699
+ readonly: Boolean(descriptor.readonly),
700
+ relativePath: descriptor.relativePath,
701
+ absolutePath,
702
+ exists: true,
703
+ size: stat.size,
704
+ updatedAt: stat.mtime.toISOString(),
705
+ contents,
706
+ }));
707
+ } catch (err) {
708
+ if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
709
+ // Report "file doesn't exist yet" with empty contents so the UI can
710
+ // still open an editor and let the user create it with a save.
711
+ res.json(createApiSuccessResponse({
712
+ id: descriptor.id,
713
+ label: descriptor.label,
714
+ format: descriptor.format,
715
+ readonly: Boolean(descriptor.readonly),
716
+ relativePath: descriptor.relativePath,
717
+ absolutePath,
718
+ exists: false,
719
+ size: 0,
720
+ updatedAt: null,
721
+ contents: '',
722
+ }));
723
+ return;
724
+ }
725
+ throw err;
726
+ }
727
+ }),
728
+ );
729
+
730
+ router.put(
731
+ '/:provider/config-files/:fileId',
732
+ asyncHandler(async (req: Request, res: Response) => {
733
+ const provider = String(req.params.provider);
734
+ const fileId = String(req.params.fileId);
735
+ const { descriptor, absolutePath } = resolveConfigFile(provider, fileId);
736
+
737
+ if (descriptor.readonly) {
738
+ throw new AppError(`${descriptor.label} is read-only`, {
739
+ code: 'PROVIDER_CONFIG_READONLY',
740
+ statusCode: 403,
741
+ });
742
+ }
743
+
744
+ const contents = typeof req.body?.contents === 'string' ? req.body.contents : '';
745
+ if (Buffer.byteLength(contents, 'utf8') > MAX_CONFIG_FILE_SIZE_BYTES) {
746
+ throw new AppError(
747
+ `Refusing to write: contents exceed ${MAX_CONFIG_FILE_SIZE_BYTES} bytes`,
748
+ { code: 'PROVIDER_CONFIG_TOO_LARGE', statusCode: 413 },
749
+ );
750
+ }
751
+
752
+ // Light format validation — catches "pasted a stray character and now
753
+ // the CLI refuses to start" before we actually save the file. We don't
754
+ // try to be strict about TOML / env formats because a user who's
755
+ // editing these probably knows the grammar better than our regex.
756
+ if (descriptor.format === 'json') {
757
+ try {
758
+ JSON.parse(contents || '{}');
759
+ } catch (err) {
760
+ throw new AppError(`Invalid JSON: ${(err as Error).message}`, {
761
+ code: 'PROVIDER_CONFIG_INVALID_JSON',
762
+ statusCode: 400,
763
+ });
764
+ }
765
+ }
766
+
767
+ await fs.mkdir(path.dirname(absolutePath), { recursive: true });
768
+ await fs.writeFile(absolutePath, contents, 'utf8');
769
+
770
+ const stat = await fs.stat(absolutePath);
771
+ res.json(createApiSuccessResponse({
772
+ id: descriptor.id,
773
+ relativePath: descriptor.relativePath,
774
+ absolutePath,
775
+ size: stat.size,
776
+ updatedAt: stat.mtime.toISOString(),
777
+ }));
778
+ }),
779
+ );
780
+
583
781
  export default router;
@@ -0,0 +1,118 @@
1
+ /**
2
+ * Registry of user-editable config files per provider CLI.
3
+ *
4
+ * This is the single source of truth for the Settings → Agents →
5
+ * Configuration tab. Adding a new provider? Append a row here and the
6
+ * UI + API pick it up — no component changes required.
7
+ *
8
+ * Rules:
9
+ * - `relativePath` is relative to the user's home directory.
10
+ * We never accept absolute paths from the client; the server
11
+ * resolves these explicitly so path traversal is impossible.
12
+ * - `format` drives the CodeMirror language extension on the client.
13
+ * - `readonly: true` hides the Save button and the server rejects
14
+ * writes. Use it for files the CLI owns (e.g. OAuth tokens).
15
+ * - `description` is shown as a subtle caption under the editor.
16
+ */
17
+
18
+ export type ProviderConfigFormat = 'json' | 'toml' | 'env' | 'text';
19
+
20
+ export type ProviderConfigFile = {
21
+ id: string;
22
+ label: string;
23
+ relativePath: string;
24
+ format: ProviderConfigFormat;
25
+ readonly?: boolean;
26
+ description?: string;
27
+ };
28
+
29
+ export const PROVIDER_CONFIG_FILES: Record<string, ProviderConfigFile[]> = {
30
+ claude: [
31
+ {
32
+ id: 'settings',
33
+ label: 'settings.json',
34
+ relativePath: '.claude/settings.json',
35
+ format: 'json',
36
+ description: 'Main Claude Code settings — default model, system prompt, tool policy.',
37
+ },
38
+ {
39
+ id: 'env',
40
+ label: '.env',
41
+ relativePath: '.claude/.env',
42
+ format: 'env',
43
+ description: 'Environment variables loaded when Claude runs (e.g. ANTHROPIC_API_KEY).',
44
+ },
45
+ ],
46
+ codex: [
47
+ {
48
+ id: 'config',
49
+ label: 'config.toml',
50
+ relativePath: '.codex/config.toml',
51
+ format: 'toml',
52
+ description: 'Main Codex CLI config — models, MCP servers, approval policy, sandbox mode.',
53
+ },
54
+ {
55
+ id: 'env',
56
+ label: '.env',
57
+ relativePath: '.codex/.env',
58
+ format: 'env',
59
+ description: 'Environment variables (OPENAI_API_KEY, OPENAI_BASE_URL, …).',
60
+ },
61
+ {
62
+ id: 'auth',
63
+ label: 'auth.json',
64
+ relativePath: '.codex/auth.json',
65
+ format: 'json',
66
+ readonly: true,
67
+ description: 'OAuth tokens managed by `codex login`. Read-only; editing here would corrupt the session.',
68
+ },
69
+ ],
70
+ cursor: [
71
+ {
72
+ id: 'env',
73
+ label: '.env',
74
+ relativePath: '.cursor/.env',
75
+ format: 'env',
76
+ description: 'Cursor CLI environment variables.',
77
+ },
78
+ ],
79
+ gemini: [
80
+ {
81
+ id: 'settings',
82
+ label: 'settings.json',
83
+ relativePath: '.gemini/settings.json',
84
+ format: 'json',
85
+ description: 'Main Gemini CLI settings — selected model, MCP servers, tool approval mode.',
86
+ },
87
+ {
88
+ id: 'env',
89
+ label: '.env',
90
+ relativePath: '.gemini/.env',
91
+ format: 'env',
92
+ description: 'Environment variables (GOOGLE_API_KEY, GEMINI_API_KEY, …).',
93
+ },
94
+ ],
95
+ qwen: [
96
+ {
97
+ id: 'settings',
98
+ label: 'settings.json',
99
+ relativePath: '.qwen/settings.json',
100
+ format: 'json',
101
+ description: 'Main Qwen Code settings — selected model, MCP servers, approval mode.',
102
+ },
103
+ {
104
+ id: 'env',
105
+ label: '.env',
106
+ relativePath: '.qwen/.env',
107
+ format: 'env',
108
+ description: 'Environment variables (DASHSCOPE_API_KEY, OPENAI_API_KEY for OpenAI-compatible routes, …).',
109
+ },
110
+ ],
111
+ };
112
+
113
+ export const SUPPORTED_CONFIG_PROVIDERS = Object.keys(PROVIDER_CONFIG_FILES);
114
+
115
+ // Hard cap — no config file we care about is remotely this big, but we
116
+ // want to refuse reads and writes that would swell memory. Editing a 1 MB
117
+ // settings.json is already a smell.
118
+ export const MAX_CONFIG_FILE_SIZE_BYTES = 1_048_576; // 1 MB
@@ -3,7 +3,7 @@ import { promises as fs } from 'fs';
3
3
  import path from 'path';
4
4
  import { spawn } from 'child_process';
5
5
  import os from 'os';
6
- import { addProjectManually } from '../projects.js';
6
+ import { addProjectManually, extractProjectDirectory } from '../projects.js';
7
7
 
8
8
  const router = express.Router();
9
9
 
@@ -240,15 +240,100 @@ export async function validateWorkspacePath(requestedPath) {
240
240
  }
241
241
  }
242
242
 
243
+ /**
244
+ * Is this `pixcode-project-N` slot already in use? "In use" means the
245
+ * user has sent at least one message under any provider — presence of a
246
+ * session file under ~/.claude/projects/<encoded>/, ~/.codex/sessions/,
247
+ * or ~/.gemini/… is our signal. We keep it best-effort: if we can't
248
+ * probe a provider's session dir (no permissions, path missing), we
249
+ * treat it as "no sessions for this provider" rather than raise.
250
+ *
251
+ * Checking the on-disk workspace dir for files is NOT a reliable signal
252
+ * — providers store their history outside the workspace, so a project
253
+ * that has had 20 messages still has an empty folder.
254
+ */
255
+ async function projectHasAnySessions(workspacePath) {
256
+ const home = os.homedir();
257
+ // encodeProjectName strips drive separators (C:\ → -C--…) and dots so
258
+ // `extractProjectDirectory` can round-trip. Using the same encoder as
259
+ // the rest of projects.js keeps us aligned with however Claude's CLI
260
+ // computes its per-project directory name.
261
+ const slug = workspacePath.replace(/[\\/:]/g, '-').replace(/\./g, '-');
262
+
263
+ const probes = [
264
+ // Claude Code: JSONL-per-session files under a per-project subdir.
265
+ path.join(home, '.claude', 'projects', slug),
266
+ // Codex writes session logs under ~/.codex/sessions — they're cross-project
267
+ // so we can't cheaply attribute them to a specific slot; skip.
268
+ // Gemini: same layout as Claude.
269
+ path.join(home, '.gemini', 'projects', slug),
270
+ // Qwen Code (Gemini fork): same layout.
271
+ path.join(home, '.qwen', 'projects', slug),
272
+ ];
273
+
274
+ for (const dir of probes) {
275
+ try {
276
+ const entries = await fs.readdir(dir);
277
+ if (entries.some((name) => name.endsWith('.jsonl') || name.endsWith('.json'))) {
278
+ return true;
279
+ }
280
+ } catch {
281
+ // Missing / unreadable dir just means "no sessions here", not fatal.
282
+ }
283
+ }
284
+ return false;
285
+ }
286
+
287
+ /**
288
+ * GET /api/projects/:projectName/dir-status
289
+ *
290
+ * Lightweight "does the workspace still exist on disk?" check used by
291
+ * the chat composer to detect deleted-directory sessions. We decode the
292
+ * project name back to an absolute path and stat it — a slug alone isn't
293
+ * useful because the user may have deleted the workspace while the
294
+ * session metadata still lives under ~/.<provider>/projects/.
295
+ *
296
+ * Returns `{ exists, path, isDirectory }` so the UI can lock the
297
+ * composer and surface a "directory deleted" warning instead of letting
298
+ * the user fire prompts into a void.
299
+ */
300
+ router.get('/:projectName/dir-status', async (req, res) => {
301
+ const { projectName } = req.params;
302
+ try {
303
+ const actualPath = await extractProjectDirectory(projectName);
304
+ if (!actualPath) {
305
+ return res.json({ exists: false, path: null, isDirectory: false });
306
+ }
307
+ try {
308
+ const stat = await fs.stat(actualPath);
309
+ return res.json({
310
+ exists: true,
311
+ path: actualPath,
312
+ isDirectory: stat.isDirectory(),
313
+ });
314
+ } catch (err) {
315
+ // ENOENT is the typical "user rm -rf'd the workspace" path.
316
+ if (err.code === 'ENOENT') {
317
+ return res.json({ exists: false, path: actualPath, isDirectory: false });
318
+ }
319
+ throw err;
320
+ }
321
+ } catch (error) {
322
+ console.error(`[projects] dir-status ${projectName}:`, error);
323
+ res.status(500).json({ error: error.message || 'Failed to check project directory' });
324
+ }
325
+ });
326
+
243
327
  /**
244
328
  * POST /api/projects/quick-start
245
329
  *
246
- * Zero-config project creation: picks the next available
247
- * `pixcode-project-N` slot under WORKSPACES_BASE, creates the directory,
248
- * registers it, and returns the project record. Used by the "start
249
- * chatting without setting up a project first" landing flow we want
250
- * the user to type + send a message and have a real workspace appear
251
- * underneath them without prompting for a name or path up front.
330
+ * Zero-config project creation: **reuses** the first unused
331
+ * `pixcode-project-N` slot if one exists, otherwise creates the next
332
+ * free index. "Unused" = no session files on disk for any provider.
333
+ * Without reuse, clicking "New chat" rapidly stacks up pixcode-project-1
334
+ * through pixcode-project-N and litters the workspace the UX we want
335
+ * matches ChatGPT's "New chat" which reuses the empty canvas until the
336
+ * user actually commits a message.
252
337
  */
253
338
  router.post('/quick-start', async (req, res) => {
254
339
  try {
@@ -258,30 +343,65 @@ router.post('/quick-start', async (req, res) => {
258
343
  try {
259
344
  entries = await fs.readdir(WORKSPACES_BASE, { withFileTypes: true });
260
345
  } catch { /* empty is fine */ }
261
- const taken = new Set(
262
- entries.filter((e) => e.isDirectory()).map((e) => e.name.toLowerCase()),
263
- );
264
346
 
265
- let nextIndex = 1;
266
- let name = '';
267
- // eslint-disable-next-line no-constant-condition
268
- while (true) {
269
- const candidate = `pixcode-project-${nextIndex}`;
270
- if (!taken.has(candidate.toLowerCase())) {
271
- name = candidate;
272
- break;
347
+ // Pixcode-owned slots, sorted by numeric index so reuse is deterministic
348
+ // and picks the lowest idle slot (pixcode-project-1 before -3).
349
+ const existingSlots = entries
350
+ .filter((e) => e.isDirectory() && /^pixcode-project-\d+$/i.test(e.name))
351
+ .map((e) => ({
352
+ name: e.name,
353
+ index: parseInt(e.name.split('-').pop(), 10) || 0,
354
+ }))
355
+ .sort((a, b) => a.index - b.index);
356
+
357
+ // 1. First pass: reuse the lowest-indexed slot that has no sessions.
358
+ for (const slot of existingSlots) {
359
+ const absolutePath = path.join(WORKSPACES_BASE, slot.name);
360
+ const used = await projectHasAnySessions(absolutePath);
361
+ if (!used) {
362
+ let project;
363
+ try {
364
+ project = await addProjectManually(absolutePath);
365
+ } catch (err) {
366
+ // addProjectManually throws when the project is already
367
+ // registered. That's fine — look it up via its encoded name
368
+ // instead of creating a duplicate.
369
+ const msg = err?.message || '';
370
+ if (!/already configured/i.test(msg)) throw err;
371
+ project = {
372
+ name: absolutePath.replace(/[\\/:]/g, '-').replace(/\./g, '-'),
373
+ path: absolutePath,
374
+ fullPath: absolutePath,
375
+ displayName: slot.name,
376
+ isManuallyAdded: true,
377
+ sessions: [],
378
+ cursorSessions: [],
379
+ };
380
+ }
381
+ return res.json({
382
+ success: true,
383
+ project,
384
+ suggestedName: slot.name,
385
+ reused: true,
386
+ });
273
387
  }
388
+ }
389
+
390
+ // 2. No idle slot — create the next free index above what exists.
391
+ const takenIndices = new Set(existingSlots.map((s) => s.index));
392
+ let nextIndex = 1;
393
+ while (takenIndices.has(nextIndex)) {
274
394
  nextIndex += 1;
275
395
  if (nextIndex > 9999) {
276
396
  return res.status(500).json({ error: 'No free pixcode-project slot (exhausted 1..9999)' });
277
397
  }
278
398
  }
279
-
399
+ const name = `pixcode-project-${nextIndex}`;
280
400
  const absolutePath = path.join(WORKSPACES_BASE, name);
281
401
  await fs.mkdir(absolutePath, { recursive: true });
282
402
  const project = await addProjectManually(absolutePath);
283
403
 
284
- res.json({ success: true, project, suggestedName: name });
404
+ res.json({ success: true, project, suggestedName: name, reused: false });
285
405
  } catch (error) {
286
406
  console.error('[projects] quick-start failed:', error);
287
407
  res.status(500).json({ error: error.message || 'Failed to quick-start project' });