@pheem49/mint 1.4.0 → 1.4.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.
- package/.codex +0 -0
- package/README.md +171 -127
- package/main.js +21 -1
- package/mint-cli-logic.js +21 -1
- package/mint-cli.js +89 -22
- package/package.json +1 -1
- package/src/AI_Brain/Gemini_API.js +38 -24
- package/src/AI_Brain/proactive_engine.js +2 -8
- package/src/Automation_Layer/file_operations.js +123 -4
- package/src/Automation_Layer/open_app.js +72 -43
- package/src/Automation_Layer/open_website.js +3 -3
- package/src/CLI/chat_router.js +51 -18
- package/src/CLI/chat_ui.js +34 -10
- package/src/CLI/code_agent.js +113 -13
- package/src/CLI/workspace_manager.js +15 -6
- package/src/Plugins/docker.js +12 -10
- package/src/System/custom_workflows.js +9 -2
- package/tests/chat_router.test.js +42 -0
- package/tests/code_agent.test.js +69 -0
- package/tests/docker.test.js +46 -0
- package/tests/file_operations.test.js +57 -0
- package/tests/provider_routing.test.js +67 -0
- package/tests/workspace_manager.test.js +15 -0
package/src/CLI/chat_ui.js
CHANGED
|
@@ -90,11 +90,10 @@ function createChatUI({ onSubmit, onExit }) {
|
|
|
90
90
|
style: { bg: 'default' }
|
|
91
91
|
});
|
|
92
92
|
|
|
93
|
-
// ─── Input area ───────────────────────────────────────────────────────────
|
|
94
93
|
const inputBox = blessed.textbox({
|
|
95
94
|
bottom: 3, left: 1, width: '100%-2', height: 3,
|
|
96
95
|
tags: false,
|
|
97
|
-
inputOnFocus:
|
|
96
|
+
inputOnFocus: false, // We'll manage this manually for stability
|
|
98
97
|
keys: true,
|
|
99
98
|
style: {
|
|
100
99
|
bg: INPUT_BG,
|
|
@@ -111,6 +110,16 @@ function createChatUI({ onSubmit, onExit }) {
|
|
|
111
110
|
label: ' Message '
|
|
112
111
|
});
|
|
113
112
|
|
|
113
|
+
// --- SAFETY PATCH ---
|
|
114
|
+
// Prevent "TypeError: done is not a function" if a listener survives a blur/focus cycle.
|
|
115
|
+
const originalListener = inputBox._listener;
|
|
116
|
+
inputBox._listener = function(ch, key) {
|
|
117
|
+
if (typeof this._done !== 'function') return;
|
|
118
|
+
return originalListener.call(this, ch, key);
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
|
|
114
123
|
// ─── Placeholder (SIBLING widget floating over input content area) ─────────
|
|
115
124
|
// inputBox: bottom=3, height=3, border=1 → content row at bottom=4, left=2
|
|
116
125
|
const placeholderWidget = blessed.text({
|
|
@@ -236,6 +245,7 @@ function createChatUI({ onSubmit, onExit }) {
|
|
|
236
245
|
|
|
237
246
|
/** Update model name in status bar (called after /models switch) */
|
|
238
247
|
function updateStatusModel(newModel) {
|
|
248
|
+
if (!newModel) return;
|
|
239
249
|
statusRight.setContent(`{#88e0b0-fg}${newModel}{/}`);
|
|
240
250
|
screen.render();
|
|
241
251
|
}
|
|
@@ -284,7 +294,7 @@ function createChatUI({ onSubmit, onExit }) {
|
|
|
284
294
|
border: { fg: '#88e0b0' }
|
|
285
295
|
},
|
|
286
296
|
width: '80%',
|
|
287
|
-
height: 'shrink'
|
|
297
|
+
height: 12, // Fixed height to avoid 'shrink' miscalculation with buttons
|
|
288
298
|
top: 'center',
|
|
289
299
|
left: 'center',
|
|
290
300
|
label: ' Approval ',
|
|
@@ -381,26 +391,28 @@ function createChatUI({ onSubmit, onExit }) {
|
|
|
381
391
|
|
|
382
392
|
|
|
383
393
|
// Submit or Select Suggestion on Enter
|
|
384
|
-
inputBox.
|
|
394
|
+
inputBox.on('submit', (value) => {
|
|
385
395
|
if (!commandList.hidden) {
|
|
386
396
|
const selected = activeSuggestions[commandList.selected];
|
|
387
397
|
if (selected) {
|
|
388
398
|
inputBox.setValue(selected.name + ' ');
|
|
389
399
|
commandList.hide();
|
|
390
400
|
hidePlaceholder();
|
|
391
|
-
inputBox.focus();
|
|
401
|
+
inputBox.focus();
|
|
402
|
+
inputBox.readInput(); // Re-focus to continue typing
|
|
392
403
|
refreshInputStyles();
|
|
393
404
|
screen.render();
|
|
394
405
|
return; // Don't submit yet, let user add args or press enter again
|
|
395
406
|
}
|
|
396
407
|
}
|
|
397
408
|
|
|
398
|
-
const raw =
|
|
409
|
+
const raw = value || '';
|
|
399
410
|
const text = raw.trim();
|
|
400
411
|
if (!text) {
|
|
401
412
|
inputBox.clearValue();
|
|
402
413
|
showPlaceholder();
|
|
403
|
-
inputBox.focus();
|
|
414
|
+
inputBox.focus();
|
|
415
|
+
inputBox.readInput(); // Re-focus to continue typing
|
|
404
416
|
refreshInputStyles();
|
|
405
417
|
screen.render();
|
|
406
418
|
return;
|
|
@@ -409,7 +421,8 @@ function createChatUI({ onSubmit, onExit }) {
|
|
|
409
421
|
// Clear input and restore placeholder
|
|
410
422
|
inputBox.clearValue();
|
|
411
423
|
showPlaceholder();
|
|
412
|
-
inputBox.focus();
|
|
424
|
+
inputBox.focus();
|
|
425
|
+
inputBox.readInput(); // Explicitly restart reading
|
|
413
426
|
refreshInputStyles();
|
|
414
427
|
screen.render();
|
|
415
428
|
|
|
@@ -486,6 +499,7 @@ function createChatUI({ onSubmit, onExit }) {
|
|
|
486
499
|
|
|
487
500
|
// ─── Initial render ───────────────────────────────────────────────────────
|
|
488
501
|
inputBox.focus();
|
|
502
|
+
inputBox.readInput(); // Initial start
|
|
489
503
|
refreshInputStyles();
|
|
490
504
|
screen.render();
|
|
491
505
|
|
|
@@ -691,18 +705,28 @@ function createChatUI({ onSubmit, onExit }) {
|
|
|
691
705
|
? 'Shell Command'
|
|
692
706
|
: request.type === 'patch'
|
|
693
707
|
? 'Patch Edit'
|
|
694
|
-
:
|
|
708
|
+
: request.type === 'code_mode'
|
|
709
|
+
? 'Enter Code Mode'
|
|
710
|
+
: 'File Write';
|
|
695
711
|
const preview = request.preview || request.label || '';
|
|
696
712
|
const message = [
|
|
697
713
|
`{bold}${typeLabel}{/bold}`,
|
|
698
714
|
'',
|
|
699
715
|
preview,
|
|
700
716
|
'',
|
|
701
|
-
'Approve this action?'
|
|
717
|
+
'Approve this action?',
|
|
718
|
+
'', // Extra lines to push buttons down and avoid overlapping
|
|
719
|
+
''
|
|
702
720
|
].join('\n');
|
|
703
721
|
|
|
722
|
+
// Temporarily stop reading input so the dialog can receive keys
|
|
723
|
+
if (inputBox._reading) {
|
|
724
|
+
inputBox.cancel();
|
|
725
|
+
}
|
|
726
|
+
|
|
704
727
|
approvalDialog.ask(message, (approved) => {
|
|
705
728
|
inputBox.focus();
|
|
729
|
+
inputBox.readInput(); // Ensure we resume reading after dialog
|
|
706
730
|
refreshInputStyles();
|
|
707
731
|
screen.render();
|
|
708
732
|
resolve(Boolean(approved));
|
package/src/CLI/code_agent.js
CHANGED
|
@@ -11,6 +11,8 @@ const execFileAsync = promisify(execFile);
|
|
|
11
11
|
const DEFAULT_GEMINI_MODEL = 'gemini-2.5-flash';
|
|
12
12
|
const MAX_TOOL_OUTPUT = 12000;
|
|
13
13
|
const MAX_AGENT_STEPS = 16;
|
|
14
|
+
const MAX_JSON_REPAIR_ATTEMPTS = 2;
|
|
15
|
+
const SUPPORTED_CODE_PROVIDERS = ['gemini', 'anthropic', 'openai', 'local_openai'];
|
|
14
16
|
|
|
15
17
|
const CODE_AGENT_PROMPT = `You are Mint Code Mode, a careful coding agent for a local workspace.
|
|
16
18
|
|
|
@@ -31,10 +33,11 @@ Rules:
|
|
|
31
33
|
Response format:
|
|
32
34
|
{
|
|
33
35
|
"thought": "short reasoning",
|
|
34
|
-
"action": "list_files" | "read_file" | "search_code" | "run_shell" | "apply_patch" | "write_file" | "finish",
|
|
36
|
+
"action": "list_files" | "read_file" | "search_code" | "find_path" | "run_shell" | "apply_patch" | "write_file" | "finish",
|
|
35
37
|
"input": {
|
|
36
38
|
"path": "relative/path",
|
|
37
39
|
"query": "search text",
|
|
40
|
+
"type": "file" | "dir" | "any",
|
|
38
41
|
"command": "shell command",
|
|
39
42
|
"startLine": 1,
|
|
40
43
|
"endLine": 120,
|
|
@@ -58,6 +61,7 @@ Tool notes:
|
|
|
58
61
|
- "list_files": inspect the workspace or a subdirectory.
|
|
59
62
|
- "read_file": read a file, optionally with startLine/endLine.
|
|
60
63
|
- "search_code": search by text or regex-like pattern.
|
|
64
|
+
- "find_path": find files or directories by path/name when the user is looking for a folder, filename, or location.
|
|
61
65
|
- "run_shell": run a non-destructive command in the workspace.
|
|
62
66
|
- "apply_patch": update an existing file using one or more exact replacement hunks.
|
|
63
67
|
- "write_file": create a new file or fully rewrite a file when replacement is not practical.
|
|
@@ -81,6 +85,22 @@ function extractJson(text) {
|
|
|
81
85
|
}
|
|
82
86
|
}
|
|
83
87
|
|
|
88
|
+
function selectSupportedCodeProvider(config, availableProviders = getAvailableProviders(config || {})) {
|
|
89
|
+
const requestedProvider = (config && config.aiProvider) || 'gemini';
|
|
90
|
+
if (SUPPORTED_CODE_PROVIDERS.includes(requestedProvider) && availableProviders.includes(requestedProvider)) {
|
|
91
|
+
return requestedProvider;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const priority = ['anthropic', 'openai', 'gemini', 'local_openai'];
|
|
95
|
+
for (const provider of priority) {
|
|
96
|
+
if (availableProviders.includes(provider)) {
|
|
97
|
+
return provider;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return 'gemini';
|
|
102
|
+
}
|
|
103
|
+
|
|
84
104
|
function resolveWorkspacePath(workspaceRoot, targetPath = '.') {
|
|
85
105
|
const resolved = path.resolve(workspaceRoot, targetPath);
|
|
86
106
|
const relative = path.relative(workspaceRoot, resolved);
|
|
@@ -160,6 +180,40 @@ async function searchCode(workspaceRoot, query) {
|
|
|
160
180
|
}
|
|
161
181
|
}
|
|
162
182
|
|
|
183
|
+
async function findPaths(workspaceRoot, query, type = 'any') {
|
|
184
|
+
if (!query || !query.trim()) {
|
|
185
|
+
throw new Error('Path search query is required.');
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const normalizedType = ['file', 'dir', 'any'].includes(type) ? type : 'any';
|
|
189
|
+
const loweredQuery = query.trim().toLowerCase();
|
|
190
|
+
const results = [];
|
|
191
|
+
|
|
192
|
+
function visit(currentPath) {
|
|
193
|
+
const entries = fs.readdirSync(currentPath, { withFileTypes: true });
|
|
194
|
+
for (const entry of entries) {
|
|
195
|
+
const absoluteEntryPath = path.join(currentPath, entry.name);
|
|
196
|
+
const relativeEntryPath = path.relative(workspaceRoot, absoluteEntryPath) || '.';
|
|
197
|
+
const entryType = entry.isDirectory() ? 'dir' : 'file';
|
|
198
|
+
const matchesType = normalizedType === 'any' || normalizedType === entryType;
|
|
199
|
+
const matchesQuery = entry.name.toLowerCase().includes(loweredQuery) || relativeEntryPath.toLowerCase().includes(loweredQuery);
|
|
200
|
+
|
|
201
|
+
if (matchesType && matchesQuery) {
|
|
202
|
+
results.push(`${entryType === 'dir' ? '[dir]' : '[file]'} ${relativeEntryPath}`);
|
|
203
|
+
if (results.length >= 200) return;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (entry.isDirectory() && results.length < 200) {
|
|
207
|
+
visit(absoluteEntryPath);
|
|
208
|
+
if (results.length >= 200) return;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
visit(workspaceRoot);
|
|
214
|
+
return results.length > 0 ? results.join('\n') : '(no matching paths)';
|
|
215
|
+
}
|
|
216
|
+
|
|
163
217
|
function assertSafeShell(command) {
|
|
164
218
|
const blockedPatterns = [
|
|
165
219
|
/\brm\s+-rf\b/,
|
|
@@ -247,7 +301,7 @@ function writeFile(workspaceRoot, targetPath, content) {
|
|
|
247
301
|
|
|
248
302
|
class UnifiedAgentClient {
|
|
249
303
|
constructor(provider, config) {
|
|
250
|
-
this.provider = provider;
|
|
304
|
+
this.provider = SUPPORTED_CODE_PROVIDERS.includes(provider) ? provider : 'gemini';
|
|
251
305
|
this.config = config;
|
|
252
306
|
this.history = [];
|
|
253
307
|
this.systemInstruction = CODE_AGENT_PROMPT;
|
|
@@ -342,6 +396,29 @@ class UnifiedAgentClient {
|
|
|
342
396
|
}
|
|
343
397
|
}
|
|
344
398
|
|
|
399
|
+
async function getAgentDecision(client, observation, options = {}) {
|
|
400
|
+
const onProgress = typeof options.onProgress === 'function' ? options.onProgress : () => {};
|
|
401
|
+
const step = options.step || 0;
|
|
402
|
+
|
|
403
|
+
let rawText = await client.sendMessage(observation);
|
|
404
|
+
for (let attempt = 0; attempt <= MAX_JSON_REPAIR_ATTEMPTS; attempt++) {
|
|
405
|
+
try {
|
|
406
|
+
return extractJson(rawText);
|
|
407
|
+
} catch (error) {
|
|
408
|
+
if (attempt === MAX_JSON_REPAIR_ATTEMPTS) {
|
|
409
|
+
throw new Error(`Agent returned invalid JSON after ${MAX_JSON_REPAIR_ATTEMPTS + 1} attempts: ${error.message}`);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
onProgress(`Step ${step}: invalid JSON response, requesting repair (${attempt + 1}/${MAX_JSON_REPAIR_ATTEMPTS})`);
|
|
413
|
+
rawText = await client.sendMessage([
|
|
414
|
+
'Your previous response was not valid JSON for Code Mode.',
|
|
415
|
+
'Reply again with valid JSON only, following the required schema exactly.',
|
|
416
|
+
`Previous response:\n${truncate(rawText, 4000)}`
|
|
417
|
+
].join('\n'));
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
345
422
|
function detectPackageManager(workspaceRoot) {
|
|
346
423
|
if (fs.existsSync(path.join(workspaceRoot, 'package-lock.json'))) return 'npm';
|
|
347
424
|
if (fs.existsSync(path.join(workspaceRoot, 'pnpm-lock.yaml'))) return 'pnpm';
|
|
@@ -384,12 +461,17 @@ async function getGitContext(workspaceRoot) {
|
|
|
384
461
|
return { isRepo: true, branch, status, diffSummary };
|
|
385
462
|
}
|
|
386
463
|
|
|
387
|
-
async function buildInitialObservation(task, workspaceRoot) {
|
|
464
|
+
async function buildInitialObservation(task, workspaceRoot, history = []) {
|
|
388
465
|
const session = readWorkspaceSession(workspaceRoot);
|
|
389
466
|
const gitContext = await getGitContext(workspaceRoot);
|
|
390
467
|
const testCommands = detectTestCommands(workspaceRoot);
|
|
391
468
|
|
|
469
|
+
const contextStr = history.length > 0
|
|
470
|
+
? `Recent Context:\n${history.slice(-10).map(m => `${m.sender}: ${m.text}`).join('\n')}\n`
|
|
471
|
+
: '';
|
|
472
|
+
|
|
392
473
|
return [
|
|
474
|
+
contextStr,
|
|
393
475
|
`Task: ${task}`,
|
|
394
476
|
`Workspace: ${workspaceRoot}`,
|
|
395
477
|
`Git branch: ${gitContext.branch}`,
|
|
@@ -409,24 +491,26 @@ async function buildInitialObservation(task, workspaceRoot) {
|
|
|
409
491
|
|
|
410
492
|
async function executeCodeTask(task, options = {}) {
|
|
411
493
|
const workspaceRoot = path.resolve(options.cwd || process.cwd());
|
|
494
|
+
const history = options.history || [];
|
|
412
495
|
const onProgress = typeof options.onProgress === 'function' ? options.onProgress : () => {};
|
|
413
496
|
const requestApproval = typeof options.requestApproval === 'function'
|
|
414
497
|
? options.requestApproval
|
|
415
498
|
: async () => true;
|
|
416
499
|
const config = readConfig();
|
|
417
|
-
const provider = options.provider ||
|
|
500
|
+
const provider = options.provider || selectSupportedCodeProvider(config);
|
|
418
501
|
const client = new UnifiedAgentClient(provider, config);
|
|
419
502
|
|
|
420
|
-
let observation = await buildInitialObservation(task, workspaceRoot);
|
|
503
|
+
let observation = await buildInitialObservation(task, workspaceRoot, history);
|
|
421
504
|
|
|
422
505
|
let finalSummary = '';
|
|
423
506
|
let finalVerification = '';
|
|
424
507
|
let finalSessionSummary = '';
|
|
508
|
+
let executedSteps = 0;
|
|
425
509
|
|
|
426
510
|
for (let step = 1; step <= MAX_AGENT_STEPS; step++) {
|
|
511
|
+
executedSteps = step;
|
|
427
512
|
onProgress(`Step ${step}: thinking`);
|
|
428
|
-
const
|
|
429
|
-
const decision = extractJson(text);
|
|
513
|
+
const decision = await getAgentDecision(client, observation, { onProgress, step });
|
|
430
514
|
const action = decision.action;
|
|
431
515
|
const input = decision.input || {};
|
|
432
516
|
|
|
@@ -455,6 +539,9 @@ async function executeCodeTask(task, options = {}) {
|
|
|
455
539
|
case 'search_code':
|
|
456
540
|
toolResult = await searchCode(workspaceRoot, input.query);
|
|
457
541
|
break;
|
|
542
|
+
case 'find_path':
|
|
543
|
+
toolResult = await findPaths(workspaceRoot, input.query, input.type);
|
|
544
|
+
break;
|
|
458
545
|
case 'run_shell': {
|
|
459
546
|
const approved = await requestApproval({
|
|
460
547
|
type: 'shell',
|
|
@@ -510,9 +597,15 @@ async function executeCodeTask(task, options = {}) {
|
|
|
510
597
|
// Check for Agent Collaboration (Review)
|
|
511
598
|
if (config.enableAgentCollaboration !== false) {
|
|
512
599
|
const availableProviders = getAvailableProviders(config);
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
600
|
+
// Exclude providers that often need special local setup or are slow/unreliable for tiny reviews
|
|
601
|
+
const altProviders = availableProviders.filter(p => p !== provider && p !== 'ollama' && p !== 'huggingface' && p !== 'local_openai');
|
|
602
|
+
|
|
603
|
+
// Fallback to provider itself if no other good ones exist, or pick the best available
|
|
604
|
+
const reviewerProvider = altProviders.length > 0
|
|
605
|
+
? altProviders[0]
|
|
606
|
+
: (availableProviders.includes('gemini') ? 'gemini' : availableProviders[0]);
|
|
607
|
+
|
|
608
|
+
if (reviewerProvider && finalSummary) {
|
|
516
609
|
onProgress(`Invoking Reviewer Agent (${reviewerProvider})...`);
|
|
517
610
|
|
|
518
611
|
const reviewerClient = new UnifiedAgentClient(reviewerProvider, config);
|
|
@@ -536,7 +629,7 @@ async function executeCodeTask(task, options = {}) {
|
|
|
536
629
|
return {
|
|
537
630
|
summary: finalSummary,
|
|
538
631
|
verification: finalVerification,
|
|
539
|
-
steps:
|
|
632
|
+
steps: executedSteps
|
|
540
633
|
};
|
|
541
634
|
}
|
|
542
635
|
|
|
@@ -549,8 +642,15 @@ async function executeCodeTask(task, options = {}) {
|
|
|
549
642
|
return {
|
|
550
643
|
summary: 'Stopped after reaching the maximum number of agent steps.',
|
|
551
644
|
verification: 'Agent limit reached before explicit completion.',
|
|
552
|
-
steps: MAX_AGENT_STEPS
|
|
645
|
+
steps: executedSteps || MAX_AGENT_STEPS
|
|
553
646
|
};
|
|
554
647
|
}
|
|
555
648
|
|
|
556
|
-
module.exports = {
|
|
649
|
+
module.exports = {
|
|
650
|
+
executeCodeTask,
|
|
651
|
+
_helpers: {
|
|
652
|
+
extractJson,
|
|
653
|
+
selectSupportedCodeProvider,
|
|
654
|
+
findPaths
|
|
655
|
+
}
|
|
656
|
+
};
|
|
@@ -9,26 +9,35 @@ const fs = require('fs');
|
|
|
9
9
|
const path = require('path');
|
|
10
10
|
const os = require('os');
|
|
11
11
|
|
|
12
|
-
|
|
12
|
+
function getWorkspaceFile() {
|
|
13
|
+
return process.env.MINT_WORKSPACE_FILE || path.join(os.homedir(), '.config', 'mint', 'workspaces.json');
|
|
14
|
+
}
|
|
13
15
|
|
|
14
16
|
function ensureDir() {
|
|
15
|
-
const dir = path.dirname(
|
|
17
|
+
const dir = path.dirname(getWorkspaceFile());
|
|
16
18
|
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
17
19
|
}
|
|
18
20
|
|
|
19
21
|
function loadWorkspaces() {
|
|
22
|
+
const workspaceFile = getWorkspaceFile();
|
|
20
23
|
ensureDir();
|
|
21
|
-
if (!fs.existsSync(
|
|
24
|
+
if (!fs.existsSync(workspaceFile)) return {};
|
|
22
25
|
try {
|
|
23
|
-
return JSON.parse(fs.readFileSync(
|
|
26
|
+
return JSON.parse(fs.readFileSync(workspaceFile, 'utf8'));
|
|
24
27
|
} catch (e) {
|
|
25
28
|
return {};
|
|
26
29
|
}
|
|
27
30
|
}
|
|
28
31
|
|
|
29
32
|
function saveWorkspaces(data) {
|
|
33
|
+
const workspaceFile = getWorkspaceFile();
|
|
30
34
|
ensureDir();
|
|
31
|
-
fs.writeFileSync(
|
|
35
|
+
fs.writeFileSync(workspaceFile, JSON.stringify(data, null, 2));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function isPathInsideWorkspace(currentPath, workspacePath) {
|
|
39
|
+
const relative = path.relative(workspacePath, currentPath);
|
|
40
|
+
return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
|
|
32
41
|
}
|
|
33
42
|
|
|
34
43
|
function addWorkspace(name, rootPath, instructions = '') {
|
|
@@ -62,7 +71,7 @@ function getWorkspaceByPath(currentPath) {
|
|
|
62
71
|
// Find workspace where current path is inside or equal to workspace path
|
|
63
72
|
for (const name in workspaces) {
|
|
64
73
|
const ws = workspaces[name];
|
|
65
|
-
if (absoluteCurrent
|
|
74
|
+
if (isPathInsideWorkspace(absoluteCurrent, ws.path)) {
|
|
66
75
|
return ws;
|
|
67
76
|
}
|
|
68
77
|
}
|
package/src/Plugins/docker.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
const {
|
|
1
|
+
const { execFile } = require('child_process');
|
|
2
2
|
|
|
3
3
|
module.exports = {
|
|
4
4
|
name: 'docker',
|
|
@@ -8,28 +8,30 @@ module.exports = {
|
|
|
8
8
|
return new Promise((resolve) => {
|
|
9
9
|
console.log(`[Docker Plugin] Executing command: ${target}`);
|
|
10
10
|
|
|
11
|
-
const
|
|
11
|
+
const rawTarget = (target || '').trim();
|
|
12
|
+
const [rawAction, ...args] = rawTarget.split(/\s+/);
|
|
13
|
+
const action = (rawAction || '').toLowerCase();
|
|
12
14
|
const containerName = args.join(' ');
|
|
13
|
-
|
|
14
|
-
let cmd = '';
|
|
15
|
+
let commandArgs = [];
|
|
15
16
|
|
|
16
17
|
if (action === 'list') {
|
|
17
|
-
|
|
18
|
+
commandArgs = ['ps', '--format', '{{.Names}} ({{.Status}})'];
|
|
18
19
|
} else if (['start', 'stop', 'restart'].includes(action) && containerName) {
|
|
19
|
-
|
|
20
|
+
commandArgs = [action, containerName];
|
|
20
21
|
} else {
|
|
21
22
|
return resolve(`Invalid docker command or missing container name: ${target}`);
|
|
22
23
|
}
|
|
23
24
|
|
|
24
|
-
|
|
25
|
+
execFile('docker', commandArgs, (error, stdout, stderr) => {
|
|
25
26
|
if (error) {
|
|
26
|
-
|
|
27
|
+
const stderrText = stderr || '';
|
|
28
|
+
if (error.code === 127 || stderrText.includes('not found') || error.code === 'ENOENT') {
|
|
27
29
|
return resolve('Error: Docker is not installed or not in PATH.');
|
|
28
30
|
}
|
|
29
|
-
if (
|
|
31
|
+
if (stderrText.toLowerCase().includes('permission denied')) {
|
|
30
32
|
return resolve('Error: Permission denied. You might need to add your user to the "docker" group.');
|
|
31
33
|
}
|
|
32
|
-
return resolve(`Docker Error: ${
|
|
34
|
+
return resolve(`Docker Error: ${stderrText || error.message}`);
|
|
33
35
|
}
|
|
34
36
|
|
|
35
37
|
if (action === 'list') {
|
|
@@ -3,6 +3,10 @@ const path = require('path');
|
|
|
3
3
|
const { app, shell } = require('electron');
|
|
4
4
|
const { exec } = require('child_process');
|
|
5
5
|
|
|
6
|
+
function escapeRegExp(text) {
|
|
7
|
+
return String(text).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
8
|
+
}
|
|
9
|
+
|
|
6
10
|
class CustomWorkflows {
|
|
7
11
|
constructor() {
|
|
8
12
|
this.configPath = path.join(app.getPath('userData'), 'workflows.json');
|
|
@@ -86,7 +90,7 @@ class CustomWorkflows {
|
|
|
86
90
|
if (wf.trigger && wf.trigger.type === 'process_running' && wf.trigger.processName) {
|
|
87
91
|
const targetName = wf.trigger.processName.toLowerCase();
|
|
88
92
|
// simplistic exact-word match to avoid partial matches
|
|
89
|
-
const regex = new RegExp(`^${targetName}$`, 'm');
|
|
93
|
+
const regex = new RegExp(`^${escapeRegExp(targetName)}$`, 'm');
|
|
90
94
|
const isRunning = regex.test(runningProcesses);
|
|
91
95
|
|
|
92
96
|
if (isRunning) {
|
|
@@ -124,4 +128,7 @@ class CustomWorkflows {
|
|
|
124
128
|
}
|
|
125
129
|
}
|
|
126
130
|
|
|
127
|
-
|
|
131
|
+
const instance = new CustomWorkflows();
|
|
132
|
+
instance._helpers = { escapeRegExp };
|
|
133
|
+
|
|
134
|
+
module.exports = instance;
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests: chat_router routing helpers
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
jest.mock('@google/genai', () => ({
|
|
6
|
+
GoogleGenAI: jest.fn()
|
|
7
|
+
}));
|
|
8
|
+
|
|
9
|
+
jest.mock('../src/CLI/code_agent', () => ({
|
|
10
|
+
executeCodeTask: jest.fn(),
|
|
11
|
+
_helpers: {
|
|
12
|
+
selectSupportedCodeProvider: jest.fn(() => 'gemini')
|
|
13
|
+
}
|
|
14
|
+
}));
|
|
15
|
+
|
|
16
|
+
jest.mock('../src/System/config_manager', () => ({
|
|
17
|
+
readConfig: jest.fn(() => ({})),
|
|
18
|
+
getAvailableProviders: jest.fn(() => ['gemini'])
|
|
19
|
+
}));
|
|
20
|
+
|
|
21
|
+
describe('chat_router helpers', () => {
|
|
22
|
+
test('recognizes direct folder open request as chat task', () => {
|
|
23
|
+
const { _helpers } = require('../src/CLI/chat_router');
|
|
24
|
+
expect(_helpers.isDirectFilesystemActionRequest('เปิดโฟลเดอร์ xidaidai ให้หน่อย')).toBe(true);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test('does not classify direct folder open request as code intent', () => {
|
|
28
|
+
const { _helpers } = require('../src/CLI/chat_router');
|
|
29
|
+
const route = _helpers.detectCodeIntentHeuristic('open folder xidaidai', process.cwd());
|
|
30
|
+
expect(route).toBe(false);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test('treats small file-related request as normal chat', () => {
|
|
34
|
+
const { _helpers } = require('../src/CLI/chat_router');
|
|
35
|
+
expect(_helpers.isLargeCodeTaskRequest('ดูไฟล์ package.json ให้หน่อย', process.cwd())).toBe(false);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test('treats substantial project fix request as code task', () => {
|
|
39
|
+
const { _helpers } = require('../src/CLI/chat_router');
|
|
40
|
+
expect(_helpers.isLargeCodeTaskRequest('fix the failing tests in this project and verify the result', process.cwd())).toBe(true);
|
|
41
|
+
});
|
|
42
|
+
});
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests: code_agent helpers
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
jest.mock('@google/genai', () => ({
|
|
6
|
+
GoogleGenAI: jest.fn()
|
|
7
|
+
}));
|
|
8
|
+
|
|
9
|
+
jest.mock('axios', () => ({}));
|
|
10
|
+
|
|
11
|
+
jest.mock('../src/System/config_manager', () => ({
|
|
12
|
+
readConfig: jest.fn(() => ({})),
|
|
13
|
+
getAvailableProviders: jest.fn(() => ['ollama', 'gemini'])
|
|
14
|
+
}));
|
|
15
|
+
|
|
16
|
+
jest.mock('../src/CLI/code_session_memory', () => ({
|
|
17
|
+
readWorkspaceSession: jest.fn(() => ({
|
|
18
|
+
summary: '',
|
|
19
|
+
lastTask: '',
|
|
20
|
+
lastVerification: '',
|
|
21
|
+
updatedAt: null
|
|
22
|
+
})),
|
|
23
|
+
writeWorkspaceSession: jest.fn()
|
|
24
|
+
}));
|
|
25
|
+
|
|
26
|
+
const fs = require('fs');
|
|
27
|
+
const path = require('path');
|
|
28
|
+
const os = require('os');
|
|
29
|
+
|
|
30
|
+
describe('code_agent helpers', () => {
|
|
31
|
+
test('extractJson recovers JSON embedded in surrounding text', () => {
|
|
32
|
+
const { _helpers } = require('../src/CLI/code_agent');
|
|
33
|
+
const parsed = _helpers.extractJson('note\n{"action":"finish","input":{"summary":"ok"}}\nthanks');
|
|
34
|
+
expect(parsed.action).toBe('finish');
|
|
35
|
+
expect(parsed.input.summary).toBe('ok');
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test('selectSupportedCodeProvider falls back away from unsupported code providers', () => {
|
|
39
|
+
const { _helpers } = require('../src/CLI/code_agent');
|
|
40
|
+
const selected = _helpers.selectSupportedCodeProvider(
|
|
41
|
+
{ aiProvider: 'ollama' },
|
|
42
|
+
['ollama', 'openai', 'gemini']
|
|
43
|
+
);
|
|
44
|
+
expect(selected).toBe('openai');
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test('selectSupportedCodeProvider keeps configured supported provider when available', () => {
|
|
48
|
+
const { _helpers } = require('../src/CLI/code_agent');
|
|
49
|
+
const selected = _helpers.selectSupportedCodeProvider(
|
|
50
|
+
{ aiProvider: 'anthropic' },
|
|
51
|
+
['anthropic', 'gemini']
|
|
52
|
+
);
|
|
53
|
+
expect(selected).toBe('anthropic');
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test('findPaths can locate directories by partial name', async () => {
|
|
57
|
+
const { _helpers } = require('../src/CLI/code_agent');
|
|
58
|
+
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mint-code-agent-'));
|
|
59
|
+
const targetDir = path.join(tempDir, 'projects', 'xidaidai');
|
|
60
|
+
fs.mkdirSync(targetDir, { recursive: true });
|
|
61
|
+
|
|
62
|
+
try {
|
|
63
|
+
const result = await _helpers.findPaths(tempDir, 'xidaidai', 'dir');
|
|
64
|
+
expect(result).toContain('[dir] projects/xidaidai');
|
|
65
|
+
} finally {
|
|
66
|
+
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
});
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests: docker.js plugin
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
jest.mock('child_process', () => ({
|
|
6
|
+
execFile: jest.fn()
|
|
7
|
+
}));
|
|
8
|
+
|
|
9
|
+
let docker;
|
|
10
|
+
let execFile;
|
|
11
|
+
|
|
12
|
+
beforeEach(() => {
|
|
13
|
+
jest.resetModules();
|
|
14
|
+
({ execFile } = require('child_process'));
|
|
15
|
+
execFile.mockReset();
|
|
16
|
+
docker = require('../src/Plugins/docker');
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
describe('Docker Plugin', () => {
|
|
20
|
+
test('lists running containers', async () => {
|
|
21
|
+
execFile.mockImplementation((command, args, callback) => {
|
|
22
|
+
callback(null, 'web (Up 2 hours)\n', '');
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
const result = await docker.execute('list');
|
|
26
|
+
expect(execFile).toHaveBeenCalledWith('docker', ['ps', '--format', '{{.Names}} ({{.Status}})'], expect.any(Function));
|
|
27
|
+
expect(result).toContain('Running Containers');
|
|
28
|
+
expect(result).toContain('web (Up 2 hours)');
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test('starts a named container without shell interpolation', async () => {
|
|
32
|
+
execFile.mockImplementation((command, args, callback) => {
|
|
33
|
+
callback(null, '', '');
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
const result = await docker.execute('start my-app');
|
|
37
|
+
expect(execFile).toHaveBeenCalledWith('docker', ['start', 'my-app'], expect.any(Function));
|
|
38
|
+
expect(result).toContain('Successfully executed "docker start"');
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test('returns helpful message when command is invalid', async () => {
|
|
42
|
+
const result = await docker.execute('remove my-app');
|
|
43
|
+
expect(result).toContain('Invalid docker command');
|
|
44
|
+
expect(execFile).not.toHaveBeenCalled();
|
|
45
|
+
});
|
|
46
|
+
});
|