@siteboon/claude-code-ui 1.21.0 → 1.22.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.
package/dist/index.html CHANGED
@@ -25,11 +25,11 @@
25
25
 
26
26
  <!-- Prevent zoom on iOS -->
27
27
  <meta name="format-detection" content="telephone=no" />
28
- <script type="module" crossorigin src="/assets/index-DN2ZJcRJ.js"></script>
28
+ <script type="module" crossorigin src="/assets/index-Br2fwqOq.js"></script>
29
29
  <link rel="modulepreload" crossorigin href="/assets/vendor-react-DIN4KjD2.js">
30
30
  <link rel="modulepreload" crossorigin href="/assets/vendor-codemirror-BMLq5tLB.js">
31
31
  <link rel="modulepreload" crossorigin href="/assets/vendor-xterm-CJZjLICi.js">
32
- <link rel="stylesheet" crossorigin href="/assets/index-Cxnz_sny.css">
32
+ <link rel="stylesheet" crossorigin href="/assets/index-B6iL1dXV.css">
33
33
  </head>
34
34
  <body>
35
35
  <div id="root"></div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@siteboon/claude-code-ui",
3
- "version": "1.21.0",
3
+ "version": "1.22.0",
4
4
  "description": "A web-based UI for Claude Code CLI",
5
5
  "type": "module",
6
6
  "main": "server/index.js",
@@ -78,6 +78,7 @@
78
78
  "i18next": "^25.7.4",
79
79
  "i18next-browser-languagedetector": "^8.2.0",
80
80
  "jsonwebtoken": "^9.0.2",
81
+ "jszip": "^3.10.1",
81
82
  "katex": "^0.16.25",
82
83
  "lucide-react": "^0.515.0",
83
84
  "mime-types": "^3.0.1",
@@ -593,9 +593,6 @@ async function queryClaudeSDK(command, options = {}, ws) {
593
593
  console.log('No session_id in message or already captured. message.session_id:', message.session_id, 'capturedSessionId:', capturedSessionId);
594
594
  }
595
595
 
596
- // logs which model was used in the message
597
- console.log("---> Model was sent using:", Object.keys(message.modelUsage || {}));
598
-
599
596
  // Transform and send message to WebSocket
600
597
  const transformedMessage = transformMessage(message);
601
598
  ws.send({
@@ -606,6 +603,10 @@ async function queryClaudeSDK(command, options = {}, ws) {
606
603
 
607
604
  // Extract and send token budget updates from result messages
608
605
  if (message.type === 'result') {
606
+ const models = Object.keys(message.modelUsage || {});
607
+ if (models.length > 0) {
608
+ console.log("---> Model was sent using:", models);
609
+ }
609
610
  const tokenBudget = extractTokenBudget(message);
610
611
  if (tokenBudget) {
611
612
  console.log('Token budget from modelUsage:', tokenBudget);
package/server/index.js CHANGED
@@ -884,6 +884,436 @@ app.get('/api/projects/:projectName/files', authenticateToken, async (req, res)
884
884
  }
885
885
  });
886
886
 
887
+ // ============================================================================
888
+ // FILE OPERATIONS API ENDPOINTS
889
+ // ============================================================================
890
+
891
+ /**
892
+ * Validate that a path is within the project root
893
+ * @param {string} projectRoot - The project root path
894
+ * @param {string} targetPath - The path to validate
895
+ * @returns {{ valid: boolean, resolved?: string, error?: string }}
896
+ */
897
+ function validatePathInProject(projectRoot, targetPath) {
898
+ const resolved = path.isAbsolute(targetPath)
899
+ ? path.resolve(targetPath)
900
+ : path.resolve(projectRoot, targetPath);
901
+ const normalizedRoot = path.resolve(projectRoot) + path.sep;
902
+ if (!resolved.startsWith(normalizedRoot)) {
903
+ return { valid: false, error: 'Path must be under project root' };
904
+ }
905
+ return { valid: true, resolved };
906
+ }
907
+
908
+ /**
909
+ * Validate filename - check for invalid characters
910
+ * @param {string} name - The filename to validate
911
+ * @returns {{ valid: boolean, error?: string }}
912
+ */
913
+ function validateFilename(name) {
914
+ if (!name || !name.trim()) {
915
+ return { valid: false, error: 'Filename cannot be empty' };
916
+ }
917
+ // Check for invalid characters (Windows + Unix)
918
+ const invalidChars = /[<>:"/\\|?*\x00-\x1f]/;
919
+ if (invalidChars.test(name)) {
920
+ return { valid: false, error: 'Filename contains invalid characters' };
921
+ }
922
+ // Check for reserved names (Windows)
923
+ const reserved = /^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])$/i;
924
+ if (reserved.test(name)) {
925
+ return { valid: false, error: 'Filename is a reserved name' };
926
+ }
927
+ // Check for dots only
928
+ if (/^\.+$/.test(name)) {
929
+ return { valid: false, error: 'Filename cannot be only dots' };
930
+ }
931
+ return { valid: true };
932
+ }
933
+
934
+ // POST /api/projects/:projectName/files/create - Create new file or directory
935
+ app.post('/api/projects/:projectName/files/create', authenticateToken, async (req, res) => {
936
+ try {
937
+ const { projectName } = req.params;
938
+ const { path: parentPath, type, name } = req.body;
939
+
940
+ // Validate input
941
+ if (!name || !type) {
942
+ return res.status(400).json({ error: 'Name and type are required' });
943
+ }
944
+
945
+ if (!['file', 'directory'].includes(type)) {
946
+ return res.status(400).json({ error: 'Type must be "file" or "directory"' });
947
+ }
948
+
949
+ const nameValidation = validateFilename(name);
950
+ if (!nameValidation.valid) {
951
+ return res.status(400).json({ error: nameValidation.error });
952
+ }
953
+
954
+ // Get project root
955
+ const projectRoot = await extractProjectDirectory(projectName).catch(() => null);
956
+ if (!projectRoot) {
957
+ return res.status(404).json({ error: 'Project not found' });
958
+ }
959
+
960
+ // Build and validate target path
961
+ const targetDir = parentPath || '';
962
+ const targetPath = targetDir ? path.join(targetDir, name) : name;
963
+ const validation = validatePathInProject(projectRoot, targetPath);
964
+ if (!validation.valid) {
965
+ return res.status(403).json({ error: validation.error });
966
+ }
967
+
968
+ const resolvedPath = validation.resolved;
969
+
970
+ // Check if already exists
971
+ try {
972
+ await fsPromises.access(resolvedPath);
973
+ return res.status(409).json({ error: `${type === 'file' ? 'File' : 'Directory'} already exists` });
974
+ } catch {
975
+ // Doesn't exist, which is what we want
976
+ }
977
+
978
+ // Create file or directory
979
+ if (type === 'directory') {
980
+ await fsPromises.mkdir(resolvedPath, { recursive: false });
981
+ } else {
982
+ // Ensure parent directory exists
983
+ const parentDir = path.dirname(resolvedPath);
984
+ try {
985
+ await fsPromises.access(parentDir);
986
+ } catch {
987
+ await fsPromises.mkdir(parentDir, { recursive: true });
988
+ }
989
+ await fsPromises.writeFile(resolvedPath, '', 'utf8');
990
+ }
991
+
992
+ res.json({
993
+ success: true,
994
+ path: resolvedPath,
995
+ name,
996
+ type,
997
+ message: `${type === 'file' ? 'File' : 'Directory'} created successfully`
998
+ });
999
+ } catch (error) {
1000
+ console.error('Error creating file/directory:', error);
1001
+ if (error.code === 'EACCES') {
1002
+ res.status(403).json({ error: 'Permission denied' });
1003
+ } else if (error.code === 'ENOENT') {
1004
+ res.status(404).json({ error: 'Parent directory not found' });
1005
+ } else {
1006
+ res.status(500).json({ error: error.message });
1007
+ }
1008
+ }
1009
+ });
1010
+
1011
+ // PUT /api/projects/:projectName/files/rename - Rename file or directory
1012
+ app.put('/api/projects/:projectName/files/rename', authenticateToken, async (req, res) => {
1013
+ try {
1014
+ const { projectName } = req.params;
1015
+ const { oldPath, newName } = req.body;
1016
+
1017
+ // Validate input
1018
+ if (!oldPath || !newName) {
1019
+ return res.status(400).json({ error: 'oldPath and newName are required' });
1020
+ }
1021
+
1022
+ const nameValidation = validateFilename(newName);
1023
+ if (!nameValidation.valid) {
1024
+ return res.status(400).json({ error: nameValidation.error });
1025
+ }
1026
+
1027
+ // Get project root
1028
+ const projectRoot = await extractProjectDirectory(projectName).catch(() => null);
1029
+ if (!projectRoot) {
1030
+ return res.status(404).json({ error: 'Project not found' });
1031
+ }
1032
+
1033
+ // Validate old path
1034
+ const oldValidation = validatePathInProject(projectRoot, oldPath);
1035
+ if (!oldValidation.valid) {
1036
+ return res.status(403).json({ error: oldValidation.error });
1037
+ }
1038
+
1039
+ const resolvedOldPath = oldValidation.resolved;
1040
+
1041
+ // Check if old path exists
1042
+ try {
1043
+ await fsPromises.access(resolvedOldPath);
1044
+ } catch {
1045
+ return res.status(404).json({ error: 'File or directory not found' });
1046
+ }
1047
+
1048
+ // Build and validate new path
1049
+ const parentDir = path.dirname(resolvedOldPath);
1050
+ const resolvedNewPath = path.join(parentDir, newName);
1051
+ const newValidation = validatePathInProject(projectRoot, resolvedNewPath);
1052
+ if (!newValidation.valid) {
1053
+ return res.status(403).json({ error: newValidation.error });
1054
+ }
1055
+
1056
+ // Check if new path already exists
1057
+ try {
1058
+ await fsPromises.access(resolvedNewPath);
1059
+ return res.status(409).json({ error: 'A file or directory with this name already exists' });
1060
+ } catch {
1061
+ // Doesn't exist, which is what we want
1062
+ }
1063
+
1064
+ // Rename
1065
+ await fsPromises.rename(resolvedOldPath, resolvedNewPath);
1066
+
1067
+ res.json({
1068
+ success: true,
1069
+ oldPath: resolvedOldPath,
1070
+ newPath: resolvedNewPath,
1071
+ newName,
1072
+ message: 'Renamed successfully'
1073
+ });
1074
+ } catch (error) {
1075
+ console.error('Error renaming file/directory:', error);
1076
+ if (error.code === 'EACCES') {
1077
+ res.status(403).json({ error: 'Permission denied' });
1078
+ } else if (error.code === 'ENOENT') {
1079
+ res.status(404).json({ error: 'File or directory not found' });
1080
+ } else if (error.code === 'EXDEV') {
1081
+ res.status(400).json({ error: 'Cannot move across different filesystems' });
1082
+ } else {
1083
+ res.status(500).json({ error: error.message });
1084
+ }
1085
+ }
1086
+ });
1087
+
1088
+ // DELETE /api/projects/:projectName/files - Delete file or directory
1089
+ app.delete('/api/projects/:projectName/files', authenticateToken, async (req, res) => {
1090
+ try {
1091
+ const { projectName } = req.params;
1092
+ const { path: targetPath, type } = req.body;
1093
+
1094
+ // Validate input
1095
+ if (!targetPath) {
1096
+ return res.status(400).json({ error: 'Path is required' });
1097
+ }
1098
+
1099
+ // Get project root
1100
+ const projectRoot = await extractProjectDirectory(projectName).catch(() => null);
1101
+ if (!projectRoot) {
1102
+ return res.status(404).json({ error: 'Project not found' });
1103
+ }
1104
+
1105
+ // Validate path
1106
+ const validation = validatePathInProject(projectRoot, targetPath);
1107
+ if (!validation.valid) {
1108
+ return res.status(403).json({ error: validation.error });
1109
+ }
1110
+
1111
+ const resolvedPath = validation.resolved;
1112
+
1113
+ // Check if path exists and get stats
1114
+ let stats;
1115
+ try {
1116
+ stats = await fsPromises.stat(resolvedPath);
1117
+ } catch {
1118
+ return res.status(404).json({ error: 'File or directory not found' });
1119
+ }
1120
+
1121
+ // Prevent deleting the project root itself
1122
+ if (resolvedPath === path.resolve(projectRoot)) {
1123
+ return res.status(403).json({ error: 'Cannot delete project root directory' });
1124
+ }
1125
+
1126
+ // Delete based on type
1127
+ if (stats.isDirectory()) {
1128
+ await fsPromises.rm(resolvedPath, { recursive: true, force: true });
1129
+ } else {
1130
+ await fsPromises.unlink(resolvedPath);
1131
+ }
1132
+
1133
+ res.json({
1134
+ success: true,
1135
+ path: resolvedPath,
1136
+ type: stats.isDirectory() ? 'directory' : 'file',
1137
+ message: 'Deleted successfully'
1138
+ });
1139
+ } catch (error) {
1140
+ console.error('Error deleting file/directory:', error);
1141
+ if (error.code === 'EACCES') {
1142
+ res.status(403).json({ error: 'Permission denied' });
1143
+ } else if (error.code === 'ENOENT') {
1144
+ res.status(404).json({ error: 'File or directory not found' });
1145
+ } else if (error.code === 'ENOTEMPTY') {
1146
+ res.status(400).json({ error: 'Directory is not empty' });
1147
+ } else {
1148
+ res.status(500).json({ error: error.message });
1149
+ }
1150
+ }
1151
+ });
1152
+
1153
+ // POST /api/projects/:projectName/files/upload - Upload files
1154
+ // Dynamic import of multer for file uploads
1155
+ const uploadFilesHandler = async (req, res) => {
1156
+ // Dynamic import of multer
1157
+ const multer = (await import('multer')).default;
1158
+
1159
+ const uploadMiddleware = multer({
1160
+ storage: multer.diskStorage({
1161
+ destination: (req, file, cb) => {
1162
+ cb(null, os.tmpdir());
1163
+ },
1164
+ filename: (req, file, cb) => {
1165
+ // Use a unique temp name, but preserve original name in file.originalname
1166
+ // Note: file.originalname may contain path separators for folder uploads
1167
+ const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
1168
+ // For temp file, just use a safe unique name without the path
1169
+ cb(null, `upload-${uniqueSuffix}`);
1170
+ }
1171
+ }),
1172
+ limits: {
1173
+ fileSize: 50 * 1024 * 1024, // 50MB limit
1174
+ files: 20 // Max 20 files at once
1175
+ }
1176
+ });
1177
+
1178
+ // Use multer middleware
1179
+ uploadMiddleware.array('files', 20)(req, res, async (err) => {
1180
+ if (err) {
1181
+ console.error('Multer error:', err);
1182
+ if (err.code === 'LIMIT_FILE_SIZE') {
1183
+ return res.status(400).json({ error: 'File too large. Maximum size is 50MB.' });
1184
+ }
1185
+ if (err.code === 'LIMIT_FILE_COUNT') {
1186
+ return res.status(400).json({ error: 'Too many files. Maximum is 20 files.' });
1187
+ }
1188
+ return res.status(500).json({ error: err.message });
1189
+ }
1190
+
1191
+ try {
1192
+ const { projectName } = req.params;
1193
+ const { targetPath, relativePaths } = req.body;
1194
+
1195
+ // Parse relative paths if provided (for folder uploads)
1196
+ let filePaths = [];
1197
+ if (relativePaths) {
1198
+ try {
1199
+ filePaths = JSON.parse(relativePaths);
1200
+ } catch (e) {
1201
+ console.log('[DEBUG] Failed to parse relativePaths:', relativePaths);
1202
+ }
1203
+ }
1204
+
1205
+ console.log('[DEBUG] File upload request:', {
1206
+ projectName,
1207
+ targetPath: JSON.stringify(targetPath),
1208
+ targetPathType: typeof targetPath,
1209
+ filesCount: req.files?.length,
1210
+ relativePaths: filePaths
1211
+ });
1212
+
1213
+ if (!req.files || req.files.length === 0) {
1214
+ return res.status(400).json({ error: 'No files provided' });
1215
+ }
1216
+
1217
+ // Get project root
1218
+ const projectRoot = await extractProjectDirectory(projectName).catch(() => null);
1219
+ if (!projectRoot) {
1220
+ return res.status(404).json({ error: 'Project not found' });
1221
+ }
1222
+
1223
+ console.log('[DEBUG] Project root:', projectRoot);
1224
+
1225
+ // Validate and resolve target path
1226
+ // If targetPath is empty or '.', use project root directly
1227
+ const targetDir = targetPath || '';
1228
+ let resolvedTargetDir;
1229
+
1230
+ console.log('[DEBUG] Target dir:', JSON.stringify(targetDir));
1231
+
1232
+ if (!targetDir || targetDir === '.' || targetDir === './') {
1233
+ // Empty path means upload to project root
1234
+ resolvedTargetDir = path.resolve(projectRoot);
1235
+ console.log('[DEBUG] Using project root as target:', resolvedTargetDir);
1236
+ } else {
1237
+ const validation = validatePathInProject(projectRoot, targetDir);
1238
+ if (!validation.valid) {
1239
+ console.log('[DEBUG] Path validation failed:', validation.error);
1240
+ return res.status(403).json({ error: validation.error });
1241
+ }
1242
+ resolvedTargetDir = validation.resolved;
1243
+ console.log('[DEBUG] Resolved target dir:', resolvedTargetDir);
1244
+ }
1245
+
1246
+ // Ensure target directory exists
1247
+ try {
1248
+ await fsPromises.access(resolvedTargetDir);
1249
+ } catch {
1250
+ await fsPromises.mkdir(resolvedTargetDir, { recursive: true });
1251
+ }
1252
+
1253
+ // Move uploaded files from temp to target directory
1254
+ const uploadedFiles = [];
1255
+ console.log('[DEBUG] Processing files:', req.files.map(f => ({ originalname: f.originalname, path: f.path })));
1256
+ for (let i = 0; i < req.files.length; i++) {
1257
+ const file = req.files[i];
1258
+ // Use relative path if provided (for folder uploads), otherwise use originalname
1259
+ const fileName = (filePaths && filePaths[i]) ? filePaths[i] : file.originalname;
1260
+ console.log('[DEBUG] Processing file:', fileName, '(originalname:', file.originalname + ')');
1261
+ const destPath = path.join(resolvedTargetDir, fileName);
1262
+
1263
+ // Validate destination path
1264
+ const destValidation = validatePathInProject(projectRoot, destPath);
1265
+ if (!destValidation.valid) {
1266
+ console.log('[DEBUG] Destination validation failed for:', destPath);
1267
+ // Clean up temp file
1268
+ await fsPromises.unlink(file.path).catch(() => {});
1269
+ continue;
1270
+ }
1271
+
1272
+ // Ensure parent directory exists (for nested files from folder upload)
1273
+ const parentDir = path.dirname(destPath);
1274
+ try {
1275
+ await fsPromises.access(parentDir);
1276
+ } catch {
1277
+ await fsPromises.mkdir(parentDir, { recursive: true });
1278
+ }
1279
+
1280
+ // Move file (copy + unlink to handle cross-device scenarios)
1281
+ await fsPromises.copyFile(file.path, destPath);
1282
+ await fsPromises.unlink(file.path);
1283
+
1284
+ uploadedFiles.push({
1285
+ name: fileName,
1286
+ path: destPath,
1287
+ size: file.size,
1288
+ mimeType: file.mimetype
1289
+ });
1290
+ }
1291
+
1292
+ res.json({
1293
+ success: true,
1294
+ files: uploadedFiles,
1295
+ targetPath: resolvedTargetDir,
1296
+ message: `Uploaded ${uploadedFiles.length} file(s) successfully`
1297
+ });
1298
+ } catch (error) {
1299
+ console.error('Error uploading files:', error);
1300
+ // Clean up any remaining temp files
1301
+ if (req.files) {
1302
+ for (const file of req.files) {
1303
+ await fsPromises.unlink(file.path).catch(() => {});
1304
+ }
1305
+ }
1306
+ if (error.code === 'EACCES') {
1307
+ res.status(403).json({ error: 'Permission denied' });
1308
+ } else {
1309
+ res.status(500).json({ error: error.message });
1310
+ }
1311
+ }
1312
+ });
1313
+ };
1314
+
1315
+ app.post('/api/projects/:projectName/files/upload', authenticateToken, uploadFilesHandler);
1316
+
887
1317
  // WebSocket connection handler that routes based on URL path
888
1318
  wss.on('connection', (ws, request) => {
889
1319
  const url = request.url;
@@ -1218,7 +1648,7 @@ function handleShellConnection(ws) {
1218
1648
  if (hasSession && sessionId) {
1219
1649
  try {
1220
1650
  // Gemini CLI enforces its own native session IDs, unlike other agents that accept arbitrary string names.
1221
- // The UI only knows about its internal generated `sessionId` (e.g. gemini_1234).
1651
+ // The UI only knows about its internal generated `sessionId` (e.g. gemini_1234).
1222
1652
  // We must fetch the mapping from the backend session manager to pass the native `cliSessionId` to the shell.
1223
1653
  const sess = sessionManager.getSession(sessionId);
1224
1654
  if (sess && sess.cliSessionId) {
@@ -1791,8 +2221,8 @@ app.get('/api/projects/:projectName/sessions/:sessionId/token-usage', authentica
1791
2221
 
1792
2222
  // Construct the JSONL file path
1793
2223
  // Claude stores session files in ~/.claude/projects/[encoded-project-path]/[session-id].jsonl
1794
- // The encoding replaces /, spaces, ~, and _ with -
1795
- const encodedPath = projectPath.replace(/[\\/:\s~_]/g, '-');
2224
+ // The encoding replaces any non-alphanumeric character (except -) with -
2225
+ const encodedPath = projectPath.replace(/[^a-zA-Z0-9-]/g, '-');
1796
2226
  const projectDir = path.join(homeDir, '.claude', 'projects', encodedPath);
1797
2227
 
1798
2228
  const jsonlPath = path.join(projectDir, `${safeSessionId}.jsonl`);