codemini-cli 0.1.19 → 0.2.1

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.
@@ -0,0 +1,510 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import crypto from 'node:crypto';
4
+ import { getFileIndexPath, getProjectIndexDir, getProjectMapPath, getProjectWorkspaceDir } from './paths.js';
5
+
6
+ const SKIP_DIRS = new Set(['.git', 'node_modules', '.codemini', '.codemini-project', '.codemini-global', 'dist', 'coverage']);
7
+ const PROJECT_MARKER_FILES = new Set([
8
+ 'package.json',
9
+ 'tsconfig.json',
10
+ 'pyproject.toml',
11
+ 'requirements.txt',
12
+ 'go.mod',
13
+ 'Cargo.toml',
14
+ 'composer.json',
15
+ 'Gemfile',
16
+ 'pom.xml',
17
+ 'build.gradle',
18
+ 'build.gradle.kts',
19
+ 'Makefile',
20
+ '.gitignore'
21
+ ]);
22
+ const SOURCE_EXTENSIONS = new Set([
23
+ '.js', '.jsx', '.mjs', '.cjs', '.ts', '.tsx', '.py', '.go', '.c', '.h', '.cpp', '.cc', '.cxx', '.hpp', '.hh',
24
+ '.sh', '.bash', '.java', '.rs', '.cs', '.php', '.rb'
25
+ ]);
26
+ const LANGUAGE_BY_EXT = {
27
+ '.js': 'js', '.jsx': 'jsx', '.mjs': 'js', '.cjs': 'js', '.ts': 'ts', '.tsx': 'tsx', '.py': 'python',
28
+ '.go': 'go', '.c': 'c', '.h': 'c', '.cpp': 'cpp', '.cc': 'cpp', '.cxx': 'cpp', '.hpp': 'cpp', '.hh': 'cpp',
29
+ '.sh': 'bash', '.bash': 'bash', '.java': 'java', '.rs': 'rust', '.cs': 'csharp', '.php': 'php', '.rb': 'ruby'
30
+ };
31
+
32
+ const initCache = new Map();
33
+ const PROJECT_CONTEXT_MAX_FILES = 6;
34
+
35
+ function sha1(input) {
36
+ return crypto.createHash('sha1').update(String(input || '')).digest('hex');
37
+ }
38
+
39
+ function clipList(values, max = 32) {
40
+ return [...new Set((Array.isArray(values) ? values : []).filter(Boolean))].slice(0, max);
41
+ }
42
+
43
+ function rel(cwd, filePath) {
44
+ return path.relative(cwd, filePath).replace(/\\/g, '/');
45
+ }
46
+
47
+ function normalizeRelativePath(value) {
48
+ return String(value || '').replace(/\\/g, '/').replace(/^\.\/+/, '').replace(/^\/+/, '');
49
+ }
50
+
51
+ async function safeStat(filePath) {
52
+ try {
53
+ return await fs.stat(filePath);
54
+ } catch {
55
+ return null;
56
+ }
57
+ }
58
+
59
+ async function safeReadJson(filePath, fallback) {
60
+ try {
61
+ return JSON.parse(await fs.readFile(filePath, 'utf8'));
62
+ } catch {
63
+ return fallback;
64
+ }
65
+ }
66
+
67
+ function tokenizeQuery(text) {
68
+ return [...new Set(String(text || '').toLowerCase().match(/[a-z0-9_./-]+/g) || [])].filter(Boolean);
69
+ }
70
+
71
+ function trimInline(value, max = 240) {
72
+ const text = String(value || '').replace(/\s+/g, ' ').trim();
73
+ if (!text) return '';
74
+ if (text.length <= max) return text;
75
+ return `${text.slice(0, max - 3)}...`;
76
+ }
77
+
78
+ function trimMultiline(value, max = 1800) {
79
+ const text = String(value || '').trim();
80
+ if (!text) return '';
81
+ if (text.length <= max) return text;
82
+ return `${text.slice(0, max - 3).trimEnd()}...`;
83
+ }
84
+
85
+ async function writeJson(filePath, value) {
86
+ await fs.mkdir(path.dirname(filePath), { recursive: true });
87
+ await fs.writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`, 'utf8');
88
+ }
89
+
90
+ function escapeRegex(value) {
91
+ return String(value || '').replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
92
+ }
93
+
94
+ function gitignorePatternToRegex(pattern) {
95
+ const normalized = normalizeRelativePath(pattern);
96
+ let regexBody = '';
97
+ for (let index = 0; index < normalized.length; index += 1) {
98
+ const ch = normalized[index];
99
+ const next = normalized[index + 1];
100
+ if (ch === '*') {
101
+ if (next === '*') {
102
+ regexBody += '.*';
103
+ index += 1;
104
+ } else {
105
+ regexBody += '[^/]*';
106
+ }
107
+ continue;
108
+ }
109
+ if (ch === '?') {
110
+ regexBody += '[^/]';
111
+ continue;
112
+ }
113
+ regexBody += escapeRegex(ch);
114
+ }
115
+ return new RegExp(`^${regexBody}$`);
116
+ }
117
+
118
+ async function readGitignoreRules(cwd) {
119
+ try {
120
+ const raw = await fs.readFile(path.join(cwd, '.gitignore'), 'utf8');
121
+ return raw
122
+ .split(/\r?\n/)
123
+ .map((line) => line.trim())
124
+ .filter((line) => line && !line.startsWith('#'))
125
+ .map((line) => {
126
+ const negated = line.startsWith('!');
127
+ const source = negated ? line.slice(1) : line;
128
+ const dirOnly = source.endsWith('/');
129
+ const anchored = source.startsWith('/');
130
+ const normalized = normalizeRelativePath(dirOnly ? source.slice(0, -1) : source);
131
+ return {
132
+ negated,
133
+ dirOnly,
134
+ anchored,
135
+ normalized,
136
+ hasSlash: normalized.includes('/'),
137
+ regex: gitignorePatternToRegex(normalized)
138
+ };
139
+ })
140
+ .filter((rule) => rule.normalized);
141
+ } catch {
142
+ return [];
143
+ }
144
+ }
145
+
146
+ function matchesGitignoreRule(rule, relativePath, isDirectory) {
147
+ if (!rule || !relativePath) return false;
148
+ if (rule.dirOnly && !isDirectory) return false;
149
+ const normalizedPath = normalizeRelativePath(relativePath);
150
+ if (!normalizedPath) return false;
151
+ if (rule.anchored || rule.hasSlash) {
152
+ return rule.regex.test(normalizedPath);
153
+ }
154
+ return normalizedPath.split('/').some((segment) => rule.regex.test(segment));
155
+ }
156
+
157
+ function shouldIgnorePath(relativePath, isDirectory, gitignoreRules = []) {
158
+ const normalizedPath = normalizeRelativePath(relativePath);
159
+ if (!normalizedPath) return false;
160
+ const topName = normalizedPath.split('/')[0];
161
+ if (topName && SKIP_DIRS.has(topName)) return true;
162
+ let ignored = false;
163
+ for (const rule of gitignoreRules) {
164
+ if (!matchesGitignoreRule(rule, normalizedPath, isDirectory)) continue;
165
+ ignored = !rule.negated;
166
+ }
167
+ return ignored;
168
+ }
169
+
170
+ async function detectWorkspaceKind(cwd) {
171
+ const gitDir = await safeStat(path.join(cwd, '.git'));
172
+ if (gitDir?.isDirectory()) return 'project';
173
+ for (const marker of PROJECT_MARKER_FILES) {
174
+ const stat = await safeStat(path.join(cwd, marker));
175
+ if (stat?.isFile()) return 'project';
176
+ }
177
+ return 'directory';
178
+ }
179
+
180
+ async function findNearestProjectRoot(startDir, workspaceRoot) {
181
+ let current = path.resolve(startDir);
182
+ const root = path.resolve(workspaceRoot);
183
+ while (current.startsWith(root)) {
184
+ if ((await detectWorkspaceKind(current)) === 'project') return current;
185
+ if (current === root) break;
186
+ const parent = path.dirname(current);
187
+ if (parent === current) break;
188
+ current = parent;
189
+ }
190
+ return null;
191
+ }
192
+
193
+ async function findProjectRootFromFile(workspaceRoot, relativePath = '') {
194
+ const absolutePath = path.resolve(workspaceRoot, String(relativePath || '.'));
195
+ const stat = await safeStat(absolutePath);
196
+ const probeStart = stat?.isDirectory() ? absolutePath : path.dirname(absolutePath);
197
+ return findNearestProjectRoot(probeStart, workspaceRoot);
198
+ }
199
+
200
+ async function findNearestIndexedProjectRoot(startDir, workspaceRoot) {
201
+ let current = path.resolve(startDir);
202
+ const root = path.resolve(workspaceRoot);
203
+ while (current.startsWith(root)) {
204
+ const projectMapStat = await safeStat(getProjectMapPath(current));
205
+ const fileIndexStat = await safeStat(getFileIndexPath(current));
206
+ if (projectMapStat?.isFile() && fileIndexStat?.isFile()) return current;
207
+ if (current === root) break;
208
+ const parent = path.dirname(current);
209
+ if (parent === current) break;
210
+ current = parent;
211
+ }
212
+ return null;
213
+ }
214
+
215
+ async function walkFiles(cwd, start = cwd, out = [], gitignoreRules = []) {
216
+ const entries = await fs.readdir(start, { withFileTypes: true });
217
+ for (const entry of entries) {
218
+ const absolutePath = path.join(start, entry.name);
219
+ const relativePath = rel(cwd, absolutePath);
220
+ if (entry.isDirectory()) {
221
+ if (shouldIgnorePath(relativePath, true, gitignoreRules)) continue;
222
+ await walkFiles(cwd, absolutePath, out, gitignoreRules);
223
+ continue;
224
+ }
225
+ if (shouldIgnorePath(relativePath, false, gitignoreRules)) continue;
226
+ out.push(absolutePath);
227
+ }
228
+ return out;
229
+ }
230
+
231
+ function categorizeDirectory(relativeDir) {
232
+ const text = String(relativeDir || '').toLowerCase();
233
+ if (!text || text === '.') return 'root';
234
+ if (/(^|\/)(src|app|apps)\b/.test(text)) return 'source';
235
+ if (/(^|\/)(test|tests|__tests__|spec)\b/.test(text)) return 'test';
236
+ if (/(^|\/)(scripts|bin)\b/.test(text)) return 'script';
237
+ if (/(^|\/)(config|configs)\b/.test(text)) return 'config';
238
+ return 'other';
239
+ }
240
+
241
+ function extractMatches(regex, text, group = 1) {
242
+ const out = [];
243
+ for (const match of String(text || '').matchAll(regex)) {
244
+ const value = String(match[group] || '').trim();
245
+ if (value) out.push(value);
246
+ }
247
+ return out;
248
+ }
249
+
250
+ function buildFileEntry(relativePath, content, stat) {
251
+ const ext = path.extname(relativePath).toLowerCase();
252
+ const imports = clipList([
253
+ ...extractMatches(/import\s+(?:[^'"]*from\s+)?['"]([^'"]+)['"]/g, content),
254
+ ...extractMatches(/require\(\s*['"]([^'"]+)['"]\s*\)/g, content),
255
+ ...extractMatches(/\buse\s+([A-Za-z0-9_:\\]+)/g, ext === '.rs' ? content : '')
256
+ ]);
257
+ const exports = clipList([
258
+ ...extractMatches(/export\s+(?:async\s+)?function\s+([A-Za-z0-9_$]+)/g, content),
259
+ ...extractMatches(/export\s+class\s+([A-Za-z0-9_$]+)/g, content),
260
+ ...extractMatches(/export\s+const\s+([A-Za-z0-9_$]+)/g, content),
261
+ ...extractMatches(/module\.exports\s*=\s*([A-Za-z0-9_$]+)/g, content),
262
+ ...extractMatches(/exports\.([A-Za-z0-9_$]+)/g, content)
263
+ ]);
264
+ const functions = clipList([
265
+ ...extractMatches(/\bfunction\s+([A-Za-z0-9_$]+)/g, content),
266
+ ...extractMatches(/\bdef\s+([A-Za-z0-9_]+)/g, content),
267
+ ...extractMatches(/\bfunc\s+([A-Za-z0-9_]+)/g, content),
268
+ ...extractMatches(/\bfn\s+([A-Za-z0-9_]+)/g, content),
269
+ ...extractMatches(/^\s*(?:public|private|protected|internal)?\s*(?:static\s+)?[A-Za-z0-9_<>,[\]?]+\s+([A-Za-z0-9_]+)\s*\(/gm, content),
270
+ ...extractMatches(/^\s*function\s+([A-Za-z0-9_]+)/gm, content),
271
+ ...extractMatches(/^\s*def\s+([A-Za-z0-9_]+)/gm, content)
272
+ ]);
273
+ const classes = clipList([
274
+ ...extractMatches(/\bclass\s+([A-Za-z0-9_$]+)/g, content)
275
+ ]);
276
+ const calls = clipList([
277
+ ...extractMatches(/\b([A-Za-z0-9_$]+)\s*\(/g, content).filter((name) => !['if', 'for', 'while', 'switch', 'return', 'function', 'class', 'catch'].includes(name))
278
+ ], 64);
279
+
280
+ return {
281
+ file: relativePath,
282
+ language: LANGUAGE_BY_EXT[ext] || 'text',
283
+ hash: sha1(content),
284
+ size: Number(stat?.size || content.length || 0),
285
+ mtimeMs: Number(stat?.mtimeMs || 0),
286
+ imports,
287
+ exports,
288
+ functions,
289
+ classes,
290
+ calls
291
+ };
292
+ }
293
+
294
+ async function scanProject(cwd) {
295
+ const workspaceKind = await detectWorkspaceKind(cwd);
296
+ if (workspaceKind !== 'project') {
297
+ return {
298
+ workspaceKind,
299
+ projectMap: null,
300
+ fileIndex: null,
301
+ gitignoreRules: []
302
+ };
303
+ }
304
+
305
+ const gitignoreRules = await readGitignoreRules(cwd);
306
+ const allFiles = await walkFiles(cwd, cwd, [], gitignoreRules);
307
+ const relativeFiles = allFiles.map((filePath) => rel(cwd, filePath));
308
+ const sourceFiles = allFiles.filter((filePath) => SOURCE_EXTENSIONS.has(path.extname(filePath).toLowerCase()));
309
+
310
+ const packageJson = await safeReadJson(path.join(cwd, 'package.json'), null);
311
+ const tsconfigExists = Boolean(await safeStat(path.join(cwd, 'tsconfig.json')));
312
+ const sourceRoots = clipList(relativeFiles.filter((value) => /^(src|app|apps)\b/.test(value)).map((value) => value.split('/')[0]), 12);
313
+ const testRoots = clipList(relativeFiles.filter((value) => /^(tests|test|__tests__)\b/.test(value)).map((value) => value.split('/')[0]), 12);
314
+ const entryCandidates = clipList(
315
+ relativeFiles.filter((value) => /(^|\/)(main|index|server|app)\.(js|jsx|mjs|cjs|ts|tsx|py|go|rs|java|cs|php|rb)$/.test(value)),
316
+ 16
317
+ );
318
+ const languages = clipList(sourceFiles.map((filePath) => LANGUAGE_BY_EXT[path.extname(filePath).toLowerCase()] || '').filter(Boolean), 16);
319
+ const importantFiles = clipList(
320
+ relativeFiles.filter((value) => ['package.json', 'tsconfig.json', 'pyproject.toml', 'go.mod', 'Cargo.toml', 'composer.json', 'Gemfile'].includes(value)),
321
+ 16
322
+ );
323
+ const packageManagers = clipList([
324
+ packageJson ? 'npm' : '',
325
+ relativeFiles.includes('bun.lockb') ? 'bun' : '',
326
+ relativeFiles.includes('pnpm-lock.yaml') ? 'pnpm' : '',
327
+ relativeFiles.includes('yarn.lock') ? 'yarn' : ''
328
+ ].filter(Boolean));
329
+ const frameworkHints = clipList([
330
+ packageJson?.dependencies?.react || packageJson?.devDependencies?.react ? 'react' : '',
331
+ packageJson?.dependencies?.express ? 'express' : '',
332
+ packageJson?.dependencies?.vue ? 'vue' : '',
333
+ packageJson?.dependencies?.next ? 'next' : '',
334
+ tsconfigExists ? 'typescript' : ''
335
+ ].filter(Boolean));
336
+
337
+ const directories = {};
338
+ for (const value of relativeFiles) {
339
+ const dir = path.posix.dirname(value);
340
+ if (!dir || dir === '.') continue;
341
+ if (!(dir in directories)) directories[dir] = categorizeDirectory(dir);
342
+ }
343
+
344
+ const files = [];
345
+ for (const filePath of sourceFiles) {
346
+ const content = await fs.readFile(filePath, 'utf8');
347
+ const stat = await fs.stat(filePath);
348
+ files.push(buildFileEntry(rel(cwd, filePath), content, stat));
349
+ }
350
+
351
+ return {
352
+ workspaceKind,
353
+ projectMap: {
354
+ projectRoot: cwd,
355
+ workspaceKind,
356
+ languages,
357
+ packageManagers,
358
+ importantFiles,
359
+ sourceRoots,
360
+ testRoots,
361
+ entryCandidates,
362
+ frameworkHints,
363
+ directories,
364
+ gitignoreEnabled: gitignoreRules.length > 0,
365
+ updatedAt: new Date().toISOString()
366
+ },
367
+ fileIndex: {
368
+ updatedAt: new Date().toISOString(),
369
+ files
370
+ },
371
+ gitignoreRules
372
+ };
373
+ }
374
+
375
+ export async function initializeProjectIndex(cwd = process.cwd()) {
376
+ const targetRoot = (await findNearestProjectRoot(cwd, cwd)) || path.resolve(cwd);
377
+ const cacheKey = targetRoot;
378
+ if (initCache.has(cacheKey)) return initCache.get(cacheKey);
379
+ const promise = (async () => {
380
+ const workspaceDir = getProjectWorkspaceDir(cwd);
381
+ await fs.mkdir(workspaceDir, { recursive: true });
382
+ const { workspaceKind, projectMap, fileIndex } = await scanProject(targetRoot);
383
+ if (workspaceKind !== 'project' || !projectMap || !fileIndex) {
384
+ return {
385
+ workspaceKind,
386
+ projectRoot: null,
387
+ projectMap: null,
388
+ fileIndex: null,
389
+ summary: '',
390
+ skipped: true
391
+ };
392
+ }
393
+ await fs.mkdir(getProjectIndexDir(targetRoot), { recursive: true });
394
+ await writeJson(getProjectMapPath(targetRoot), projectMap);
395
+ await writeJson(getFileIndexPath(targetRoot), fileIndex);
396
+ return {
397
+ workspaceKind,
398
+ projectRoot: targetRoot,
399
+ projectMap,
400
+ fileIndex,
401
+ summary: `initialized ${path.basename(targetRoot) || '.'}/.codemini-project (${Array.isArray(fileIndex?.files) ? fileIndex.files.length : 0} files)`
402
+ };
403
+ })();
404
+ initCache.set(cacheKey, promise);
405
+ try {
406
+ return await promise;
407
+ } catch (error) {
408
+ initCache.delete(cacheKey);
409
+ throw error;
410
+ }
411
+ }
412
+
413
+ export async function refreshIndexedFile(cwd = process.cwd(), relativePath = '') {
414
+ if (!relativePath) return null;
415
+ const workspaceDir = getProjectWorkspaceDir(cwd);
416
+ await fs.mkdir(workspaceDir, { recursive: true });
417
+ const projectRoot = await findProjectRootFromFile(cwd, relativePath);
418
+ if (!projectRoot) return null;
419
+ const fileIndexPath = getFileIndexPath(projectRoot);
420
+ const gitignoreRules = await readGitignoreRules(projectRoot);
421
+ const absolutePath = path.join(cwd, relativePath);
422
+ const stat = await safeStat(absolutePath);
423
+ let action = 'updated';
424
+ const projectRelativePath = path.relative(projectRoot, absolutePath).replace(/\\/g, '/');
425
+ const current = await safeReadJson(fileIndexPath, { updatedAt: '', files: [] });
426
+ const files = Array.isArray(current.files) ? [...current.files] : [];
427
+ const index = files.findIndex((entry) => entry.file === projectRelativePath);
428
+
429
+ if (shouldIgnorePath(projectRelativePath, Boolean(stat?.isDirectory?.()), gitignoreRules)) {
430
+ if (index >= 0) files.splice(index, 1);
431
+ action = 'removed';
432
+ } else if (!stat || !stat.isFile()) {
433
+ if (index >= 0) files.splice(index, 1);
434
+ action = 'removed';
435
+ } else {
436
+ const ext = path.extname(relativePath).toLowerCase();
437
+ if (!SOURCE_EXTENSIONS.has(ext)) {
438
+ if (index >= 0) files.splice(index, 1);
439
+ action = 'removed';
440
+ } else {
441
+ const content = await fs.readFile(absolutePath, 'utf8');
442
+ const nextEntry = buildFileEntry(projectRelativePath, content, stat);
443
+ if (index >= 0) {
444
+ files[index] = nextEntry;
445
+ } else {
446
+ files.push(nextEntry);
447
+ action = 'added';
448
+ }
449
+ }
450
+ }
451
+
452
+ await writeJson(fileIndexPath, {
453
+ updatedAt: new Date().toISOString(),
454
+ files: files.sort((left, right) => left.file.localeCompare(right.file))
455
+ });
456
+
457
+ return {
458
+ path: projectRelativePath,
459
+ projectRoot,
460
+ action,
461
+ summary: `${action} ${path.basename(projectRoot) || '.'}/.codemini-project for ${projectRelativePath}`
462
+ };
463
+ }
464
+
465
+ export async function buildProjectContextSnippet(cwd = process.cwd(), userText = '') {
466
+ const indexedRoot = await findNearestIndexedProjectRoot(cwd, cwd);
467
+ if (!indexedRoot) return '';
468
+
469
+ const projectMap = await safeReadJson(getProjectMapPath(indexedRoot), null);
470
+ const fileIndex = await safeReadJson(getFileIndexPath(indexedRoot), null);
471
+ if (!projectMap || !Array.isArray(fileIndex?.files)) return '';
472
+
473
+ const lines = [
474
+ 'Project Context:',
475
+ `- project_root: ${indexedRoot}`,
476
+ `- languages: ${(projectMap.languages || []).slice(0, 6).join(', ') || 'unknown'}`,
477
+ `- source_roots: ${(projectMap.sourceRoots || []).slice(0, 6).join(', ') || 'none'}`,
478
+ `- test_roots: ${(projectMap.testRoots || []).slice(0, 6).join(', ') || 'none'}`,
479
+ `- entry_candidates: ${(projectMap.entryCandidates || []).slice(0, 6).join(', ') || 'none'}`,
480
+ `- framework_hints: ${(projectMap.frameworkHints || []).slice(0, 6).join(', ') || 'none'}`
481
+ ];
482
+
483
+ const tokens = tokenizeQuery(userText);
484
+ const scored = [];
485
+ for (const entry of fileIndex.files) {
486
+ let score = 0;
487
+ const fileText = String(entry.file || '').toLowerCase();
488
+ for (const token of tokens) {
489
+ if (fileText.includes(token)) score += 5;
490
+ if ((entry.exports || []).some((value) => String(value).toLowerCase() === token)) score += 4;
491
+ if ((entry.functions || []).some((value) => String(value).toLowerCase() === token)) score += 4;
492
+ if ((entry.classes || []).some((value) => String(value).toLowerCase() === token)) score += 4;
493
+ if ((entry.imports || []).some((value) => String(value).toLowerCase().includes(token))) score += 1;
494
+ }
495
+ if (score > 0) scored.push({ entry, score });
496
+ }
497
+ scored.sort((left, right) => right.score - left.score || String(left.entry.file).localeCompare(String(right.entry.file)));
498
+ const selected = scored.slice(0, PROJECT_CONTEXT_MAX_FILES).map((item) => item.entry);
499
+ if (selected.length > 0) {
500
+ lines.push('- relevant_files:');
501
+ for (const entry of selected) {
502
+ lines.push(
503
+ ` - ${entry.file} :: exports=[${(entry.exports || []).slice(0, 4).join(', ')}] functions=[${(entry.functions || []).slice(0, 4).join(', ')}] classes=[${(entry.classes || []).slice(0, 4).join(', ')}]`
504
+ );
505
+ }
506
+ }
507
+
508
+ const snippet = trimMultiline(lines.join('\n'));
509
+ return snippet;
510
+ }
@@ -205,6 +205,7 @@ export async function createChatCompletionStream({
205
205
  temperature = 0.2,
206
206
  tools,
207
207
  onTextDelta,
208
+ onToolCallDelta,
208
209
  timeoutMs = 90000,
209
210
  maxRetries = 2
210
211
  }) {
@@ -248,6 +249,14 @@ export async function createChatCompletionStream({
248
249
  if (td.function?.name) current.name = `${current.name}${td.function.name}`;
249
250
  if (td.function?.arguments) current.arguments = `${current.arguments}${td.function.arguments}`;
250
251
  toolCallsByIndex.set(idx, current);
252
+ if (onToolCallDelta) {
253
+ onToolCallDelta({
254
+ index: idx,
255
+ id: current.id || `tc-${idx + 1}`,
256
+ name: current.name,
257
+ arguments: current.arguments || '{}'
258
+ });
259
+ }
251
260
  }
252
261
  }
253
262
 
@@ -118,5 +118,5 @@ export function getEffectivePolicy(config) {
118
118
 
119
119
  export function getShellSystemPrompt(value) {
120
120
  const profile = getShellProfile(value);
121
- return `You are CodeMini CLI working in a ${profile.label} shell environment. Prefer OpenCode-style primary tools first: use read to inspect files, grep to search file contents, glob to find files by pattern, list to inspect directories, edit to modify existing files, write to create or fully rewrite files when appropriate, patch to apply unified diffs, and run for one-shot shell commands. Treat edit as the default editing path for existing code. Internal low-level edit strategies such as target resolution, block replacement, exact text replacement, and anchored inserts are handled inside edit rather than exposed as separate tools. Use generate_diff when you need a structured preview of a proposed file change. Use start_service, list_services, get_service_status, get_service_logs, and stop_service for long-running servers or watchers. Use run only for one-shot commands that should exit on their own. For existing code files, prefer grep/read/edit and only use write with full_file_rewrite=true when a whole-file rewrite is truly intended. Avoid unnecessary tool calls.`;
121
+ return `You are CodeMini CLI working in a ${profile.label} shell environment. Prefer OpenCode-style primary tools first: use read to inspect files, grep to search file contents, glob to find files by pattern, list to inspect directories, edit to modify existing files, write to create or fully rewrite files when appropriate, patch to apply unified diffs, and run for one-shot shell commands like install, build, test, or other finite tasks. For structural code edits such as changing a function, method, or class, prefer the AST-first workflow: use ast_query to select the syntax node, use read_ast_node to inspect that node, then use edit with ast_target and kind=replace_block so the write stays constrained to the selected node. Fall back to plain grep/read/edit only when AST selection is not appropriate. Classify frontend, backend, database, and Docker work carefully: use run for finite commands, and use start_service, list_services, get_service_status, get_service_logs, and stop_service for long-running servers, watchers, and dev processes. Treat edit as the default editing path for existing code. Internal low-level edit strategies such as target resolution, block replacement, exact text replacement, and anchored inserts are handled inside edit rather than exposed as separate tools. Use generate_diff when you need a structured preview of a proposed file change. For existing code files, prefer grep/read/edit and only use write with full_file_rewrite=true when a whole-file rewrite is truly intended. Avoid unnecessary tool calls.`;
122
122
  }
package/src/core/shell.js CHANGED
@@ -26,9 +26,129 @@ const READY_OUTPUT_PATTERNS = [
26
26
  const AUTO_STOP_GRACE_MS = 150;
27
27
  const LONG_RUNNING_STARTUP_WINDOW_MS = 1500;
28
28
 
29
+ function normalizeCommand(command) {
30
+ return String(command || '').trim();
31
+ }
32
+
33
+ function matchesAny(value, patterns) {
34
+ return patterns.some((pattern) => pattern.test(value));
35
+ }
36
+
37
+ export function classifyCommandIntent(command) {
38
+ const value = normalizeCommand(command);
39
+
40
+ if (!value) {
41
+ return { kind: 'generic', longRunning: false };
42
+ }
43
+
44
+ if (
45
+ /\b(?:npm|pnpm|yarn|bun)\s+install\b/i.test(value) ||
46
+ /\b(?:npm|pnpm|yarn|bun)\s+(?:ci|i|add)\b/i.test(value) ||
47
+ /\buv\s+pip\s+install\b/i.test(value) ||
48
+ /\bpip\s+install\b/i.test(value) ||
49
+ /\bcargo\s+install\b/i.test(value) ||
50
+ /\bbundle\s+install\b/i.test(value) ||
51
+ /\bcomposer\s+install\b/i.test(value)
52
+ ) {
53
+ return { kind: 'install', longRunning: false };
54
+ }
55
+
56
+ if (/\b(?:build|compile|bundle|pack|transpile)\b/i.test(value)) {
57
+ return { kind: 'build', longRunning: false };
58
+ }
59
+
60
+ if (
61
+ /\b(?:npm|pnpm|yarn|bun)\s+(?:run\s+)?(?:test|lint|check|typecheck)\b/i.test(value) ||
62
+ /\b(?:jest|vitest|mocha|ava|pytest|go\s+test|cargo\s+test|dotnet\s+test)\b/i.test(value)
63
+ ) {
64
+ return { kind: 'test', longRunning: false };
65
+ }
66
+
67
+ const frontendServicePatterns = [
68
+ /\bvite\b/i,
69
+ /\bnext\s+dev\b/i,
70
+ /\bnuxt\s+dev\b/i,
71
+ /\bastro\s+dev\b/i,
72
+ /\bremix\s+dev\b/i,
73
+ /\bsvelte-kit\s+dev\b/i,
74
+ /\bwebpack\s+serve\b/i,
75
+ /\bvue-cli-service\s+serve\b/i,
76
+ /\breact-scripts\s+start\b/i,
77
+ /\bstorybook\b/i,
78
+ /\b(?:npm|pnpm|yarn|bun)\s+(?:run\s+)?(?:dev|start|serve|preview)\b.*\b(?:client|frontend|front-end|web|ui)\b/i,
79
+ /\b(?:client|frontend|front-end|web|ui)\b.*\b(?:dev|start|serve|preview)\b/i
80
+ ];
81
+ if (matchesAny(value, frontendServicePatterns)) {
82
+ return { kind: 'frontend-service', longRunning: true };
83
+ }
84
+
85
+ const backendServicePatterns = [
86
+ /\bpython\s+-m\s+http\.server\b/i,
87
+ /\buvicorn\b/i,
88
+ /\bgunicorn\b/i,
89
+ /\bflask\s+run\b/i,
90
+ /\bdjango\s+runserver\b/i,
91
+ /\brails\s+(?:s|server)\b/i,
92
+ /\bmvn(?:w)?\s+spring-boot:run\b/i,
93
+ /\bgradle(?:w)?\s+bootRun\b/i,
94
+ /\bgradle(?:w)?\s+run\b/i,
95
+ /\bjava\b.*\bserver\b/i,
96
+ /\bdotnet\s+run\b/i,
97
+ /\bgo\s+run\b.*\b(server|cmd\/server|main\.go)\b/i,
98
+ /\bnest\s+start\b/i,
99
+ /\bnodemon\b/i,
100
+ /\bts-node-dev\b/i,
101
+ /\bair\b/i,
102
+ /\bphp\s+artisan\s+serve\b/i,
103
+ /\bsymfony\s+server:start\b/i,
104
+ /\b(?:npm|pnpm|yarn|bun)\s+(?:run\s+)?(?:dev|start|serve|preview)\b.*\b(?:server|api|backend)\b/i,
105
+ /\b(?:server|api|backend)\b.*\b(?:dev|start|serve|preview)\b/i
106
+ ];
107
+ if (matchesAny(value, backendServicePatterns)) {
108
+ return { kind: 'backend-service', longRunning: true };
109
+ }
110
+
111
+ const databaseServicePatterns = [
112
+ /\bpostgres(?:ql)?\b/i,
113
+ /\bmysql\b/i,
114
+ /\bmariadb\b/i,
115
+ /\bmongod\b/i,
116
+ /\bredis-server\b/i,
117
+ /\b(?:docker|docker-compose|docker compose)\s+.*\b(?:db|database|postgres|mysql|mongo|redis)\b/i,
118
+ /\b(?:db|database|postgres|mysql|mongo|redis)\b.*\b(?:start|up|serve|run)\b/i
119
+ ];
120
+ if (matchesAny(value, databaseServicePatterns)) {
121
+ return { kind: 'database-service', longRunning: true };
122
+ }
123
+
124
+ const dockerServicePatterns = [
125
+ /\bdocker\s+compose\s+up\b/i,
126
+ /\bdocker-compose\s+up\b/i,
127
+ /\bdocker\s+run\b/i,
128
+ /\bdocker\s+start\b/i
129
+ ];
130
+ if (matchesAny(value, dockerServicePatterns)) {
131
+ return { kind: 'docker-service', longRunning: true };
132
+ }
133
+
134
+ if (
135
+ /\b(?:npm|pnpm|yarn|bun)\s+(?:run\s+)?(?:dev|start|serve|preview|watch)\b/i.test(value) ||
136
+ /\b(?:vite|serve)\b/i.test(value) ||
137
+ /\b(?:watch|serve|server|dev|preview)\b/i.test(value)
138
+ ) {
139
+ return { kind: 'service', longRunning: true };
140
+ }
141
+
142
+ if (/\b(?:watch|serve|server|dev|preview)\b/i.test(value)) {
143
+ return { kind: 'service', longRunning: true };
144
+ }
145
+
146
+ return { kind: 'generic', longRunning: false };
147
+ }
148
+
29
149
  export function isLikelyLongRunningCommand(command) {
30
- const value = String(command || '');
31
- return LONG_RUNNING_COMMAND_RE.test(value) || GENERIC_LONG_RUNNING_HINT_RE.test(value);
150
+ const { longRunning } = classifyCommandIntent(command);
151
+ return longRunning || LONG_RUNNING_COMMAND_RE.test(normalizeCommand(command)) || GENERIC_LONG_RUNNING_HINT_RE.test(normalizeCommand(command));
32
152
  }
33
153
 
34
154
  export function hasReadyOutput(text) {
@@ -1,14 +1,15 @@
1
1
  import fs from 'node:fs/promises';
2
2
  import path from 'node:path';
3
+ import { getProjectLegacyTasksFilePath, getProjectTasksDir } from './paths.js';
3
4
 
4
5
  function legacyTasksFilePath(cwd = process.cwd()) {
5
- return path.join(cwd, '.coder', 'tasks.json');
6
+ return getProjectLegacyTasksFilePath(cwd);
6
7
  }
7
8
 
8
9
  function tasksFilePath(cwd = process.cwd(), sessionId = '') {
9
10
  const sid = String(sessionId || '').trim();
10
11
  if (!sid) return legacyTasksFilePath(cwd);
11
- return path.join(cwd, '.coder', 'tasks', `${sid}.json`);
12
+ return path.join(getProjectTasksDir(cwd), `${sid}.json`);
12
13
  }
13
14
 
14
15
  async function ensureDir(filePath) {