recoder-code 2.3.5 → 2.3.6
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/cli/approval-dialog.js +230 -0
- package/cli/collaboration-manager.js +2 -2
- package/cli/diff-generator.js +272 -0
- package/cli/output-formatter.js +265 -0
- package/cli/permission-manager.js +223 -0
- package/package.json +1 -1
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
const readline = require('readline');
|
|
2
|
+
const chalkModule = require('chalk');
|
|
3
|
+
const chalk = chalkModule.default || chalkModule;
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Approval Dialog System - Claude Code Style
|
|
7
|
+
* Multi-choice numbered options with preferences
|
|
8
|
+
*/
|
|
9
|
+
class ApprovalDialog {
|
|
10
|
+
constructor(rl) {
|
|
11
|
+
this.rl = rl;
|
|
12
|
+
this.preferences = new Map(); // Store "don't ask again" preferences
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Show bash command approval with multi-choice options
|
|
17
|
+
*/
|
|
18
|
+
async showBashApproval(command, description = '') {
|
|
19
|
+
const width = 90;
|
|
20
|
+
|
|
21
|
+
// Build dialog
|
|
22
|
+
console.log('');
|
|
23
|
+
console.log(chalk.cyan('╭' + '─'.repeat(width - 2) + '╮'));
|
|
24
|
+
console.log(chalk.cyan('│') + chalk.bold(' Bash command') + ' '.repeat(width - 15) + chalk.cyan('│'));
|
|
25
|
+
console.log(chalk.cyan('│') + ' '.repeat(width - 2) + chalk.cyan('│'));
|
|
26
|
+
console.log(chalk.cyan('│') + ' ' + chalk.white(this.truncate(command, width - 6)) + ' '.repeat(Math.max(0, width - 6 - command.length)) + chalk.cyan('│'));
|
|
27
|
+
|
|
28
|
+
if (description) {
|
|
29
|
+
console.log(chalk.cyan('│') + ' ' + chalk.dim(this.truncate(description, width - 6)) + ' '.repeat(Math.max(0, width - 6 - description.length)) + chalk.cyan('│'));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
console.log(chalk.cyan('│') + ' '.repeat(width - 2) + chalk.cyan('│'));
|
|
33
|
+
console.log(chalk.cyan('│') + chalk.white(' Do you want to proceed?') + ' '.repeat(width - 28) + chalk.cyan('│'));
|
|
34
|
+
console.log(chalk.cyan('│') + chalk.green(' ❯ 1. ') + chalk.white('Yes') + ' '.repeat(width - 13) + chalk.cyan('│'));
|
|
35
|
+
console.log(chalk.cyan('│') + ' ' + chalk.dim('2. ') + chalk.white('Yes, and don\'t ask again for bash commands') + ' '.repeat(Math.max(0, width - 51)) + chalk.cyan('│'));
|
|
36
|
+
console.log(chalk.cyan('│') + ' ' + chalk.dim('3. ') + chalk.white('No, and tell Claude what to do differently ') + chalk.dim('(esc)') + ' '.repeat(Math.max(0, width - 58)) + chalk.cyan('│'));
|
|
37
|
+
console.log(chalk.cyan('╰' + '─'.repeat(width - 2) + '╯'));
|
|
38
|
+
console.log('');
|
|
39
|
+
|
|
40
|
+
return this.waitForChoice(3);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Show file operation approval with multi-choice options
|
|
45
|
+
*/
|
|
46
|
+
async showFileApproval(operation, filePath, diff, stats = {}) {
|
|
47
|
+
const width = 90;
|
|
48
|
+
const { additions = 0, deletions = 0 } = stats;
|
|
49
|
+
|
|
50
|
+
// Determine operation type
|
|
51
|
+
let title = 'File Operation';
|
|
52
|
+
let descText = '';
|
|
53
|
+
|
|
54
|
+
if (operation === 'create') {
|
|
55
|
+
title = 'File Creation';
|
|
56
|
+
descText = `Create ${filePath}`;
|
|
57
|
+
} else if (operation === 'str_replace') {
|
|
58
|
+
title = 'File Edit';
|
|
59
|
+
descText = `Modify ${filePath} (${additions > 0 ? '+' + additions : ''}${deletions > 0 ? ' -' + deletions : ''})`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Build dialog
|
|
63
|
+
console.log('');
|
|
64
|
+
console.log(chalk.cyan('╭' + '─'.repeat(width - 2) + '╮'));
|
|
65
|
+
console.log(chalk.cyan('│') + chalk.bold(` ${title}`) + ' '.repeat(width - title.length - 3) + chalk.cyan('│'));
|
|
66
|
+
console.log(chalk.cyan('│') + ' '.repeat(width - 2) + chalk.cyan('│'));
|
|
67
|
+
console.log(chalk.cyan('│') + ' ' + chalk.white(this.truncate(descText, width - 6)) + ' '.repeat(Math.max(0, width - 6 - descText.length)) + chalk.cyan('│'));
|
|
68
|
+
console.log(chalk.cyan('│') + ' '.repeat(width - 2) + chalk.cyan('│'));
|
|
69
|
+
|
|
70
|
+
// Show truncated diff
|
|
71
|
+
const diffLines = diff.split('\n').slice(0, 8);
|
|
72
|
+
diffLines.forEach(line => {
|
|
73
|
+
const displayLine = this.truncate(line, width - 6);
|
|
74
|
+
console.log(chalk.cyan('│') + ' ' + displayLine + ' '.repeat(Math.max(0, width - 4 - displayLine.length)) + chalk.cyan('│'));
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
if (diff.split('\n').length > 8) {
|
|
78
|
+
const remaining = diff.split('\n').length - 8;
|
|
79
|
+
console.log(chalk.cyan('│') + ' ' + chalk.yellow(`… +${remaining} lines `) + chalk.dim('(ctrl+o to expand)') + ' '.repeat(Math.max(0, width - 30 - remaining.toString().length)) + chalk.cyan('│'));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
console.log(chalk.cyan('│') + ' '.repeat(width - 2) + chalk.cyan('│'));
|
|
83
|
+
console.log(chalk.cyan('│') + chalk.white(' Do you want to proceed?') + ' '.repeat(width - 28) + chalk.cyan('│'));
|
|
84
|
+
console.log(chalk.cyan('│') + chalk.green(' ❯ 1. ') + chalk.white('Yes') + ' '.repeat(width - 13) + chalk.cyan('│'));
|
|
85
|
+
console.log(chalk.cyan('│') + ' ' + chalk.dim('2. ') + chalk.white('Yes, and auto-accept all remaining changes') + ' '.repeat(Math.max(0, width - 50)) + chalk.cyan('│'));
|
|
86
|
+
console.log(chalk.cyan('│') + ' ' + chalk.dim('3. ') + chalk.white('No, skip this change') + ' '.repeat(width - 28) + chalk.cyan('│'));
|
|
87
|
+
console.log(chalk.cyan('│') + ' ' + chalk.dim('4. ') + chalk.white('No, and tell Claude what to change ') + chalk.dim('(esc)') + ' '.repeat(Math.max(0, width - 50)) + chalk.cyan('│'));
|
|
88
|
+
console.log(chalk.cyan('╰' + '─'.repeat(width - 2) + '╯'));
|
|
89
|
+
console.log('');
|
|
90
|
+
|
|
91
|
+
return this.waitForChoice(4);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Wait for user to select a numbered choice
|
|
96
|
+
*/
|
|
97
|
+
async waitForChoice(maxChoice) {
|
|
98
|
+
return new Promise((resolve) => {
|
|
99
|
+
const handleInput = (input) => {
|
|
100
|
+
const choice = input.trim();
|
|
101
|
+
|
|
102
|
+
// Handle escape key
|
|
103
|
+
if (input === '\x1b') {
|
|
104
|
+
cleanup();
|
|
105
|
+
resolve({ choice: maxChoice, alternative: true }); // Last option is always "tell me what to do"
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Handle numbered choices
|
|
110
|
+
const num = parseInt(choice);
|
|
111
|
+
if (num >= 1 && num <= maxChoice) {
|
|
112
|
+
cleanup();
|
|
113
|
+
resolve({ choice: num, alternative: false });
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Invalid input - show again
|
|
118
|
+
process.stdout.write(chalk.red('Invalid choice. ') + chalk.dim(`Enter 1-${maxChoice}: `));
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
const cleanup = () => {
|
|
122
|
+
process.stdin.removeListener('data', handleInput);
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
// Listen for keypress
|
|
126
|
+
process.stdout.write(chalk.dim(`Enter choice (1-${maxChoice}): `));
|
|
127
|
+
process.stdin.once('data', handleInput);
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Show simple yes/no confirmation
|
|
133
|
+
*/
|
|
134
|
+
async showConfirmation(message) {
|
|
135
|
+
const width = 90;
|
|
136
|
+
|
|
137
|
+
console.log('');
|
|
138
|
+
console.log(chalk.cyan('╭' + '─'.repeat(width - 2) + '╮'));
|
|
139
|
+
console.log(chalk.cyan('│') + ' ' + chalk.white(this.truncate(message, width - 6)) + ' '.repeat(Math.max(0, width - 6 - message.length)) + chalk.cyan('│'));
|
|
140
|
+
console.log(chalk.cyan('│') + ' '.repeat(width - 2) + chalk.cyan('│'));
|
|
141
|
+
console.log(chalk.cyan('│') + chalk.green(' ❯ 1. ') + chalk.white('Yes') + ' '.repeat(width - 13) + chalk.cyan('│'));
|
|
142
|
+
console.log(chalk.cyan('│') + ' ' + chalk.dim('2. ') + chalk.white('No') + ' '.repeat(width - 12) + chalk.cyan('│'));
|
|
143
|
+
console.log(chalk.cyan('╰' + '─'.repeat(width - 2) + '╯'));
|
|
144
|
+
console.log('');
|
|
145
|
+
|
|
146
|
+
const result = await this.waitForChoice(2);
|
|
147
|
+
return result.choice === 1;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Store preference to not ask again
|
|
152
|
+
*/
|
|
153
|
+
setPreference(key, value) {
|
|
154
|
+
this.preferences.set(key, value);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Check if we should ask based on preferences
|
|
159
|
+
*/
|
|
160
|
+
shouldAsk(key) {
|
|
161
|
+
return !this.preferences.has(key);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Clear all preferences
|
|
166
|
+
*/
|
|
167
|
+
clearPreferences() {
|
|
168
|
+
this.preferences.clear();
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Truncate text to fit width
|
|
173
|
+
*/
|
|
174
|
+
truncate(text, maxLength) {
|
|
175
|
+
// Remove ANSI codes for length calculation
|
|
176
|
+
const plainText = text.replace(/\x1b\[[0-9;]*m/g, '');
|
|
177
|
+
if (plainText.length <= maxLength) return text;
|
|
178
|
+
|
|
179
|
+
// Find position in original text that corresponds to maxLength in plain text
|
|
180
|
+
let plainPos = 0;
|
|
181
|
+
let textPos = 0;
|
|
182
|
+
while (plainPos < maxLength - 3 && textPos < text.length) {
|
|
183
|
+
if (text.substr(textPos, 2) === '\x1b[') {
|
|
184
|
+
// Skip ANSI code
|
|
185
|
+
const endPos = text.indexOf('m', textPos);
|
|
186
|
+
if (endPos > -1) {
|
|
187
|
+
textPos = endPos + 1;
|
|
188
|
+
} else {
|
|
189
|
+
textPos++;
|
|
190
|
+
}
|
|
191
|
+
} else {
|
|
192
|
+
plainPos++;
|
|
193
|
+
textPos++;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
return text.substring(0, textPos) + '...';
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Format diff for display in dialog
|
|
202
|
+
*/
|
|
203
|
+
formatDiffForDialog(oldContent, newContent) {
|
|
204
|
+
const oldLines = oldContent.split('\n');
|
|
205
|
+
const newLines = newContent.split('\n');
|
|
206
|
+
const maxLines = Math.max(oldLines.length, newLines.length);
|
|
207
|
+
|
|
208
|
+
let formatted = [];
|
|
209
|
+
for (let i = 0; i < Math.min(maxLines, 8); i++) {
|
|
210
|
+
const oldLine = oldLines[i];
|
|
211
|
+
const newLine = newLines[i];
|
|
212
|
+
const lineNum = String(i + 1).padStart(3, ' ');
|
|
213
|
+
|
|
214
|
+
if (oldLine !== newLine) {
|
|
215
|
+
if (oldLine !== undefined) {
|
|
216
|
+
formatted.push(chalk.red(`-${lineNum} ${oldLine}`));
|
|
217
|
+
}
|
|
218
|
+
if (newLine !== undefined) {
|
|
219
|
+
formatted.push(chalk.green(`+${lineNum} ${newLine}`));
|
|
220
|
+
}
|
|
221
|
+
} else {
|
|
222
|
+
formatted.push(chalk.dim(` ${lineNum} ${oldLine || ''}`));
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
return formatted.join('\n');
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
module.exports = ApprovalDialog;
|
|
@@ -32,9 +32,9 @@ class CollaborationManager {
|
|
|
32
32
|
try {
|
|
33
33
|
// Load existing team workspaces
|
|
34
34
|
await this.loadTeamWorkspaces();
|
|
35
|
-
|
|
35
|
+
// Silent initialization - no console output
|
|
36
36
|
} catch (error) {
|
|
37
|
-
|
|
37
|
+
// Silent error handling
|
|
38
38
|
}
|
|
39
39
|
}
|
|
40
40
|
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
// Handle chalk properly
|
|
4
|
+
const chalkModule = require('chalk');
|
|
5
|
+
const chalk = chalkModule.default || chalkModule;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Diff Generator for Recoder Code
|
|
9
|
+
* Generates visual diffs for file operations
|
|
10
|
+
* Similar to Claude Code and OpenCode diff displays
|
|
11
|
+
*/
|
|
12
|
+
class DiffGenerator {
|
|
13
|
+
constructor() {
|
|
14
|
+
this.maxDiffLines = 200; // Recommended by Claude Code
|
|
15
|
+
|
|
16
|
+
// File type icons mapping
|
|
17
|
+
this.fileIcons = {
|
|
18
|
+
'.js': '📜',
|
|
19
|
+
'.ts': '📘',
|
|
20
|
+
'.jsx': '⚛️',
|
|
21
|
+
'.tsx': '⚛️',
|
|
22
|
+
'.py': '🐍',
|
|
23
|
+
'.rb': '💎',
|
|
24
|
+
'.go': '🔷',
|
|
25
|
+
'.rs': '🦀',
|
|
26
|
+
'.java': '☕',
|
|
27
|
+
'.cpp': '⚙️',
|
|
28
|
+
'.c': '⚙️',
|
|
29
|
+
'.php': '🐘',
|
|
30
|
+
'.html': '🌐',
|
|
31
|
+
'.css': '🎨',
|
|
32
|
+
'.scss': '🎨',
|
|
33
|
+
'.json': '📋',
|
|
34
|
+
'.md': '📝',
|
|
35
|
+
'.yaml': '⚙️',
|
|
36
|
+
'.yml': '⚙️',
|
|
37
|
+
'.xml': '📄',
|
|
38
|
+
'.sh': '🔧',
|
|
39
|
+
'.sql': '🗃️',
|
|
40
|
+
'.txt': '📄',
|
|
41
|
+
'.log': '📊'
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Get icon for file type
|
|
47
|
+
*/
|
|
48
|
+
getFileIcon(filePath) {
|
|
49
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
50
|
+
return this.fileIcons[ext] || '📄';
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Generate diff for any file operation
|
|
55
|
+
* @param {string} operation - 'create', 'str_replace', 'view'
|
|
56
|
+
* @param {string} filePath
|
|
57
|
+
* @param {string} oldContent
|
|
58
|
+
* @param {string} newContent
|
|
59
|
+
* @returns {string} - Formatted diff
|
|
60
|
+
*/
|
|
61
|
+
generateDiff(operation, filePath, oldContent = '', newContent = '') {
|
|
62
|
+
if (operation === 'create') {
|
|
63
|
+
return this.generateCreateDiff(filePath, newContent);
|
|
64
|
+
} else if (operation === 'str_replace') {
|
|
65
|
+
return this.generateEditDiff(filePath, oldContent, newContent);
|
|
66
|
+
} else if (operation === 'view') {
|
|
67
|
+
return this.generateViewDiff(filePath, oldContent);
|
|
68
|
+
}
|
|
69
|
+
return '';
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Generate diff for file creation
|
|
74
|
+
*/
|
|
75
|
+
generateCreateDiff(filePath, content) {
|
|
76
|
+
const lines = content.split('\n');
|
|
77
|
+
const size = Buffer.byteLength(content, 'utf8');
|
|
78
|
+
const fileName = path.basename(filePath);
|
|
79
|
+
const ext = path.extname(filePath);
|
|
80
|
+
const icon = this.getFileIcon(filePath);
|
|
81
|
+
|
|
82
|
+
// Build header
|
|
83
|
+
let diff = '\n';
|
|
84
|
+
diff += chalk.cyan('┌' + '─'.repeat(58) + '┐\n');
|
|
85
|
+
diff += chalk.cyan('│ ') + chalk.bold.green(`${icon} Create: `) + chalk.bold(fileName);
|
|
86
|
+
diff += ' '.repeat(Math.max(0, 58 - 14 - fileName.length)) + chalk.cyan('│\n');
|
|
87
|
+
diff += chalk.cyan('│ ') + chalk.dim(`Size: ${this.formatBytes(size)} | Lines: ${lines.length} | Type: ${ext || 'txt'}`);
|
|
88
|
+
diff += ' '.repeat(Math.max(0, 58 - 35 - ext.length - size.toString().length - lines.length.toString().length)) + chalk.cyan('│\n');
|
|
89
|
+
diff += chalk.cyan('├' + '─'.repeat(58) + '┤\n');
|
|
90
|
+
|
|
91
|
+
// Show content with line numbers (limit to max lines)
|
|
92
|
+
const displayLines = lines.slice(0, this.maxDiffLines);
|
|
93
|
+
const lineNumWidth = displayLines.length.toString().length;
|
|
94
|
+
|
|
95
|
+
displayLines.forEach((line, i) => {
|
|
96
|
+
const lineNum = (i + 1).toString().padStart(lineNumWidth, ' ');
|
|
97
|
+
const displayLine = this.truncateLine(line, 52);
|
|
98
|
+
diff += chalk.cyan('│ ') + chalk.green(`+ ${lineNum} │ ${displayLine}`);
|
|
99
|
+
diff += ' '.repeat(Math.max(0, 58 - 6 - lineNumWidth - displayLine.length)) + chalk.cyan('│\n');
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
if (lines.length > this.maxDiffLines) {
|
|
103
|
+
diff += chalk.cyan('│ ') + chalk.dim(`... ${lines.length - this.maxDiffLines} more lines`);
|
|
104
|
+
diff += ' '.repeat(Math.max(0, 58 - 15 - (lines.length - this.maxDiffLines).toString().length)) + chalk.cyan('│\n');
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Footer
|
|
108
|
+
diff += chalk.cyan('├' + '─'.repeat(58) + '┤\n');
|
|
109
|
+
diff += chalk.cyan('│ ') + chalk.green(`+${lines.length} lines to add`);
|
|
110
|
+
diff += ' '.repeat(Math.max(0, 58 - 17 - lines.length.toString().length)) + chalk.cyan('│\n');
|
|
111
|
+
diff += chalk.cyan('└' + '─'.repeat(58) + '┘\n');
|
|
112
|
+
|
|
113
|
+
return diff;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Generate diff for file editing
|
|
118
|
+
*/
|
|
119
|
+
generateEditDiff(filePath, oldContent, newContent) {
|
|
120
|
+
const oldLines = oldContent.split('\n');
|
|
121
|
+
const newLines = newContent.split('\n');
|
|
122
|
+
const fileName = path.basename(filePath);
|
|
123
|
+
const icon = this.getFileIcon(filePath);
|
|
124
|
+
|
|
125
|
+
// Calculate stats
|
|
126
|
+
const oldSize = Buffer.byteLength(oldContent, 'utf8');
|
|
127
|
+
const newSize = Buffer.byteLength(newContent, 'utf8');
|
|
128
|
+
const sizeDiff = newSize - oldSize;
|
|
129
|
+
const lineDiff = newLines.length - oldLines.length;
|
|
130
|
+
|
|
131
|
+
// Build header
|
|
132
|
+
let diff = '\n';
|
|
133
|
+
diff += chalk.cyan('┌' + '─'.repeat(58) + '┐\n');
|
|
134
|
+
diff += chalk.cyan('│ ') + chalk.bold.yellow(`${icon} Edit: `) + chalk.bold(fileName);
|
|
135
|
+
diff += ' '.repeat(Math.max(0, 58 - 13 - fileName.length)) + chalk.cyan('│\n');
|
|
136
|
+
diff += chalk.cyan('│ ') + chalk.dim(`Lines: ${oldLines.length} → ${newLines.length} (${lineDiff >= 0 ? '+' : ''}${lineDiff})`);
|
|
137
|
+
diff += ' '.repeat(Math.max(0, 58 - 20 - oldLines.length.toString().length - newLines.length.toString().length - lineDiff.toString().length)) + chalk.cyan('│\n');
|
|
138
|
+
diff += chalk.cyan('├' + '─'.repeat(58) + '┤\n');
|
|
139
|
+
|
|
140
|
+
// Generate unified diff
|
|
141
|
+
const diffLines = this.generateUnifiedDiff(oldLines, newLines);
|
|
142
|
+
const displayLines = diffLines.slice(0, this.maxDiffLines);
|
|
143
|
+
|
|
144
|
+
displayLines.forEach(({ type, lineNum, content }) => {
|
|
145
|
+
const displayLine = this.truncateLine(content, 48);
|
|
146
|
+
const num = lineNum.toString().padStart(3, ' ');
|
|
147
|
+
|
|
148
|
+
if (type === 'add') {
|
|
149
|
+
diff += chalk.cyan('│ ') + chalk.green(`+ ${num} │ ${displayLine}`);
|
|
150
|
+
diff += ' '.repeat(Math.max(0, 58 - 8 - displayLine.length)) + chalk.cyan('│\n');
|
|
151
|
+
} else if (type === 'remove') {
|
|
152
|
+
diff += chalk.cyan('│ ') + chalk.red(`- ${num} │ ${displayLine}`);
|
|
153
|
+
diff += ' '.repeat(Math.max(0, 58 - 8 - displayLine.length)) + chalk.cyan('│\n');
|
|
154
|
+
} else {
|
|
155
|
+
diff += chalk.cyan('│ ') + chalk.dim(` ${num} │ ${displayLine}`);
|
|
156
|
+
diff += ' '.repeat(Math.max(0, 58 - 8 - displayLine.length)) + chalk.cyan('│\n');
|
|
157
|
+
}
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
if (diffLines.length > this.maxDiffLines) {
|
|
161
|
+
diff += chalk.cyan('│ ') + chalk.dim(`... ${diffLines.length - this.maxDiffLines} more lines`);
|
|
162
|
+
diff += ' '.repeat(Math.max(0, 58 - 15 - (diffLines.length - this.maxDiffLines).toString().length)) + chalk.cyan('│\n');
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Footer with stats
|
|
166
|
+
diff += chalk.cyan('├' + '─'.repeat(58) + '┤\n');
|
|
167
|
+
const added = diffLines.filter(l => l.type === 'add').length;
|
|
168
|
+
const removed = diffLines.filter(l => l.type === 'remove').length;
|
|
169
|
+
diff += chalk.cyan('│ ') + chalk.dim(`Changes: `) + chalk.green(`+${added}`) + chalk.dim(' ') + chalk.red(`-${removed}`);
|
|
170
|
+
diff += ' '.repeat(Math.max(0, 58 - 14 - added.toString().length - removed.toString().length)) + chalk.cyan('│\n');
|
|
171
|
+
diff += chalk.cyan('└' + '─'.repeat(58) + '┘\n');
|
|
172
|
+
|
|
173
|
+
return diff;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Generate view-only diff (for file preview)
|
|
178
|
+
*/
|
|
179
|
+
generateViewDiff(filePath, content) {
|
|
180
|
+
const lines = content.split('\n');
|
|
181
|
+
const size = Buffer.byteLength(content, 'utf8');
|
|
182
|
+
const fileName = path.basename(filePath);
|
|
183
|
+
const icon = this.getFileIcon(filePath);
|
|
184
|
+
|
|
185
|
+
let diff = '\n';
|
|
186
|
+
diff += chalk.cyan('┌' + '─'.repeat(58) + '┐\n');
|
|
187
|
+
diff += chalk.cyan('│ ') + chalk.bold.blue(`${icon} View: `) + chalk.bold(fileName);
|
|
188
|
+
diff += ' '.repeat(Math.max(0, 58 - 13 - fileName.length)) + chalk.cyan('│\n');
|
|
189
|
+
diff += chalk.cyan('│ ') + chalk.dim(`Size: ${this.formatBytes(size)} | Lines: ${lines.length}`);
|
|
190
|
+
diff += ' '.repeat(Math.max(0, 58 - 25 - size.toString().length - lines.length.toString().length)) + chalk.cyan('│\n');
|
|
191
|
+
diff += chalk.cyan('├' + '─'.repeat(58) + '┤\n');
|
|
192
|
+
|
|
193
|
+
const displayLines = lines.slice(0, this.maxDiffLines);
|
|
194
|
+
const lineNumWidth = displayLines.length.toString().length;
|
|
195
|
+
|
|
196
|
+
displayLines.forEach((line, i) => {
|
|
197
|
+
const lineNum = (i + 1).toString().padStart(lineNumWidth, ' ');
|
|
198
|
+
const displayLine = this.truncateLine(line, 52 - lineNumWidth);
|
|
199
|
+
diff += chalk.cyan('│ ') + chalk.dim(`${lineNum} │ ${displayLine}`);
|
|
200
|
+
diff += ' '.repeat(Math.max(0, 58 - 4 - lineNumWidth - displayLine.length)) + chalk.cyan('│\n');
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
if (lines.length > this.maxDiffLines) {
|
|
204
|
+
diff += chalk.cyan('│ ') + chalk.dim(`... ${lines.length - this.maxDiffLines} more lines`);
|
|
205
|
+
diff += ' '.repeat(Math.max(0, 58 - 15 - (lines.length - this.maxDiffLines).toString().length)) + chalk.cyan('│\n');
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
diff += chalk.cyan('└' + '─'.repeat(58) + '┘\n');
|
|
209
|
+
|
|
210
|
+
return diff;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Generate unified diff from two arrays of lines
|
|
215
|
+
* Returns array of { type, lineNum, content }
|
|
216
|
+
*/
|
|
217
|
+
generateUnifiedDiff(oldLines, newLines) {
|
|
218
|
+
const diff = [];
|
|
219
|
+
const maxLines = Math.max(oldLines.length, newLines.length);
|
|
220
|
+
|
|
221
|
+
// Simple line-by-line diff (can be enhanced with better algorithm)
|
|
222
|
+
let oldIdx = 0;
|
|
223
|
+
let newIdx = 0;
|
|
224
|
+
|
|
225
|
+
while (oldIdx < oldLines.length || newIdx < newLines.length) {
|
|
226
|
+
const oldLine = oldLines[oldIdx];
|
|
227
|
+
const newLine = newLines[newIdx];
|
|
228
|
+
|
|
229
|
+
if (oldLine === newLine) {
|
|
230
|
+
// Unchanged line
|
|
231
|
+
diff.push({ type: 'context', lineNum: newIdx + 1, content: newLine });
|
|
232
|
+
oldIdx++;
|
|
233
|
+
newIdx++;
|
|
234
|
+
} else if (oldIdx >= oldLines.length) {
|
|
235
|
+
// Only new lines left
|
|
236
|
+
diff.push({ type: 'add', lineNum: newIdx + 1, content: newLine });
|
|
237
|
+
newIdx++;
|
|
238
|
+
} else if (newIdx >= newLines.length) {
|
|
239
|
+
// Only old lines left
|
|
240
|
+
diff.push({ type: 'remove', lineNum: oldIdx + 1, content: oldLine });
|
|
241
|
+
oldIdx++;
|
|
242
|
+
} else {
|
|
243
|
+
// Lines differ - simple approach: mark as remove + add
|
|
244
|
+
diff.push({ type: 'remove', lineNum: oldIdx + 1, content: oldLine });
|
|
245
|
+
diff.push({ type: 'add', lineNum: newIdx + 1, content: newLine });
|
|
246
|
+
oldIdx++;
|
|
247
|
+
newIdx++;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
return diff;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Format bytes to human-readable size
|
|
256
|
+
*/
|
|
257
|
+
formatBytes(bytes) {
|
|
258
|
+
if (bytes < 1024) return bytes + 'B';
|
|
259
|
+
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + 'KB';
|
|
260
|
+
return (bytes / (1024 * 1024)).toFixed(1) + 'MB';
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Truncate line to max width
|
|
265
|
+
*/
|
|
266
|
+
truncateLine(line, maxWidth) {
|
|
267
|
+
if (line.length <= maxWidth) return line;
|
|
268
|
+
return line.substring(0, maxWidth - 3) + '...';
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
module.exports = DiffGenerator;
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
const chalkModule = require('chalk');
|
|
2
|
+
const chalk = chalkModule.default || chalkModule;
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Output Formatter for Claude Code-style display
|
|
6
|
+
* Provides collapsible sections, progress indicators, and clean formatting
|
|
7
|
+
*/
|
|
8
|
+
class OutputFormatter {
|
|
9
|
+
constructor() {
|
|
10
|
+
this.collapsedSections = new Map();
|
|
11
|
+
this.truncateLength = 10; // Show first N lines by default
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Format tool execution header (like Claude Code's ⏺ Bash(...))
|
|
16
|
+
*/
|
|
17
|
+
formatToolHeader(toolName, args) {
|
|
18
|
+
let header = chalk.dim('⏺ ');
|
|
19
|
+
|
|
20
|
+
switch (toolName) {
|
|
21
|
+
case 'bash':
|
|
22
|
+
header += chalk.cyan(`Bash(${this.truncateArg(args.command)})`);
|
|
23
|
+
break;
|
|
24
|
+
case 'str_replace_editor':
|
|
25
|
+
header += chalk.cyan(`Edit(${args.command} ${this.truncateArg(args.path)})`);
|
|
26
|
+
break;
|
|
27
|
+
case 'codebase_search':
|
|
28
|
+
header += chalk.cyan(`Search(${this.truncateArg(args.query)})`);
|
|
29
|
+
break;
|
|
30
|
+
case 'web_fetch':
|
|
31
|
+
header += chalk.cyan(`WebFetch(${this.truncateArg(args.url)})`);
|
|
32
|
+
break;
|
|
33
|
+
default:
|
|
34
|
+
header += chalk.cyan(`${toolName}(...)`);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return header;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Format tool execution body with collapsible output
|
|
42
|
+
*/
|
|
43
|
+
formatToolBody(output, sectionId, truncate = true) {
|
|
44
|
+
if (!output) return '';
|
|
45
|
+
|
|
46
|
+
const lines = output.split('\n');
|
|
47
|
+
const totalLines = lines.length;
|
|
48
|
+
|
|
49
|
+
if (!truncate || totalLines <= this.truncateLength) {
|
|
50
|
+
// Show all lines with indent
|
|
51
|
+
return lines.map(line => chalk.dim(' ⎿ ') + line).join('\n');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Show truncated with expand option
|
|
55
|
+
const visibleLines = lines.slice(0, this.truncateLength);
|
|
56
|
+
const hiddenCount = totalLines - this.truncateLength;
|
|
57
|
+
|
|
58
|
+
let formatted = visibleLines.map(line => chalk.dim(' ⎿ ') + line).join('\n');
|
|
59
|
+
formatted += '\n' + chalk.dim(' ⎿ ') + chalk.yellow(`… +${hiddenCount} lines `) + chalk.dim('(ctrl+o to expand)');
|
|
60
|
+
|
|
61
|
+
// Store full output for expansion
|
|
62
|
+
this.collapsedSections.set(sectionId, lines);
|
|
63
|
+
|
|
64
|
+
return formatted;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Format thinking/processing indicator
|
|
69
|
+
*/
|
|
70
|
+
formatThinking(message = 'Thinking') {
|
|
71
|
+
return chalk.dim('✳ ') + chalk.cyan(message) + chalk.dim('…');
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Format status line (bottom bar like Claude Code)
|
|
76
|
+
*/
|
|
77
|
+
formatStatusBar(mode, sessionInfo = {}) {
|
|
78
|
+
const modeIcons = {
|
|
79
|
+
ask: '⏸',
|
|
80
|
+
auto: '⏵⏵',
|
|
81
|
+
plan: '📖',
|
|
82
|
+
build: '🔨'
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const modeIcon = modeIcons[mode] || '⏸';
|
|
86
|
+
const modeText = mode === 'auto' ? 'accept edits on' : mode + ' mode';
|
|
87
|
+
|
|
88
|
+
let status = chalk.dim(' ') + modeIcon + chalk.dim(` ${modeText} `);
|
|
89
|
+
status += chalk.dim('(shift+tab to cycle)');
|
|
90
|
+
|
|
91
|
+
// Add background tasks if any
|
|
92
|
+
if (sessionInfo.backgroundTasks > 0) {
|
|
93
|
+
status += chalk.dim(` · ${sessionInfo.backgroundTasks} background task${sessionInfo.backgroundTasks > 1 ? 's' : ''}`);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Add shortcuts hint
|
|
97
|
+
status += chalk.dim(' · ? for shortcuts');
|
|
98
|
+
|
|
99
|
+
// Add context info
|
|
100
|
+
if (sessionInfo.contextUsage) {
|
|
101
|
+
status += chalk.dim(` Context left: ${sessionInfo.contextUsage}%`);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return status;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Format separator line
|
|
109
|
+
*/
|
|
110
|
+
formatSeparator(width = 90) {
|
|
111
|
+
return chalk.dim('─'.repeat(width));
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Format success message (like Claude Code's ⏺ Perfect! ...)
|
|
116
|
+
*/
|
|
117
|
+
formatSuccess(message) {
|
|
118
|
+
return chalk.dim('⏺ ') + chalk.green(message);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Format error message
|
|
123
|
+
*/
|
|
124
|
+
formatError(message) {
|
|
125
|
+
return chalk.dim('⏺ ') + chalk.red('Error: ') + message;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Format info message
|
|
130
|
+
*/
|
|
131
|
+
formatInfo(message) {
|
|
132
|
+
return chalk.dim('⏺ ') + chalk.cyan(message);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Truncate argument for display
|
|
137
|
+
*/
|
|
138
|
+
truncateArg(arg, maxLength = 50) {
|
|
139
|
+
if (!arg) return '';
|
|
140
|
+
const str = String(arg);
|
|
141
|
+
if (str.length <= maxLength) return str;
|
|
142
|
+
return str.substring(0, maxLength - 3) + '...';
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Format multi-line output with proper indentation
|
|
147
|
+
*/
|
|
148
|
+
formatIndented(text, indent = ' ') {
|
|
149
|
+
const lines = text.split('\n');
|
|
150
|
+
return lines.map(line => chalk.dim(indent + '⎿ ') + line).join('\n');
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Format code block
|
|
155
|
+
*/
|
|
156
|
+
formatCodeBlock(code, language = '') {
|
|
157
|
+
const lines = code.split('\n');
|
|
158
|
+
return lines.map((line, i) => {
|
|
159
|
+
const lineNum = String(i + 1).padStart(3, ' ');
|
|
160
|
+
return chalk.dim(` ${lineNum} `) + chalk.gray('│ ') + line;
|
|
161
|
+
}).join('\n');
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Expand collapsed section
|
|
166
|
+
*/
|
|
167
|
+
expandSection(sectionId) {
|
|
168
|
+
const lines = this.collapsedSections.get(sectionId);
|
|
169
|
+
if (!lines) return null;
|
|
170
|
+
|
|
171
|
+
return lines.map(line => chalk.dim(' ⎿ ') + line).join('\n');
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Format progress with spinner
|
|
176
|
+
*/
|
|
177
|
+
formatProgress(message, step = 0, total = 0) {
|
|
178
|
+
const spinners = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
|
179
|
+
const spinner = spinners[step % spinners.length];
|
|
180
|
+
|
|
181
|
+
let progress = chalk.cyan(spinner) + ' ' + message;
|
|
182
|
+
|
|
183
|
+
if (total > 0) {
|
|
184
|
+
progress += chalk.dim(` (${step}/${total})`);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return progress;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Format file tree structure
|
|
192
|
+
*/
|
|
193
|
+
formatFileTree(files, depth = 0) {
|
|
194
|
+
const indent = ' '.repeat(depth);
|
|
195
|
+
return files.map(file => {
|
|
196
|
+
const icon = file.isDirectory ? '📁' : this.getFileIcon(file.name);
|
|
197
|
+
return chalk.dim(indent) + icon + ' ' + chalk.white(file.name);
|
|
198
|
+
}).join('\n');
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Get file icon by extension
|
|
203
|
+
*/
|
|
204
|
+
getFileIcon(filename) {
|
|
205
|
+
const ext = filename.split('.').pop();
|
|
206
|
+
const icons = {
|
|
207
|
+
'js': '📜', 'ts': '📘', 'jsx': '⚛️', 'tsx': '⚛️',
|
|
208
|
+
'py': '🐍', 'rb': '💎', 'go': '🔷', 'rs': '🦀',
|
|
209
|
+
'java': '☕', 'cpp': '⚙️', 'c': '⚙️', 'php': '🐘',
|
|
210
|
+
'html': '🌐', 'css': '🎨', 'json': '📋', 'md': '📝',
|
|
211
|
+
'yaml': '⚙️', 'yml': '⚙️', 'xml': '📄', 'sh': '🔧',
|
|
212
|
+
'sql': '🗃️', 'txt': '📄', 'log': '📊'
|
|
213
|
+
};
|
|
214
|
+
return icons[ext] || '📄';
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Format list with bullets
|
|
219
|
+
*/
|
|
220
|
+
formatList(items, bullet = '•') {
|
|
221
|
+
return items.map(item => chalk.dim(' ' + bullet + ' ') + item).join('\n');
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Format numbered list
|
|
226
|
+
*/
|
|
227
|
+
formatNumberedList(items) {
|
|
228
|
+
return items.map((item, i) => {
|
|
229
|
+
const num = String(i + 1).padStart(2, ' ');
|
|
230
|
+
return chalk.dim(` ${num}. `) + item;
|
|
231
|
+
}).join('\n');
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Clear current line
|
|
236
|
+
*/
|
|
237
|
+
clearLine() {
|
|
238
|
+
process.stdout.write('\r\x1b[K');
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Move cursor up N lines
|
|
243
|
+
*/
|
|
244
|
+
moveCursorUp(lines = 1) {
|
|
245
|
+
process.stdout.write(`\x1b[${lines}A`);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Format elapsed time
|
|
250
|
+
*/
|
|
251
|
+
formatElapsedTime(startTime) {
|
|
252
|
+
const elapsed = Date.now() - startTime;
|
|
253
|
+
const seconds = Math.floor(elapsed / 1000);
|
|
254
|
+
|
|
255
|
+
if (seconds < 60) {
|
|
256
|
+
return `${seconds}s`;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const minutes = Math.floor(seconds / 60);
|
|
260
|
+
const remainingSeconds = seconds % 60;
|
|
261
|
+
return `${minutes}m ${remainingSeconds}s`;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
module.exports = OutputFormatter;
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const os = require('os');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Permission Manager for Recoder Code
|
|
7
|
+
* Manages approval workflows for file operations and commands
|
|
8
|
+
* Similar to Claude Code and OpenCode permission systems
|
|
9
|
+
*/
|
|
10
|
+
class PermissionManager {
|
|
11
|
+
constructor() {
|
|
12
|
+
this.configPath = path.join(os.homedir(), '.recoder-code', 'config.json');
|
|
13
|
+
this.config = this.loadConfig();
|
|
14
|
+
this.sessionAutoAccept = false;
|
|
15
|
+
this.sessionMode = null; // Override config mode for current session
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Load configuration from ~/.recoder-code/config.json
|
|
20
|
+
*/
|
|
21
|
+
loadConfig() {
|
|
22
|
+
try {
|
|
23
|
+
if (fs.existsSync(this.configPath)) {
|
|
24
|
+
const configData = fs.readFileSync(this.configPath, 'utf8');
|
|
25
|
+
const config = JSON.parse(configData);
|
|
26
|
+
|
|
27
|
+
// Ensure permission object exists
|
|
28
|
+
if (!config.permission) {
|
|
29
|
+
config.permission = this.getDefaultPermissions();
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return config;
|
|
33
|
+
}
|
|
34
|
+
} catch (error) {
|
|
35
|
+
// Silent error - use defaults
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Default configuration
|
|
39
|
+
return {
|
|
40
|
+
permission: this.getDefaultPermissions()
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Get default permission settings
|
|
46
|
+
*/
|
|
47
|
+
getDefaultPermissions() {
|
|
48
|
+
return {
|
|
49
|
+
mode: 'ask', // 'ask', 'auto', 'deny'
|
|
50
|
+
edit: 'ask', // File editing permission
|
|
51
|
+
bash: 'ask', // Bash command permission
|
|
52
|
+
autoAcceptAll: false
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Save configuration to ~/.recoder-code/config.json
|
|
58
|
+
*/
|
|
59
|
+
saveConfig() {
|
|
60
|
+
try {
|
|
61
|
+
const configDir = path.dirname(this.configPath);
|
|
62
|
+
if (!fs.existsSync(configDir)) {
|
|
63
|
+
fs.mkdirSync(configDir, { recursive: true });
|
|
64
|
+
}
|
|
65
|
+
fs.writeFileSync(this.configPath, JSON.stringify(this.config, null, 2));
|
|
66
|
+
return true;
|
|
67
|
+
} catch (error) {
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Check if we should ask for permission for a specific operation
|
|
74
|
+
* @param {string} operation - 'edit', 'bash', 'web_fetch', etc.
|
|
75
|
+
* @returns {boolean} - true if should ask, false if auto-approve
|
|
76
|
+
*/
|
|
77
|
+
shouldAskPermission(operation) {
|
|
78
|
+
// Ensure config and permission object exist
|
|
79
|
+
if (!this.config || !this.config.permission) {
|
|
80
|
+
this.config = { permission: this.getDefaultPermissions() };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Session auto-accept overrides everything
|
|
84
|
+
if (this.sessionAutoAccept) {
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Session mode overrides config
|
|
89
|
+
const mode = this.sessionMode || this.config.permission.mode || 'ask';
|
|
90
|
+
|
|
91
|
+
// Check global mode first
|
|
92
|
+
if (mode === 'auto') {
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (mode === 'deny') {
|
|
97
|
+
return true; // Will be denied, but still "ask"
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Check operation-specific permission
|
|
101
|
+
const operationPerm = this.config.permission[operation] || 'ask';
|
|
102
|
+
if (operationPerm === 'allow') {
|
|
103
|
+
return false;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (operationPerm === 'deny') {
|
|
107
|
+
return true;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Default to ask
|
|
111
|
+
return true;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Check if an operation is denied
|
|
116
|
+
* @param {string} operation
|
|
117
|
+
* @returns {boolean}
|
|
118
|
+
*/
|
|
119
|
+
isDenied(operation) {
|
|
120
|
+
// Ensure config and permission object exist
|
|
121
|
+
if (!this.config || !this.config.permission) {
|
|
122
|
+
this.config = { permission: this.getDefaultPermissions() };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const mode = this.sessionMode || this.config.permission.mode || 'ask';
|
|
126
|
+
|
|
127
|
+
if (mode === 'deny') {
|
|
128
|
+
return true;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const operationPerm = this.config.permission[operation] || 'ask';
|
|
132
|
+
return operationPerm === 'deny';
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Enable auto-accept for the current session
|
|
137
|
+
* Similar to Claude Code's "⏵⏵ accept edits on"
|
|
138
|
+
*/
|
|
139
|
+
setAutoAcceptSession(enabled) {
|
|
140
|
+
this.sessionAutoAccept = enabled;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Get current auto-accept status
|
|
145
|
+
*/
|
|
146
|
+
isAutoAcceptEnabled() {
|
|
147
|
+
return this.sessionAutoAccept;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Set session mode (overrides config)
|
|
152
|
+
* @param {string} mode - 'ask', 'auto', 'plan', 'deny'
|
|
153
|
+
*/
|
|
154
|
+
setSessionMode(mode) {
|
|
155
|
+
this.sessionMode = mode;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Get current mode (session override or config)
|
|
160
|
+
*/
|
|
161
|
+
getCurrentMode() {
|
|
162
|
+
// Ensure config and permission object exist
|
|
163
|
+
if (!this.config || !this.config.permission) {
|
|
164
|
+
this.config = { permission: this.getDefaultPermissions() };
|
|
165
|
+
}
|
|
166
|
+
return this.sessionMode || this.config.permission.mode || 'ask';
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Update config permission for an operation
|
|
171
|
+
* @param {string} operation
|
|
172
|
+
* @param {string} value - 'ask', 'allow', 'deny'
|
|
173
|
+
*/
|
|
174
|
+
setPermission(operation, value) {
|
|
175
|
+
// Ensure config and permission object exist
|
|
176
|
+
if (!this.config || !this.config.permission) {
|
|
177
|
+
this.config = { permission: this.getDefaultPermissions() };
|
|
178
|
+
}
|
|
179
|
+
this.config.permission[operation] = value;
|
|
180
|
+
this.saveConfig();
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Update global mode
|
|
185
|
+
* @param {string} mode - 'ask', 'auto', 'deny'
|
|
186
|
+
*/
|
|
187
|
+
setMode(mode) {
|
|
188
|
+
// Ensure config and permission object exist
|
|
189
|
+
if (!this.config || !this.config.permission) {
|
|
190
|
+
this.config = { permission: this.getDefaultPermissions() };
|
|
191
|
+
}
|
|
192
|
+
this.config.permission.mode = mode;
|
|
193
|
+
this.saveConfig();
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Reset to defaults
|
|
198
|
+
*/
|
|
199
|
+
resetToDefaults() {
|
|
200
|
+
this.config = {
|
|
201
|
+
permission: this.getDefaultPermissions()
|
|
202
|
+
};
|
|
203
|
+
this.sessionAutoAccept = false;
|
|
204
|
+
this.sessionMode = null;
|
|
205
|
+
this.saveConfig();
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Get human-readable mode description
|
|
210
|
+
*/
|
|
211
|
+
getModeDescription() {
|
|
212
|
+
const mode = this.getCurrentMode();
|
|
213
|
+
const descriptions = {
|
|
214
|
+
ask: '⏸ Manual approval mode - will ask for each change',
|
|
215
|
+
auto: '⏵⏵ Auto-accept mode - all changes approved automatically',
|
|
216
|
+
plan: '📖 Plan mode - read-only, no changes allowed',
|
|
217
|
+
deny: '⊘ Deny mode - all changes blocked'
|
|
218
|
+
};
|
|
219
|
+
return descriptions[mode] || descriptions.ask;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
module.exports = PermissionManager;
|
package/package.json
CHANGED