@hapico/cli 0.0.27 → 0.0.29

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.
@@ -3,16 +3,48 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
- exports.vibe = void 0;
6
+ exports.vibe = exports.applyPatch = void 0;
7
7
  /* eslint-disable @typescript-eslint/no-explicit-any */
8
8
  const promises_1 = __importDefault(require("node:fs/promises"));
9
9
  const node_path_1 = __importDefault(require("node:path"));
10
- const node_child_process_1 = require("node:child_process");
11
- const node_util_1 = __importDefault(require("node:util"));
10
+ const diff_match_patch_1 = require("diff-match-patch");
12
11
  const express_1 = __importDefault(require("express"));
13
12
  const open_1 = __importDefault(require("open"));
14
- const execAsync = node_util_1.default.promisify(node_child_process_1.exec);
15
13
  const node_net_1 = __importDefault(require("node:net"));
14
+ const scanRecursively = async (dir, baseDir) => {
15
+ let results = [];
16
+ try {
17
+ const list = await promises_1.default.readdir(dir, { withFileTypes: true });
18
+ for (const entry of list) {
19
+ const fullPath = node_path_1.default.join(dir, entry.name);
20
+ const relativePath = node_path_1.default.relative(baseDir, fullPath);
21
+ if (shouldIgnorePath(relativePath))
22
+ continue;
23
+ if (entry.isDirectory()) {
24
+ const subResults = await scanRecursively(fullPath, baseDir);
25
+ results = results.concat(subResults);
26
+ }
27
+ else if (entry.isFile()) {
28
+ try {
29
+ const content = await promises_1.default.readFile(fullPath, "utf-8");
30
+ results.push({
31
+ path: relativePath,
32
+ fullPath: fullPath,
33
+ content: content,
34
+ extension: entry.name.split(".").pop() || "txt",
35
+ });
36
+ }
37
+ catch (err) {
38
+ // Bỏ qua file nhị phân hoặc không đọc được
39
+ }
40
+ }
41
+ }
42
+ }
43
+ catch (error) {
44
+ // Bỏ qua nếu lỗi permission hoặc path không tồn tại
45
+ }
46
+ return results;
47
+ };
16
48
  // random port from 3000-3999
17
49
  // Check if port is available would be better in real-world usage
18
50
  const isPortInUse = (port) => {
@@ -81,98 +113,226 @@ const shouldIgnorePath = (filePath) => {
81
113
  }
82
114
  return false;
83
115
  };
84
- const scanRecursively = async (dir, baseDir) => {
85
- let results = [];
86
- try {
87
- const list = await promises_1.default.readdir(dir, { withFileTypes: true });
88
- for (const entry of list) {
89
- const fullPath = node_path_1.default.join(dir, entry.name);
90
- const relativePath = node_path_1.default.relative(baseDir, fullPath);
91
- if (shouldIgnorePath(relativePath))
92
- continue;
93
- if (entry.isDirectory()) {
94
- const subResults = await scanRecursively(fullPath, baseDir);
95
- results = results.concat(subResults);
96
- }
97
- else if (entry.isFile()) {
98
- try {
99
- const content = await promises_1.default.readFile(fullPath, "utf-8");
100
- results.push({
101
- path: relativePath,
102
- fullPath: fullPath,
103
- content: content,
104
- extension: entry.name.split(".").pop() || "txt",
105
- });
106
- }
107
- catch (err) {
108
- // Bỏ qua file nhị phân hoặc không đọc được
109
- }
110
- }
111
- }
112
- }
113
- catch (error) {
114
- // Bỏ qua nếu lỗi permission hoặc path không tồn tại
116
+ /**
117
+ * ------------------------------------------------------------------
118
+ * HELPER: STRING SIMILARITY (Levenshtein based)
119
+ * Tính độ giống nhau giữa 2 chuỗi (0 -> 1)
120
+ * ------------------------------------------------------------------
121
+ */
122
+ const calculateSimilarity = (s1, s2) => {
123
+ const longer = s1.length > s2.length ? s1 : s2;
124
+ const shorter = s1.length > s2.length ? s2 : s1;
125
+ const longerLength = longer.length;
126
+ if (longerLength === 0) {
127
+ return 1.0;
115
128
  }
116
- return results;
117
- };
118
- const areLinesFuzzyEqual = (line1, line2) => {
119
- return line1.trim() === line2.trim();
129
+ // Sử dụng dmp để tính Levenshtein distance cho chính xác và nhanh
130
+ const dmp = new diff_match_patch_1.diff_match_patch();
131
+ const diffs = dmp.diff_main(longer, shorter);
132
+ const levenshtein = dmp.diff_levenshtein(diffs);
133
+ return (longerLength - levenshtein) / longerLength;
120
134
  };
121
- const performFuzzyReplace = (originalContent, searchBlock, replaceBlock) => {
122
- const originalLines = originalContent.split(/\r?\n/);
123
- const searchLines = searchBlock.split(/\r?\n/);
124
- while (searchLines.length > 0 && searchLines[0].trim() === "") {
135
+ /**
136
+ * ------------------------------------------------------------------
137
+ * HELPER: FUZZY LINE MATCHING
138
+ * Tìm đoạn code khớp nhất trong file gốc dựa trên điểm số (Confidence)
139
+ * ------------------------------------------------------------------
140
+ */
141
+ const applyFuzzyLineMatch = (original, search, replace, threshold = 0.85 // Ngưỡng chấp nhận (85% giống nhau)
142
+ ) => {
143
+ const originalLines = original.split(/\r?\n/);
144
+ const searchLines = search.split(/\r?\n/);
145
+ // Lọc bỏ dòng trống ở đầu/cuối search block để match chính xác hơn
146
+ while (searchLines.length > 0 && searchLines[0].trim() === "")
125
147
  searchLines.shift();
126
- }
127
- while (searchLines.length > 0 &&
128
- searchLines[searchLines.length - 1].trim() === "") {
148
+ while (searchLines.length > 0 && searchLines[searchLines.length - 1].trim() === "")
129
149
  searchLines.pop();
130
- }
131
150
  if (searchLines.length === 0)
132
- return null;
151
+ return { result: null, confidence: 0 };
152
+ let bestMatchIndex = -1;
153
+ let bestMatchScore = 0;
154
+ // Duyệt qua file gốc (Sliding Window)
155
+ // Chỉ cần duyệt đến vị trí mà số dòng còn lại đủ để chứa searchLines
133
156
  for (let i = 0; i <= originalLines.length - searchLines.length; i++) {
134
- let match = true;
157
+ let currentScoreTotal = 0;
158
+ let possible = true;
135
159
  for (let j = 0; j < searchLines.length; j++) {
136
- if (!areLinesFuzzyEqual(originalLines[i + j], searchLines[j])) {
137
- match = false;
160
+ const originalLine = originalLines[i + j].trim();
161
+ const searchLine = searchLines[j].trim();
162
+ // So sánh nhanh: Nếu giống hệt nhau
163
+ if (originalLine === searchLine) {
164
+ currentScoreTotal += 1;
165
+ continue;
166
+ }
167
+ // So sánh chậm: Tính độ tương đồng
168
+ const similarity = calculateSimilarity(originalLine, searchLine);
169
+ // Nếu một dòng quá khác biệt (< 60%), coi như block này không khớp
170
+ // (Giúp tối ưu hiệu năng, cắt nhánh sớm)
171
+ if (similarity < 0.6) {
172
+ possible = false;
138
173
  break;
139
174
  }
175
+ currentScoreTotal += similarity;
140
176
  }
141
- if (match) {
142
- const before = originalLines.slice(0, i).join("\n");
143
- const after = originalLines.slice(i + searchLines.length).join("\n");
144
- return `${before}\n${replaceBlock}\n${after}`;
177
+ if (possible) {
178
+ const avgScore = currentScoreTotal / searchLines.length;
179
+ if (avgScore > bestMatchScore) {
180
+ bestMatchScore = avgScore;
181
+ bestMatchIndex = i;
182
+ }
145
183
  }
146
184
  }
147
- return null;
185
+ // LOG CONFIDENCE ĐỂ DEBUG
186
+ if (bestMatchScore > 0) {
187
+ console.log(`[FuzzyMatch] Best match score: ${(bestMatchScore * 100).toFixed(2)}% at line ${bestMatchIndex + 1}`);
188
+ }
189
+ // Nếu tìm thấy vị trí có độ tin cậy cao hơn ngưỡng
190
+ if (bestMatchIndex !== -1 && bestMatchScore >= threshold) {
191
+ const before = originalLines.slice(0, bestMatchIndex);
192
+ // Lưu ý: Đoạn replace thay thế đúng số lượng dòng của searchLines
193
+ // (Giả định search và match trong original có cùng số dòng hiển thị)
194
+ const after = originalLines.slice(bestMatchIndex + searchLines.length);
195
+ return {
196
+ result: [...before, replace, ...after].join("\n"),
197
+ confidence: bestMatchScore
198
+ };
199
+ }
200
+ return { result: null, confidence: bestMatchScore };
148
201
  };
149
- const applyPatch = (originalContent, patchContent) => {
150
- let result = originalContent;
151
- const patchRegex = /<<<<<<< SEARCH\s*\n([\s\S]*?)\n?=======\s*\n([\s\S]*?)\n?>>>>>>> REPLACE/g;
152
- const matches = [...patchContent.matchAll(patchRegex)];
153
- if (matches.length === 0) {
154
- return originalContent;
202
+ /**
203
+ * ------------------------------------------------------------------
204
+ * 1. STATE MACHINE PARSER (FILE-AWARE)
205
+ * ------------------------------------------------------------------
206
+ */
207
+ const parseLLMPatch = (patchContent) => {
208
+ const lines = patchContent.split(/\r?\n/);
209
+ const filePatches = {};
210
+ let currentFile = "unknown";
211
+ let inSearch = false;
212
+ let inReplace = false;
213
+ let searchLines = [];
214
+ let replaceLines = [];
215
+ const isFileMarker = (line) => {
216
+ const match = line.match(/^### File:\s*[`']?([^`'\s]+)[`']?/i);
217
+ return match ? match[1] : null;
218
+ };
219
+ const isSearchMarker = (line) => /^\s*<{7,}\s*SEARCH\s*$/.test(line);
220
+ const isDividerMarker = (line) => /^\s*={7,}\s*$/.test(line);
221
+ const isEndMarker = (line) => /^\s*>{7,}\s*REPLACE\s*$/.test(line);
222
+ for (const line of lines) {
223
+ const fileName = isFileMarker(line);
224
+ if (fileName) {
225
+ currentFile = fileName;
226
+ continue;
227
+ }
228
+ if (isSearchMarker(line)) {
229
+ inSearch = true;
230
+ inReplace = false;
231
+ searchLines = [];
232
+ replaceLines = [];
233
+ continue;
234
+ }
235
+ if (isDividerMarker(line)) {
236
+ if (inSearch) {
237
+ inSearch = false;
238
+ inReplace = true;
239
+ }
240
+ continue;
241
+ }
242
+ if (isEndMarker(line)) {
243
+ if (inReplace || inSearch) {
244
+ if (searchLines.length > 0 || replaceLines.length > 0) {
245
+ if (!filePatches[currentFile])
246
+ filePatches[currentFile] = [];
247
+ filePatches[currentFile].push({
248
+ search: searchLines.join("\n"),
249
+ replace: replaceLines.join("\n")
250
+ });
251
+ }
252
+ }
253
+ inSearch = false;
254
+ inReplace = false;
255
+ searchLines = [];
256
+ replaceLines = [];
257
+ continue;
258
+ }
259
+ if (inSearch)
260
+ searchLines.push(line);
261
+ else if (inReplace)
262
+ replaceLines.push(line);
155
263
  }
156
- for (const match of matches) {
157
- const [_, searchBlock, replaceBlock] = match;
158
- if (result.includes(searchBlock)) {
159
- result = result.replace(searchBlock, replaceBlock);
264
+ return filePatches;
265
+ };
266
+ /**
267
+ * ------------------------------------------------------------------
268
+ * 2. APPLY PATCH (UPDATED)
269
+ * ------------------------------------------------------------------
270
+ */
271
+ const applyPatch = (originalContent, patches) => {
272
+ if (!patches || patches.length === 0)
273
+ return originalContent;
274
+ let result = originalContent;
275
+ for (const patch of patches) {
276
+ const { search, replace } = patch;
277
+ // NẾU SEARCH RỖNG (INSERT/APPEND)
278
+ if (search.trim() === "") {
279
+ console.log(`[ApplyPatch] Inserting/appending block.`);
280
+ result += `\n${replace}`;
160
281
  continue;
161
282
  }
162
- const trimmedSearch = searchBlock.trim();
163
- const trimmedReplace = replaceBlock.trim();
283
+ // --- STRATEGY 1: EXACT MATCH ---
284
+ if (result.includes(search)) {
285
+ console.log(`[ApplyPatch] Exact match found.`);
286
+ result = result.replace(search, replace);
287
+ continue;
288
+ }
289
+ // --- STRATEGY 2: NORMALIZED MATCH (Trim) ---
290
+ const trimmedSearch = search.trim();
164
291
  if (result.includes(trimmedSearch)) {
165
- result = result.replace(trimmedSearch, trimmedReplace);
292
+ console.log(`[ApplyPatch] Trimmed match found.`);
293
+ result = result.replace(trimmedSearch, replace.trim());
166
294
  continue;
167
295
  }
168
- const fuzzyResult = performFuzzyReplace(result, searchBlock, replaceBlock);
169
- if (fuzzyResult) {
170
- result = fuzzyResult;
296
+ // --- STRATEGY 3: FUZZY LINE MATCH (BEST FOR CODE) ---
297
+ // Tìm vị trí tốt nhất dựa trên similarity score của từng dòng
298
+ const fuzzyMatch = applyFuzzyLineMatch(result, search, replace, 0.80); // 80% confidence
299
+ if (fuzzyMatch.result !== null) {
300
+ console.log(`[ApplyPatch] Fuzzy line match applied (Confidence: ${(fuzzyMatch.confidence * 100).toFixed(0)}%)`);
301
+ result = fuzzyMatch.result;
171
302
  continue;
172
303
  }
304
+ else {
305
+ console.warn(`[ApplyPatch] Failed to match block. Max confidence was: ${(fuzzyMatch.confidence * 100).toFixed(0)}%`);
306
+ }
307
+ // --- STRATEGY 4: DMP FALLBACK (LAST RESORT) ---
308
+ // Nếu cả fuzzy line match cũng tạch (ví dụ do search block quá ngắn hoặc cấu trúc quá nát)
309
+ // thì mới dùng DMP patch_apply.
310
+ try {
311
+ const dmp = new diff_match_patch_1.diff_match_patch();
312
+ dmp.Match_Threshold = 0.5;
313
+ dmp.Match_Distance = 1000;
314
+ const dmpPatches = dmp.patch_make(search, replace);
315
+ const [newText, applyResults] = dmp.patch_apply(dmpPatches, result);
316
+ // Kiểm tra xem có apply được hết các hunk không
317
+ const successCount = applyResults.filter(Boolean).length;
318
+ const successRate = successCount / applyResults.length;
319
+ if (successRate === 1 && newText !== result) {
320
+ console.log(`[ApplyPatch] DMP Patch applied via fuzzy logic.`);
321
+ result = newText;
322
+ }
323
+ else {
324
+ console.error(`[ApplyPatch] All strategies failed for block starting with: ${search.substring(0, 30)}...`);
325
+ // Tuỳ chọn: Throw error để báo cho LLM biết là apply thất bại
326
+ // throw new Error("Could not apply patch block");
327
+ }
328
+ }
329
+ catch (e) {
330
+ console.error(`[ApplyPatch] DMP error:`, e);
331
+ }
173
332
  }
174
333
  return result;
175
334
  };
335
+ exports.applyPatch = applyPatch;
176
336
  // ==========================================
177
337
  // 3. WEB UI SERVER (UPDATED UI)
178
338
  // ==========================================
@@ -199,15 +359,42 @@ const startServer = async (rootDir) => {
199
359
  });
200
360
  // API 3: Apply Patch
201
361
  app.post("/api/apply", async (req, res) => {
202
- const { llmResponse, filePaths } = req.body;
362
+ var _a;
363
+ const { llmResponse, filePaths: selectedPaths } = req.body;
364
+ // 1. Parse all patches from LLM response
365
+ const filePatches = parseLLMPatch(llmResponse);
366
+ // 2. Scan project files
203
367
  const allFiles = await scanRecursively(rootDir, rootDir);
204
- const selectedFiles = allFiles.filter((f) => filePaths.includes(f.path));
205
368
  const modifiedFiles = [];
206
- for (const file of selectedFiles) {
207
- const newContent = applyPatch(file.content, llmResponse);
208
- if (newContent !== file.content) {
209
- await promises_1.default.writeFile(file.fullPath, newContent, "utf-8");
210
- modifiedFiles.push(file.path);
369
+ // 3. Match patches to files and apply
370
+ for (const file of allFiles) {
371
+ // Find patches meant for this specific file path
372
+ // Note: LLM might use different path separators, so we normalize
373
+ const normalizedPath = file.path.replace(/\\/g, '/');
374
+ const patchesForFile = (_a = Object.entries(filePatches).find(([path]) => {
375
+ const normalizedKey = path.replace(/\\/g, '/');
376
+ return normalizedKey === normalizedPath || normalizedPath.endsWith('/' + normalizedKey);
377
+ })) === null || _a === void 0 ? void 0 : _a[1];
378
+ // Only apply if the file was selected in UI AND has patches in the response
379
+ if (selectedPaths.includes(file.path) && patchesForFile) {
380
+ console.log(`[Apply] Processing ${patchesForFile.length} blocks for: ${file.path}`);
381
+ const newContent = (0, exports.applyPatch)(file.content, patchesForFile);
382
+ if (newContent !== file.content) {
383
+ await promises_1.default.writeFile(file.fullPath, newContent, "utf-8");
384
+ modifiedFiles.push(file.path);
385
+ }
386
+ }
387
+ }
388
+ // Fallback: If no matched files but only one file was selected, try applying "unknown" patches
389
+ if (modifiedFiles.length === 0 && selectedPaths.length === 1 && filePatches["unknown"]) {
390
+ const file = allFiles.find(f => f.path === selectedPaths[0]);
391
+ if (file) {
392
+ console.log(`[Apply] Fallback: Applying unknown blocks to single selected file: ${file.path}`);
393
+ const newContent = (0, exports.applyPatch)(file.content, filePatches["unknown"]);
394
+ if (newContent !== file.content) {
395
+ await promises_1.default.writeFile(file.fullPath, newContent, "utf-8");
396
+ modifiedFiles.push(file.path);
397
+ }
211
398
  }
212
399
  }
213
400
  res.json({ modified: modifiedFiles });
@@ -335,7 +522,8 @@ const startServer = async (rootDir) => {
335
522
 
336
523
  <div class="flex-1 flex flex-col bg-gray-850 min-w-0">
337
524
 
338
- <div class="flex-shrink-0 p-6 border-b border-apple-border">
525
+ <!-- 1. Instruction (Takes more space and resizable) -->
526
+ <div class="flex-1 p-6 flex flex-col min-h-0 border-b border-apple-border">
339
527
  <div class="flex justify-between items-end mb-3">
340
528
  <div>
341
529
  <h2 class="text-sm font-semibold text-gray-200">1. Instruction</h2>
@@ -351,12 +539,12 @@ const startServer = async (rootDir) => {
351
539
  </button>
352
540
  </div>
353
541
  <textarea x-model="instruction"
354
- class="w-full bg-apple-panel border border-apple-border rounded p-3 text-sm text-gray-200 placeholder-gray-600 focus:outline-none focus:border-apple-blue/50 focus:ring-1 focus:ring-apple-blue/50 transition resize-none font-mono"
355
- rows="3"
542
+ class="w-full flex-1 bg-apple-panel border border-apple-border rounded p-3 text-sm text-gray-200 placeholder-gray-600 focus:outline-none focus:border-apple-blue/50 focus:ring-1 focus:ring-apple-blue/50 transition resize-y font-mono min-h-[120px]"
356
543
  placeholder="e.g. Refactor the Button component to use TypeScript interfaces..."></textarea>
357
544
  </div>
358
545
 
359
- <div class="flex-1 p-6 flex flex-col min-h-0">
546
+ <!-- 2. LLM Patch (Fixed smaller size) -->
547
+ <div class="h-64 p-6 flex flex-col flex-shrink-0">
360
548
  <div class="flex justify-between items-end mb-3">
361
549
  <div>
362
550
  <h2 class="text-sm font-semibold text-gray-200">2. LLM Patch</h2>
@@ -369,11 +557,9 @@ const startServer = async (rootDir) => {
369
557
  <span x-show="applying">Applying...</span>
370
558
  </button>
371
559
  </div>
372
- <div class="flex-1 relative">
373
- <textarea x-model="llmResponse"
374
- class="absolute inset-0 w-full h-full bg-apple-panel border border-apple-border rounded p-3 text-sm text-gray-300 font-mono placeholder-gray-600 focus:outline-none focus:border-emerald-500/50 focus:ring-1 focus:ring-emerald-500/50 transition resize-none leading-relaxed"
375
- placeholder="<<<<<<< SEARCH..."></textarea>
376
- </div>
560
+ <textarea x-model="llmResponse"
561
+ class="w-full h-40 bg-apple-panel border border-apple-border rounded p-3 text-sm text-gray-300 font-mono placeholder-gray-600 focus:outline-none focus:border-emerald-500/50 focus:ring-1 focus:ring-emerald-500/50 transition resize-none leading-relaxed"
562
+ placeholder="<<<<<<< SEARCH..."></textarea>
377
563
  </div>
378
564
 
379
565
  </div>
package/index.ts CHANGED
@@ -228,8 +228,18 @@ class FileManager {
228
228
  }
229
229
 
230
230
  private writeFile(file: FileContent): void {
231
+ if (!file.path || file.path.trim() === "") return;
231
232
  try {
232
233
  const fullPath = path.join(this.basePath, file.path);
234
+
235
+ if (fs.existsSync(fullPath)) {
236
+ try {
237
+ if (fs.statSync(fullPath).isDirectory()) return;
238
+ } catch (e) {
239
+ // ignore
240
+ }
241
+ }
242
+
233
243
  const dir = path.dirname(fullPath);
234
244
  fs.mkdirSync(dir, { recursive: true });
235
245
 
@@ -240,7 +250,8 @@ class FileManager {
240
250
  encoding: "utf8",
241
251
  });
242
252
  hasChanged = existingContent !== file.content;
243
- } catch (readError) {
253
+ } catch (readError: any) {
254
+ if (readError.code === "EISDIR") return;
244
255
  console.warn(
245
256
  chalk.yellow(
246
257
  `Warning: Could not read existing file ${fullPath}, treating as changed:`
@@ -254,7 +265,8 @@ class FileManager {
254
265
  if (hasChanged) {
255
266
  fs.writeFileSync(fullPath, file.content, { encoding: "utf8" });
256
267
  }
257
- } catch (error) {
268
+ } catch (error: any) {
269
+ if (error.code === "EISDIR") return;
258
270
  console.error(chalk.red(`Error processing file ${file.path}:`), error);
259
271
  throw error;
260
272
  }
@@ -471,7 +483,7 @@ class RoomState {
471
483
  }
472
484
  }
473
485
 
474
- program.version("0.0.27").description("Hapico CLI for project management");
486
+ program.version("0.0.29").description("Hapico CLI for project management");
475
487
 
476
488
  program
477
489
  .command("clone <id>")
@@ -784,7 +796,7 @@ program
784
796
  const zversion = options.zversion;
785
797
 
786
798
  if (projectType === "expo_app" || projectType === "emg_edu_lesson") {
787
- const BASE_EXPO_LINK = `exp://u.expo.dev/e362c6df-abe8-4503-8723-1362f015d167/group/abdb7abf-bcf7-4f39-a527-dc4d277b74c0`;
799
+ const BASE_EXPO_LINK = `exp://u.expo.dev/e362c6df-abe8-4503-8723-1362f015d167/group/e5d6b96e-3c78-485a-a170-1d8aa8b2c47e`;
788
800
  const link = `${BASE_EXPO_LINK}?sessionKey=${projectId}&mode=development`;
789
801
  QRCode.generate(link, { small: true }, (qrcode) => {
790
802
  console.log(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hapico/cli",
3
- "version": "0.0.27",
3
+ "version": "0.0.29",
4
4
  "description": "A simple CLI tool for project management",
5
5
  "main": "index.js",
6
6
  "bin": {
@@ -23,6 +23,7 @@
23
23
  "@babel/standalone": "^7.28.2",
24
24
  "axios": "^1.11.0",
25
25
  "commander": "^14.0.0",
26
+ "diff-match-patch": "^1.0.5",
26
27
  "express": "^5.2.1",
27
28
  "inquirer": "^12.9.6",
28
29
  "lodash": "^4.17.21",
@@ -38,6 +39,7 @@
38
39
  "devDependencies": {
39
40
  "@types/babel__standalone": "^7.1.9",
40
41
  "@types/commander": "^2.12.5",
42
+ "@types/diff-match-patch": "^1.0.36",
41
43
  "@types/express": "^5.0.6",
42
44
  "@types/lodash": "^4.17.20",
43
45
  "@types/node": "^24.1.0",