dotdotdot-cli 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +707 -0
- package/bin/dotdotdot.js +170 -0
- package/lib/colors.js +244 -0
- package/lib/config.js +224 -0
- package/lib/context.js +265 -0
- package/lib/executor.js +274 -0
- package/lib/index.js +16 -0
- package/lib/llm.js +471 -0
- package/lib/menu.js +100 -0
- package/lib/planner.js +169 -0
- package/lib/postinstall.js +20 -0
- package/lib/renderer.js +145 -0
- package/lib/safety.js +71 -0
- package/lib/session.js +165 -0
- package/lib/tokens.js +291 -0
- package/lib/ui.js +207 -0
- package/package.json +56 -0
package/lib/context.js
ADDED
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
4
|
+
// context.js — Rich environment context gathering for LLM prompts
|
|
5
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
6
|
+
|
|
7
|
+
const os = require('os');
|
|
8
|
+
const path = require('path');
|
|
9
|
+
const fs = require('fs');
|
|
10
|
+
const { execSync, exec } = require('child_process');
|
|
11
|
+
const { promisify } = require('util');
|
|
12
|
+
const { CACHE_DIR } = require('./config');
|
|
13
|
+
|
|
14
|
+
const execAsync = promisify(exec);
|
|
15
|
+
const CACHE_FILE = path.join(CACHE_DIR, 'context-cache.json');
|
|
16
|
+
const CACHE_TTL = 3600000; // 1 hour
|
|
17
|
+
|
|
18
|
+
// ─── Safe exec wrappers ─────────────────────────────────────────────────────
|
|
19
|
+
|
|
20
|
+
function safeExec(cmd, fallback = '') {
|
|
21
|
+
try {
|
|
22
|
+
const suppress = process.platform === 'win32' ? ' 2>nul' : ' 2>/dev/null';
|
|
23
|
+
return execSync(cmd + suppress, { timeout: 2000, encoding: 'utf8' }).trim();
|
|
24
|
+
} catch {
|
|
25
|
+
return fallback;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function safeExecAsync(cmd, fallback = '') {
|
|
30
|
+
try {
|
|
31
|
+
const suppress = process.platform === 'win32' ? ' 2>nul' : ' 2>/dev/null';
|
|
32
|
+
const { stdout } = await execAsync(cmd + suppress, { timeout: 2000 });
|
|
33
|
+
return stdout.trim();
|
|
34
|
+
} catch {
|
|
35
|
+
return fallback;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// ─── Cache management ───────────────────────────────────────────────────────
|
|
40
|
+
|
|
41
|
+
function loadCache() {
|
|
42
|
+
try {
|
|
43
|
+
const raw = fs.readFileSync(CACHE_FILE, 'utf8');
|
|
44
|
+
const data = JSON.parse(raw);
|
|
45
|
+
if (Date.now() - data._ts < CACHE_TTL) return data;
|
|
46
|
+
} catch { /* miss */ }
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function saveCache(data) {
|
|
51
|
+
try {
|
|
52
|
+
fs.writeFileSync(CACHE_FILE, JSON.stringify({ ...data, _ts: Date.now() }));
|
|
53
|
+
} catch { /* ignore */ }
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// ─── Shell detection ────────────────────────────────────────────────────────
|
|
57
|
+
|
|
58
|
+
function detectShell(cache) {
|
|
59
|
+
// NEVER use cached shell — user may switch between PowerShell, Git Bash, CMD.
|
|
60
|
+
// Shell detection is fast (<50ms), so always detect fresh.
|
|
61
|
+
|
|
62
|
+
const platform = process.platform;
|
|
63
|
+
let name, shellPath, version, isPowerShell = false, isCmd = false, isBash = false;
|
|
64
|
+
|
|
65
|
+
if (platform === 'win32') {
|
|
66
|
+
// Check for Git Bash / MINGW / MSYS FIRST.
|
|
67
|
+
// Key: $SHELL is set to a bash path (e.g. '/usr/bin/bash') only inside Git Bash.
|
|
68
|
+
// MSYSTEM alone is unreliable — it leaks into PowerShell on systems with Git installed.
|
|
69
|
+
const envShell = process.env.SHELL; // '/usr/bin/bash' in Git Bash, undefined in PS
|
|
70
|
+
const isMingwBash = envShell && (envShell.includes('bash') || envShell.includes('/sh'));
|
|
71
|
+
|
|
72
|
+
if (isMingwBash) {
|
|
73
|
+
name = 'bash'; shellPath = envShell; isBash = true;
|
|
74
|
+
version = safeExec('bash --version');
|
|
75
|
+
if (version.length > 80) version = version.split('\n')[0].slice(0, 80);
|
|
76
|
+
} else {
|
|
77
|
+
// Check for PowerShell 7+ first
|
|
78
|
+
const pwshVer = safeExec('pwsh -NoProfile -Command "$PSVersionTable.PSVersion.ToString()"');
|
|
79
|
+
if (pwshVer) {
|
|
80
|
+
name = 'pwsh'; shellPath = 'pwsh'; version = pwshVer; isPowerShell = true;
|
|
81
|
+
} else {
|
|
82
|
+
const psVer = safeExec('powershell -NoProfile -Command "$PSVersionTable.PSVersion.ToString()"');
|
|
83
|
+
if (psVer) {
|
|
84
|
+
name = 'powershell'; shellPath = 'powershell'; version = psVer; isPowerShell = true;
|
|
85
|
+
} else {
|
|
86
|
+
name = 'cmd'; shellPath = process.env.ComSpec || 'cmd.exe'; isCmd = true;
|
|
87
|
+
version = safeExec('ver');
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
} else {
|
|
92
|
+
shellPath = process.env.SHELL || '/bin/sh';
|
|
93
|
+
name = path.basename(shellPath);
|
|
94
|
+
version = safeExec(`${shellPath} --version`);
|
|
95
|
+
if (version.length > 80) version = version.slice(0, 80);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return { name, path: shellPath, version, isPowerShell, isCmd, isBash, platform };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ─── Directory listing ──────────────────────────────────────────────────────
|
|
102
|
+
|
|
103
|
+
function getDirectoryListing(dir) {
|
|
104
|
+
try {
|
|
105
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
106
|
+
const items = [];
|
|
107
|
+
let count = 0;
|
|
108
|
+
const MAX = 30; // reduced from 50 — saves tokens
|
|
109
|
+
|
|
110
|
+
for (const entry of entries) {
|
|
111
|
+
if (count >= MAX) {
|
|
112
|
+
items.push(`+${entries.length - MAX} more`);
|
|
113
|
+
break;
|
|
114
|
+
}
|
|
115
|
+
// Skip hidden files except useful ones
|
|
116
|
+
if (entry.name.startsWith('.') && !['.env', '.gitignore', '.dockerignore', '.nvmrc'].includes(entry.name)) continue;
|
|
117
|
+
if (entry.name === 'node_modules') { items.push('node_modules/'); count++; continue; }
|
|
118
|
+
|
|
119
|
+
// Just name + trailing slash for dirs. No file sizes — saves tokens.
|
|
120
|
+
items.push(entry.isDirectory() ? entry.name + '/' : entry.name);
|
|
121
|
+
count++;
|
|
122
|
+
}
|
|
123
|
+
return items.join(',');
|
|
124
|
+
} catch {
|
|
125
|
+
return '';
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ─── Git info ───────────────────────────────────────────────────────────────
|
|
130
|
+
|
|
131
|
+
async function getGitInfo() {
|
|
132
|
+
const branch = await safeExecAsync('git branch --show-current');
|
|
133
|
+
if (!branch) return null;
|
|
134
|
+
const status = await safeExecAsync('git status --short');
|
|
135
|
+
return {
|
|
136
|
+
branch,
|
|
137
|
+
isDirty: status.length > 0,
|
|
138
|
+
// Limit to 5 lines max — just enough for LLM context
|
|
139
|
+
status: status ? status.split('\n').slice(0, 5).join('\n') : '',
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// ─── Installed tools ────────────────────────────────────────────────────────
|
|
144
|
+
|
|
145
|
+
async function getInstalledTools(cache) {
|
|
146
|
+
if (cache?.tools) return cache.tools;
|
|
147
|
+
|
|
148
|
+
const checks = [
|
|
149
|
+
{ name: 'node', cmd: 'node --version' },
|
|
150
|
+
{ name: 'npm', cmd: 'npm --version' },
|
|
151
|
+
{ name: 'python', cmd: process.platform === 'win32' ? 'python --version' : 'python3 --version' },
|
|
152
|
+
{ name: 'git', cmd: 'git --version' },
|
|
153
|
+
{ name: 'docker', cmd: 'docker --version' },
|
|
154
|
+
{ name: 'go', cmd: 'go version' },
|
|
155
|
+
{ name: 'cargo', cmd: 'cargo --version' },
|
|
156
|
+
{ name: 'pip', cmd: process.platform === 'win32' ? 'pip --version' : 'pip3 --version' },
|
|
157
|
+
];
|
|
158
|
+
|
|
159
|
+
const results = await Promise.all(
|
|
160
|
+
checks.map(async ({ name, cmd }) => {
|
|
161
|
+
const ver = await safeExecAsync(cmd);
|
|
162
|
+
// Just store the tool name — version detail wastes tokens, LLM just needs to know it's available
|
|
163
|
+
return ver ? { name } : null;
|
|
164
|
+
})
|
|
165
|
+
);
|
|
166
|
+
|
|
167
|
+
const tools = results.filter(Boolean);
|
|
168
|
+
|
|
169
|
+
// Detect package manager
|
|
170
|
+
const cwd = process.cwd();
|
|
171
|
+
if (fs.existsSync(path.join(cwd, 'pnpm-lock.yaml'))) tools.push({ name: 'pnpm', version: await safeExecAsync('pnpm --version') || 'installed' });
|
|
172
|
+
if (fs.existsSync(path.join(cwd, 'yarn.lock'))) tools.push({ name: 'yarn', version: await safeExecAsync('yarn --version') || 'installed' });
|
|
173
|
+
if (fs.existsSync(path.join(cwd, 'bun.lockb'))) tools.push({ name: 'bun', version: await safeExecAsync('bun --version') || 'installed' });
|
|
174
|
+
|
|
175
|
+
return tools;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// ─── Project info ───────────────────────────────────────────────────────────
|
|
179
|
+
|
|
180
|
+
function getProjectInfo() {
|
|
181
|
+
const cwd = process.cwd();
|
|
182
|
+
const info = { type: null, name: null, scripts: null, deps: null, files: [] };
|
|
183
|
+
|
|
184
|
+
// package.json
|
|
185
|
+
try {
|
|
186
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(cwd, 'package.json'), 'utf8'));
|
|
187
|
+
info.type = 'node';
|
|
188
|
+
info.name = pkg.name;
|
|
189
|
+
info.scripts = pkg.scripts ? Object.keys(pkg.scripts).slice(0, 10) : [];
|
|
190
|
+
const deps = Object.keys(pkg.dependencies || {}).slice(0, 10);
|
|
191
|
+
const devDeps = Object.keys(pkg.devDependencies || {}).slice(0, 5);
|
|
192
|
+
info.deps = { deps, devDeps };
|
|
193
|
+
} catch { /* not a node project */ }
|
|
194
|
+
|
|
195
|
+
// Project indicator files
|
|
196
|
+
const indicators = [
|
|
197
|
+
'Cargo.toml', 'pyproject.toml', 'setup.py', 'go.mod',
|
|
198
|
+
'Makefile', 'CMakeLists.txt', 'Dockerfile', 'docker-compose.yml',
|
|
199
|
+
'docker-compose.yaml', 'tsconfig.json', '.eslintrc.json',
|
|
200
|
+
'vite.config.ts', 'vite.config.js', 'next.config.js', 'next.config.mjs',
|
|
201
|
+
'webpack.config.js', 'tailwind.config.js', 'tailwind.config.ts',
|
|
202
|
+
'.env', '.env.local', 'Procfile', 'vercel.json', 'netlify.toml',
|
|
203
|
+
'wrangler.toml', 'fly.toml',
|
|
204
|
+
];
|
|
205
|
+
|
|
206
|
+
for (const f of indicators) {
|
|
207
|
+
if (fs.existsSync(path.join(cwd, f))) info.files.push(f);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
return info;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// ─── Environment ────────────────────────────────────────────────────────────
|
|
214
|
+
|
|
215
|
+
function getEnvironment() {
|
|
216
|
+
return {
|
|
217
|
+
virtualEnv: process.env.VIRTUAL_ENV || process.env.CONDA_DEFAULT_ENV || null,
|
|
218
|
+
isSSH: !!process.env.SSH_CLIENT || !!process.env.SSH_TTY,
|
|
219
|
+
isDocker: fs.existsSync('/.dockerenv'),
|
|
220
|
+
isWSL: process.platform === 'linux' && safeExec('uname -r').toLowerCase().includes('microsoft'),
|
|
221
|
+
user: os.userInfo().username,
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// ─── Main gather function ───────────────────────────────────────────────────
|
|
226
|
+
|
|
227
|
+
async function gatherContext() {
|
|
228
|
+
const cache = loadCache();
|
|
229
|
+
|
|
230
|
+
const shell = detectShell(cache);
|
|
231
|
+
const [gitInfo, tools] = await Promise.all([
|
|
232
|
+
getGitInfo(),
|
|
233
|
+
getInstalledTools(cache),
|
|
234
|
+
]);
|
|
235
|
+
|
|
236
|
+
const context = {
|
|
237
|
+
system: {
|
|
238
|
+
platform: process.platform,
|
|
239
|
+
osName: `${os.type()} ${os.release()}`,
|
|
240
|
+
arch: os.arch(),
|
|
241
|
+
},
|
|
242
|
+
shell,
|
|
243
|
+
cwd: process.cwd(),
|
|
244
|
+
dirListing: getDirectoryListing(process.cwd()),
|
|
245
|
+
gitInfo,
|
|
246
|
+
tools,
|
|
247
|
+
projectInfo: getProjectInfo(),
|
|
248
|
+
environment: getEnvironment(),
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
// Save to cache (tools only — shell must never be cached, user switches terminals)
|
|
252
|
+
if (!cache) {
|
|
253
|
+
saveCache({ tools });
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
return context;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
module.exports = {
|
|
260
|
+
gatherContext,
|
|
261
|
+
detectShell,
|
|
262
|
+
getDirectoryListing,
|
|
263
|
+
safeExec,
|
|
264
|
+
safeExecAsync,
|
|
265
|
+
};
|
package/lib/executor.js
ADDED
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
4
|
+
// executor.js — Command execution, clipboard, interactive menu
|
|
5
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
6
|
+
|
|
7
|
+
const { spawn, execSync } = require('child_process');
|
|
8
|
+
const { bold, dim, cyan, green, red, yellow, symbols } = require('./colors');
|
|
9
|
+
const { addEntry, getUserIntent } = require('./session');
|
|
10
|
+
const { commandBox, printError, printInfo, Spinner, subtle } = require('./renderer');
|
|
11
|
+
const { selectMenu } = require('./menu');
|
|
12
|
+
const { analyzeRisk } = require('./safety');
|
|
13
|
+
const { tokenLine, estimateCost } = require('./tokens');
|
|
14
|
+
|
|
15
|
+
// ─── Strip accidental shell wrappers from LLM output ────────────────────────
|
|
16
|
+
// LLMs sometimes wrap commands in "powershell -Command ..." when already in PS,
|
|
17
|
+
// or "bash -c ..." when already in bash. Only strip if it matches current shell.
|
|
18
|
+
function stripShellWrapper(cmd) {
|
|
19
|
+
const { shell } = detectBestShell();
|
|
20
|
+
const shellName = shell.toLowerCase().replace(/\.exe$/, '');
|
|
21
|
+
const baseName = require('path').basename(shellName);
|
|
22
|
+
|
|
23
|
+
let c = cmd;
|
|
24
|
+
|
|
25
|
+
// Only strip PowerShell wrappers if we're running IN PowerShell
|
|
26
|
+
if (baseName === 'pwsh' || baseName === 'powershell') {
|
|
27
|
+
c = c.replace(/^powershell(?:\.exe)?\s+(?:-NoProfile\s+)?-Command\s+["']?/i, '').replace(/["']?\s*$/, '');
|
|
28
|
+
c = c.replace(/^pwsh(?:\.exe)?\s+(?:-NoProfile\s+)?-Command\s+["']?/i, '').replace(/["']?\s*$/, '');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Only strip bash wrappers if we're running IN bash
|
|
32
|
+
if (baseName === 'bash' || baseName === 'sh' || baseName === 'zsh') {
|
|
33
|
+
c = c.replace(/^bash\s+-c\s+["']/i, '').replace(/["']\s*$/, '');
|
|
34
|
+
c = c.replace(/^sh\s+-c\s+["']/i, '').replace(/["']\s*$/, '');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Only strip cmd wrappers if we're running IN cmd
|
|
38
|
+
if (baseName === 'cmd') {
|
|
39
|
+
c = c.replace(/^cmd(?:\.exe)?\s+\/c\s+["']?/i, '').replace(/["']?\s*$/, '');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return c;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ─── Detect shell ───────────────────────────────────────────────────────────
|
|
46
|
+
|
|
47
|
+
function detectBestShell(config) {
|
|
48
|
+
if (config?.preferredShell) return { shell: config.preferredShell, flag: '-c' };
|
|
49
|
+
if (process.platform !== 'win32') return { shell: process.env.SHELL || '/bin/sh', flag: '-c' };
|
|
50
|
+
|
|
51
|
+
// Git Bash / MINGW / MSYS — $SHELL is set to bash path only inside Git Bash
|
|
52
|
+
const envShell = process.env.SHELL;
|
|
53
|
+
if (envShell && (envShell.includes('bash') || envShell.includes('/sh'))) {
|
|
54
|
+
return { shell: envShell, flag: '-c' };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
try { execSync('pwsh -NoProfile -Command "exit"', { timeout: 2000, stdio: 'ignore' }); return { shell: 'pwsh', flag: '-Command' }; } catch {}
|
|
58
|
+
try { execSync('powershell -NoProfile -Command "exit"', { timeout: 2000, stdio: 'ignore' }); return { shell: 'powershell', flag: '-Command' }; } catch {}
|
|
59
|
+
return { shell: process.env.ComSpec || 'cmd.exe', flag: '/c' };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ─── Run command ────────────────────────────────────────────────────────────
|
|
63
|
+
|
|
64
|
+
function runCommand(command, config, opts = {}) {
|
|
65
|
+
const { shell, flag } = detectBestShell(config);
|
|
66
|
+
const { silent = false, captureOnly = false, tokenUsage = null } = opts;
|
|
67
|
+
|
|
68
|
+
return new Promise((resolve) => {
|
|
69
|
+
if (!silent && !captureOnly) {
|
|
70
|
+
console.log();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// For PowerShell, use -EncodedCommand to avoid $() string terminator issues.
|
|
74
|
+
// Base64-encode the command as UTF-16LE (PowerShell's expected encoding).
|
|
75
|
+
let spawnArgs;
|
|
76
|
+
const shellBase = require('path').basename(shell).toLowerCase().replace(/\.exe$/, '');
|
|
77
|
+
if (shellBase === 'powershell' || shellBase === 'pwsh') {
|
|
78
|
+
const encoded = Buffer.from(command, 'utf16le').toString('base64');
|
|
79
|
+
spawnArgs = ['-NoProfile', '-EncodedCommand', encoded];
|
|
80
|
+
} else {
|
|
81
|
+
spawnArgs = [flag, command];
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const proc = spawn(shell, spawnArgs, {
|
|
85
|
+
stdio: captureOnly ? ['pipe', 'pipe', 'pipe'] : ['inherit', 'pipe', 'pipe'],
|
|
86
|
+
env: { ...process.env },
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
let output = '';
|
|
90
|
+
let stderrIsClixml = false;
|
|
91
|
+
proc.stdout?.on('data', (d) => { const t = d.toString(); output += t; if (!silent && !captureOnly) process.stdout.write(t); });
|
|
92
|
+
proc.stderr?.on('data', (d) => {
|
|
93
|
+
const t = d.toString();
|
|
94
|
+
// Filter PowerShell CLIXML noise (progress bars, module loading messages).
|
|
95
|
+
// Once we see the CLIXML header, all subsequent stderr is CLIXML until process ends.
|
|
96
|
+
if (t.includes('#< CLIXML')) stderrIsClixml = true;
|
|
97
|
+
if (stderrIsClixml) return;
|
|
98
|
+
output += t;
|
|
99
|
+
if (!silent && !captureOnly) process.stderr.write(t);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
proc.on('close', (code) => {
|
|
103
|
+
addEntry(command, output, code, getUserIntent());
|
|
104
|
+
if (!silent && !captureOnly) {
|
|
105
|
+
if (tokenUsage) {
|
|
106
|
+
const tl = tokenLine(tokenUsage);
|
|
107
|
+
const cost = estimateCost(tokenUsage, config.provider, config.model);
|
|
108
|
+
const costStr = cost ? dim(` ~$${cost}`) : '';
|
|
109
|
+
console.log(`\n ${dim(tl)}${costStr}`);
|
|
110
|
+
}
|
|
111
|
+
const mark = code === 0 ? green(symbols.check) : red(symbols.cross);
|
|
112
|
+
console.log(` ${mark} ${dim(`exit ${code}`)}`);
|
|
113
|
+
}
|
|
114
|
+
resolve({ code, output });
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
proc.on('error', (err) => {
|
|
118
|
+
addEntry(command, err.message, 1, getUserIntent());
|
|
119
|
+
if (!silent && !captureOnly) console.error(` ${red(symbols.cross)} ${err.message}`);
|
|
120
|
+
resolve({ code: 1, output: err.message });
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// ─── Clipboard ──────────────────────────────────────────────────────────────
|
|
126
|
+
|
|
127
|
+
function copyToClipboard(text) {
|
|
128
|
+
try {
|
|
129
|
+
const p = process.platform;
|
|
130
|
+
if (p === 'win32') execSync('clip', { input: text, timeout: 3000 });
|
|
131
|
+
else if (p === 'darwin') execSync('pbcopy', { input: text, timeout: 3000 });
|
|
132
|
+
else { try { execSync('xclip -selection clipboard', { input: text, timeout: 3000 }); } catch { execSync('xsel --clipboard --input', { input: text, timeout: 3000 }); } }
|
|
133
|
+
return true;
|
|
134
|
+
} catch { return false; }
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// ─── Interactive mode ───────────────────────────────────────────────────────
|
|
138
|
+
|
|
139
|
+
async function interactiveMode(result, config, context) {
|
|
140
|
+
const { command: rawCommand, explanation, warning, _tokenUsage } = result;
|
|
141
|
+
const command = stripShellWrapper(rawCommand);
|
|
142
|
+
const risk = analyzeRisk(command);
|
|
143
|
+
const warn = warning || (risk.level === 'high' ? risk.reasons[0] : null);
|
|
144
|
+
|
|
145
|
+
console.log();
|
|
146
|
+
console.log(commandBox(command, explanation, warn));
|
|
147
|
+
|
|
148
|
+
const blocked = risk.level === 'high' && !config?.allowDangerous;
|
|
149
|
+
const choice = await selectMenu([
|
|
150
|
+
{ label: 'Execute', key: 'e', disabled: blocked },
|
|
151
|
+
{ label: 'Copy', key: 'c' },
|
|
152
|
+
{ label: 'Insert', key: 'i' },
|
|
153
|
+
{ label: 'Cancel', key: 'q' },
|
|
154
|
+
]);
|
|
155
|
+
|
|
156
|
+
switch (choice) {
|
|
157
|
+
case 'e': {
|
|
158
|
+
const { code, output } = await runCommand(command, config, { tokenUsage: _tokenUsage });
|
|
159
|
+
if (code !== 0) {
|
|
160
|
+
// ─── Error recovery ──────────────────────────────────────────
|
|
161
|
+
const recovered = await handleFailedCommand(command, output, code, config, context);
|
|
162
|
+
if (recovered) return; // successfully handled, don't exit with error
|
|
163
|
+
}
|
|
164
|
+
cleanExit(code);
|
|
165
|
+
break;
|
|
166
|
+
}
|
|
167
|
+
case 'c': {
|
|
168
|
+
if (copyToClipboard(command)) console.log(` ${green(symbols.check)} ${dim('copied')}`);
|
|
169
|
+
else console.log(` ${dim(command)}`);
|
|
170
|
+
break;
|
|
171
|
+
}
|
|
172
|
+
case 'i': console.log(`\n${command}\n`); break;
|
|
173
|
+
default: console.log(` ${dim('cancelled')}`); break;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// ─── Clean exit helper ──────────────────────────────────────────────────────
|
|
178
|
+
|
|
179
|
+
function cleanExit(code) {
|
|
180
|
+
// Ensure raw mode is off and cursor is visible before exiting
|
|
181
|
+
try {
|
|
182
|
+
if (process.stdin.isTTY && process.stdin.isRaw) {
|
|
183
|
+
process.stdin.setRawMode(false);
|
|
184
|
+
}
|
|
185
|
+
process.stdin.pause();
|
|
186
|
+
process.stdout.write('\x1b[?25h'); // show cursor
|
|
187
|
+
} catch { /* ignore */ }
|
|
188
|
+
process.exit(code);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// ─── Error recovery after failed command ────────────────────────────────────
|
|
192
|
+
// Returns true if the error was handled (user chose fix/retry), false otherwise
|
|
193
|
+
|
|
194
|
+
async function handleFailedCommand(command, output, code, config, context) {
|
|
195
|
+
console.log();
|
|
196
|
+
const recovery = await selectMenu([
|
|
197
|
+
{ label: 'Fix it', key: 'f' },
|
|
198
|
+
{ label: 'Retry', key: 'r' },
|
|
199
|
+
{ label: 'Copy error', key: 'c' },
|
|
200
|
+
{ label: 'Exit', key: 'q' },
|
|
201
|
+
]);
|
|
202
|
+
|
|
203
|
+
if (recovery === 'f') {
|
|
204
|
+
// Ask the LLM for a fix based on the error output
|
|
205
|
+
const { queryLLM } = require('./llm');
|
|
206
|
+
|
|
207
|
+
// Add the failure to session so LLM has context
|
|
208
|
+
addEntry(command, output, code, 'auto-fix');
|
|
209
|
+
|
|
210
|
+
const errorSnippet = output.length > 500 ? output.slice(-500) : output;
|
|
211
|
+
const fixPrompt = `The previous command failed. Fix it.\nCommand: ${command}\nError (exit ${code}):\n${errorSnippet}`;
|
|
212
|
+
|
|
213
|
+
const spin = new Spinner('fixing...').start();
|
|
214
|
+
try {
|
|
215
|
+
const fixResult = await queryLLM(fixPrompt, context, config, 'quick');
|
|
216
|
+
spin.succeed('done');
|
|
217
|
+
|
|
218
|
+
if (fixResult?.command) {
|
|
219
|
+
// Recurse into interactive mode with the new suggestion
|
|
220
|
+
await interactiveMode(fixResult, config, context);
|
|
221
|
+
return true;
|
|
222
|
+
} else {
|
|
223
|
+
printError('Could not generate a fix. Try rephrasing your request.');
|
|
224
|
+
return false;
|
|
225
|
+
}
|
|
226
|
+
} catch (err) {
|
|
227
|
+
spin.fail(err.message);
|
|
228
|
+
return false;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
if (recovery === 'r') {
|
|
233
|
+
const { code: retryCode, output: retryOutput } = await runCommand(command, config);
|
|
234
|
+
if (retryCode !== 0) {
|
|
235
|
+
return await handleFailedCommand(command, retryOutput, retryCode, config, context);
|
|
236
|
+
}
|
|
237
|
+
cleanExit(retryCode);
|
|
238
|
+
return true;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
if (recovery === 'c') {
|
|
242
|
+
const errorText = `$ ${command}\n${output}`;
|
|
243
|
+
if (copyToClipboard(errorText)) {
|
|
244
|
+
console.log(` ${green(symbols.check)} ${dim('error copied')}`);
|
|
245
|
+
} else {
|
|
246
|
+
console.log(` ${dim(output.slice(-300))}`);
|
|
247
|
+
}
|
|
248
|
+
return false;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// 'q' or null — just exit
|
|
252
|
+
return false;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// ─── Auto-execute ───────────────────────────────────────────────────────────
|
|
256
|
+
|
|
257
|
+
async function executeMode(result, config) {
|
|
258
|
+
const { command: rawCommand, explanation, warning, _tokenUsage } = result;
|
|
259
|
+
const command = stripShellWrapper(rawCommand);
|
|
260
|
+
const risk = analyzeRisk(command);
|
|
261
|
+
|
|
262
|
+
console.log();
|
|
263
|
+
console.log(commandBox(command, explanation, warning));
|
|
264
|
+
|
|
265
|
+
if (risk.level === 'high' || warning) {
|
|
266
|
+
console.log(` ${red(symbols.warning)} ${bold('Blocked.')} Use interactive mode.`);
|
|
267
|
+
cleanExit(1);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const { code } = await runCommand(command, config, { tokenUsage: _tokenUsage });
|
|
271
|
+
cleanExit(code);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
module.exports = { runCommand, copyToClipboard, interactiveMode, executeMode, handleFailedCommand, cleanExit, detectBestShell, stripShellWrapper };
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
module.exports = {
|
|
4
|
+
...require('./config'),
|
|
5
|
+
...require('./context'),
|
|
6
|
+
...require('./session'),
|
|
7
|
+
...require('./llm'),
|
|
8
|
+
...require('./executor'),
|
|
9
|
+
...require('./planner'),
|
|
10
|
+
...require('./safety'),
|
|
11
|
+
...require('./menu'),
|
|
12
|
+
...require('./renderer'),
|
|
13
|
+
...require('./colors'),
|
|
14
|
+
...require('./ui'),
|
|
15
|
+
...require('./tokens'),
|
|
16
|
+
};
|