@kernel.chat/kbot 2.23.2 → 2.25.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/dist/pair.d.ts +81 -0
- package/dist/pair.d.ts.map +1 -0
- package/dist/pair.js +993 -0
- package/dist/pair.js.map +1 -0
- package/dist/plugin-sdk.d.ts +136 -0
- package/dist/plugin-sdk.d.ts.map +1 -0
- package/dist/plugin-sdk.js +946 -0
- package/dist/plugin-sdk.js.map +1 -0
- package/dist/record.d.ts +174 -0
- package/dist/record.d.ts.map +1 -0
- package/dist/record.js +1182 -0
- package/dist/record.js.map +1 -0
- package/dist/team.d.ts +106 -0
- package/dist/team.d.ts.map +1 -0
- package/dist/team.js +917 -0
- package/dist/team.js.map +1 -0
- package/dist/tools/database.d.ts +2 -0
- package/dist/tools/database.d.ts.map +1 -0
- package/dist/tools/database.js +751 -0
- package/dist/tools/database.js.map +1 -0
- package/dist/tools/deploy.d.ts +2 -0
- package/dist/tools/deploy.d.ts.map +1 -0
- package/dist/tools/deploy.js +824 -0
- package/dist/tools/deploy.js.map +1 -0
- package/dist/tools/index.d.ts.map +1 -1
- package/dist/tools/index.js +13 -1
- package/dist/tools/index.js.map +1 -1
- package/dist/tools/mcp-marketplace.d.ts +2 -0
- package/dist/tools/mcp-marketplace.d.ts.map +1 -0
- package/dist/tools/mcp-marketplace.js +759 -0
- package/dist/tools/mcp-marketplace.js.map +1 -0
- package/dist/tools/training.d.ts +2 -0
- package/dist/tools/training.d.ts.map +1 -0
- package/dist/tools/training.js +2313 -0
- package/dist/tools/training.js.map +1 -0
- package/package.json +35 -3
package/dist/pair.js
ADDED
|
@@ -0,0 +1,993 @@
|
|
|
1
|
+
// K:BOT Pair Programming Mode — Proactive file watcher + AI copilot
|
|
2
|
+
//
|
|
3
|
+
// Watches the current directory for file changes and provides real-time
|
|
4
|
+
// suggestions: type errors, lint issues, missing tests, security flags,
|
|
5
|
+
// style warnings, and AI-powered refactoring offers.
|
|
6
|
+
//
|
|
7
|
+
// Usage:
|
|
8
|
+
// kbot pair # Watch cwd
|
|
9
|
+
// kbot pair ./src # Watch specific path
|
|
10
|
+
// kbot pair --quiet # Errors only
|
|
11
|
+
// kbot pair --auto-fix # Apply safe fixes automatically
|
|
12
|
+
//
|
|
13
|
+
// Config: ~/.kbot/pair.json
|
|
14
|
+
import { watch, readFileSync, writeFileSync, existsSync, statSync, mkdirSync } from 'node:fs';
|
|
15
|
+
import { join, extname, basename, dirname, resolve } from 'node:path';
|
|
16
|
+
import { execSync, execFileSync } from 'node:child_process';
|
|
17
|
+
import { homedir } from 'node:os';
|
|
18
|
+
import chalk from 'chalk';
|
|
19
|
+
import ora from 'ora';
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
// Constants
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
const ACCENT = chalk.hex('#A78BFA');
|
|
24
|
+
const ACCENT_DIM = chalk.hex('#7C6CB0');
|
|
25
|
+
const GREEN = chalk.hex('#4ADE80');
|
|
26
|
+
const RED = chalk.hex('#F87171');
|
|
27
|
+
const YELLOW = chalk.hex('#FBBF24');
|
|
28
|
+
const CYAN = chalk.hex('#67E8F9');
|
|
29
|
+
const DIM = chalk.dim;
|
|
30
|
+
const PAIR_CONFIG_PATH = join(homedir(), '.kbot', 'pair.json');
|
|
31
|
+
const DEBOUNCE_MS = 500;
|
|
32
|
+
const IGNORED_DIRS = new Set([
|
|
33
|
+
'node_modules',
|
|
34
|
+
'.git',
|
|
35
|
+
'dist',
|
|
36
|
+
'build',
|
|
37
|
+
'coverage',
|
|
38
|
+
'.next',
|
|
39
|
+
'.nuxt',
|
|
40
|
+
'__pycache__',
|
|
41
|
+
'.turbo',
|
|
42
|
+
'.cache',
|
|
43
|
+
'.vite',
|
|
44
|
+
'.parcel-cache',
|
|
45
|
+
]);
|
|
46
|
+
const IGNORED_FILES = new Set([
|
|
47
|
+
'.DS_Store',
|
|
48
|
+
'Thumbs.db',
|
|
49
|
+
]);
|
|
50
|
+
const IGNORED_EXTENSIONS = new Set([
|
|
51
|
+
'.lock',
|
|
52
|
+
'.map',
|
|
53
|
+
'.log',
|
|
54
|
+
'.ico',
|
|
55
|
+
'.png',
|
|
56
|
+
'.jpg',
|
|
57
|
+
'.jpeg',
|
|
58
|
+
'.gif',
|
|
59
|
+
'.svg',
|
|
60
|
+
'.woff',
|
|
61
|
+
'.woff2',
|
|
62
|
+
'.ttf',
|
|
63
|
+
'.eot',
|
|
64
|
+
]);
|
|
65
|
+
const SOURCE_EXTENSIONS = new Set([
|
|
66
|
+
'.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs',
|
|
67
|
+
'.py', '.rs', '.go', '.rb', '.java', '.kt',
|
|
68
|
+
'.c', '.cpp', '.h', '.hpp', '.cs', '.swift',
|
|
69
|
+
'.vue', '.svelte', '.astro',
|
|
70
|
+
]);
|
|
71
|
+
const TS_EXTENSIONS = new Set(['.ts', '.tsx']);
|
|
72
|
+
const JS_TS_EXTENSIONS = new Set(['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs']);
|
|
73
|
+
const DEFAULT_CHECKS = {
|
|
74
|
+
typeErrors: true,
|
|
75
|
+
lint: true,
|
|
76
|
+
missingTests: true,
|
|
77
|
+
imports: true,
|
|
78
|
+
security: true,
|
|
79
|
+
style: true,
|
|
80
|
+
};
|
|
81
|
+
const DEFAULT_CONFIG = {
|
|
82
|
+
checks: DEFAULT_CHECKS,
|
|
83
|
+
ignorePatterns: [],
|
|
84
|
+
autoFix: false,
|
|
85
|
+
bell: false,
|
|
86
|
+
quiet: false,
|
|
87
|
+
};
|
|
88
|
+
// ---------------------------------------------------------------------------
|
|
89
|
+
// Security patterns — things we always flag
|
|
90
|
+
// ---------------------------------------------------------------------------
|
|
91
|
+
const SECURITY_PATTERNS = [
|
|
92
|
+
{ pattern: /\beval\s*\(/, message: 'eval() usage detected — potential code injection', severity: 'error' },
|
|
93
|
+
{ pattern: /\bnew\s+Function\s*\(/, message: 'Function() constructor — equivalent to eval()', severity: 'error' },
|
|
94
|
+
{ pattern: /dangerouslySetInnerHTML/, message: 'dangerouslySetInnerHTML — XSS risk if input is unsanitized', severity: 'warning' },
|
|
95
|
+
{ pattern: /['"](?:sk-|AKIA|ghp_|gho_|github_pat_|xox[bps]-|Bearer\s+ey)[A-Za-z0-9_-]{10,}['"]/, message: 'Possible hardcoded secret/API key', severity: 'error' },
|
|
96
|
+
{ pattern: /password\s*[:=]\s*['"][^'"]{4,}['"]/, message: 'Possible hardcoded password', severity: 'error' },
|
|
97
|
+
{ pattern: /\bexec\s*\(\s*['"`]/, message: 'Shell exec with string literal — injection risk', severity: 'warning' },
|
|
98
|
+
{ pattern: /\b(SUPABASE_SERVICE_KEY|DATABASE_URL|PRIVATE_KEY)\s*[:=]\s*['"]/, message: 'Hardcoded sensitive environment variable', severity: 'error' },
|
|
99
|
+
];
|
|
100
|
+
// ---------------------------------------------------------------------------
|
|
101
|
+
// Module state
|
|
102
|
+
// ---------------------------------------------------------------------------
|
|
103
|
+
let activeWatcher = null;
|
|
104
|
+
let debounceTimer = null;
|
|
105
|
+
const pendingChanges = new Set();
|
|
106
|
+
let sessionStats = { filesAnalyzed: 0, suggestionsShown: 0, fixesApplied: 0, errorsFound: 0 };
|
|
107
|
+
// ---------------------------------------------------------------------------
|
|
108
|
+
// Config management
|
|
109
|
+
// ---------------------------------------------------------------------------
|
|
110
|
+
function loadPairConfig() {
|
|
111
|
+
try {
|
|
112
|
+
if (existsSync(PAIR_CONFIG_PATH)) {
|
|
113
|
+
const raw = readFileSync(PAIR_CONFIG_PATH, 'utf-8');
|
|
114
|
+
const parsed = JSON.parse(raw);
|
|
115
|
+
return {
|
|
116
|
+
checks: { ...DEFAULT_CHECKS, ...parsed.checks },
|
|
117
|
+
ignorePatterns: parsed.ignorePatterns || [],
|
|
118
|
+
autoFix: parsed.autoFix ?? false,
|
|
119
|
+
bell: parsed.bell ?? false,
|
|
120
|
+
quiet: parsed.quiet ?? false,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
// Corrupted config — use defaults
|
|
126
|
+
}
|
|
127
|
+
return { ...DEFAULT_CONFIG };
|
|
128
|
+
}
|
|
129
|
+
function savePairConfig(config) {
|
|
130
|
+
try {
|
|
131
|
+
const dir = dirname(PAIR_CONFIG_PATH);
|
|
132
|
+
if (!existsSync(dir)) {
|
|
133
|
+
mkdirSync(dir, { recursive: true });
|
|
134
|
+
}
|
|
135
|
+
writeFileSync(PAIR_CONFIG_PATH, JSON.stringify(config, null, 2), 'utf-8');
|
|
136
|
+
}
|
|
137
|
+
catch {
|
|
138
|
+
// Non-critical — config save failure is okay
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
// ---------------------------------------------------------------------------
|
|
142
|
+
// File filtering
|
|
143
|
+
// ---------------------------------------------------------------------------
|
|
144
|
+
function shouldIgnore(filePath, extraPatterns) {
|
|
145
|
+
const parts = filePath.split(/[/\\]/);
|
|
146
|
+
// Check ignored directories
|
|
147
|
+
for (const part of parts) {
|
|
148
|
+
if (IGNORED_DIRS.has(part))
|
|
149
|
+
return true;
|
|
150
|
+
}
|
|
151
|
+
// Check ignored files
|
|
152
|
+
const name = basename(filePath);
|
|
153
|
+
if (IGNORED_FILES.has(name))
|
|
154
|
+
return true;
|
|
155
|
+
// Check ignored extensions
|
|
156
|
+
const ext = extname(filePath);
|
|
157
|
+
if (IGNORED_EXTENSIONS.has(ext))
|
|
158
|
+
return true;
|
|
159
|
+
// Check .env files
|
|
160
|
+
if (name.startsWith('.env'))
|
|
161
|
+
return true;
|
|
162
|
+
// Check user-defined ignore patterns (simple glob matching)
|
|
163
|
+
for (const pattern of extraPatterns) {
|
|
164
|
+
if (matchSimpleGlob(filePath, pattern))
|
|
165
|
+
return true;
|
|
166
|
+
}
|
|
167
|
+
return false;
|
|
168
|
+
}
|
|
169
|
+
function matchSimpleGlob(filePath, pattern) {
|
|
170
|
+
// Support basic patterns: *.ext, dir/*, **/dir/*
|
|
171
|
+
const escaped = pattern
|
|
172
|
+
.replace(/[.+^${}()|[\]\\]/g, '\\$&')
|
|
173
|
+
.replace(/\*\*/g, '__DOUBLESTAR__')
|
|
174
|
+
.replace(/\*/g, '[^/]*')
|
|
175
|
+
.replace(/__DOUBLESTAR__/g, '.*');
|
|
176
|
+
try {
|
|
177
|
+
return new RegExp(`^${escaped}$`).test(filePath);
|
|
178
|
+
}
|
|
179
|
+
catch {
|
|
180
|
+
return false;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
function isSourceFile(filePath) {
|
|
184
|
+
return SOURCE_EXTENSIONS.has(extname(filePath).toLowerCase());
|
|
185
|
+
}
|
|
186
|
+
// ---------------------------------------------------------------------------
|
|
187
|
+
// Change classification
|
|
188
|
+
// ---------------------------------------------------------------------------
|
|
189
|
+
function classifyChange(fullPath, relativePath) {
|
|
190
|
+
let type = 'edit';
|
|
191
|
+
try {
|
|
192
|
+
statSync(fullPath);
|
|
193
|
+
// File exists — was it newly created or edited?
|
|
194
|
+
try {
|
|
195
|
+
const diffOutput = execSync(`git diff --name-only --diff-filter=A HEAD -- "${relativePath}"`, { encoding: 'utf-8', timeout: 3000, stdio: ['pipe', 'pipe', 'pipe'] }).trim();
|
|
196
|
+
if (diffOutput.includes(basename(relativePath))) {
|
|
197
|
+
type = 'create';
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
catch {
|
|
201
|
+
// Not in git or no previous commits — treat new files as creates
|
|
202
|
+
try {
|
|
203
|
+
execSync(`git status --porcelain -- "${relativePath}"`, { encoding: 'utf-8', timeout: 3000, stdio: ['pipe', 'pipe', 'pipe'] }).trim().startsWith('??') && (type = 'create');
|
|
204
|
+
}
|
|
205
|
+
catch {
|
|
206
|
+
// Not in a git repo — just call it an edit
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
catch {
|
|
211
|
+
// File doesn't exist — it was deleted
|
|
212
|
+
type = 'delete';
|
|
213
|
+
}
|
|
214
|
+
return { file: relativePath, fullPath, type };
|
|
215
|
+
}
|
|
216
|
+
function getGitDiff(relativePath) {
|
|
217
|
+
try {
|
|
218
|
+
// Try staged + unstaged diff
|
|
219
|
+
const diff = execSync(`git diff HEAD -- "${relativePath}" 2>/dev/null || git diff -- "${relativePath}" 2>/dev/null`, { encoding: 'utf-8', timeout: 5000, shell: '/bin/sh', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
|
|
220
|
+
return diff;
|
|
221
|
+
}
|
|
222
|
+
catch {
|
|
223
|
+
return '';
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
// ---------------------------------------------------------------------------
|
|
227
|
+
// Analysis checks
|
|
228
|
+
// ---------------------------------------------------------------------------
|
|
229
|
+
function checkTypeErrors(change) {
|
|
230
|
+
const ext = extname(change.file).toLowerCase();
|
|
231
|
+
if (!TS_EXTENSIONS.has(ext))
|
|
232
|
+
return [];
|
|
233
|
+
const suggestions = [];
|
|
234
|
+
try {
|
|
235
|
+
// Run tsc on just this file — check for type errors
|
|
236
|
+
execFileSync('npx', ['tsc', '--noEmit', '--pretty', 'false', change.fullPath], {
|
|
237
|
+
encoding: 'utf-8',
|
|
238
|
+
timeout: 15000,
|
|
239
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
catch (err) {
|
|
243
|
+
const output = err instanceof Error && 'stderr' in err
|
|
244
|
+
? String(err.stderr)
|
|
245
|
+
: err instanceof Error && 'stdout' in err
|
|
246
|
+
? String(err.stdout)
|
|
247
|
+
: '';
|
|
248
|
+
if (output) {
|
|
249
|
+
// Parse tsc error output: file(line,col): error TS1234: message
|
|
250
|
+
const errorPattern = /\((\d+),\d+\):\s*error\s+(TS\d+):\s*(.+)/g;
|
|
251
|
+
let match;
|
|
252
|
+
while ((match = errorPattern.exec(output)) !== null) {
|
|
253
|
+
suggestions.push({
|
|
254
|
+
type: 'error',
|
|
255
|
+
category: 'type',
|
|
256
|
+
file: change.file,
|
|
257
|
+
line: parseInt(match[1], 10),
|
|
258
|
+
message: `${match[2]}: ${match[3]}`,
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
// If no structured errors parsed but there was output, show raw
|
|
262
|
+
if (suggestions.length === 0 && output.includes('error TS')) {
|
|
263
|
+
const lines = output.split('\n').filter(l => l.includes('error TS')).slice(0, 5);
|
|
264
|
+
for (const line of lines) {
|
|
265
|
+
suggestions.push({
|
|
266
|
+
type: 'error',
|
|
267
|
+
category: 'type',
|
|
268
|
+
file: change.file,
|
|
269
|
+
message: line.trim(),
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
return suggestions;
|
|
276
|
+
}
|
|
277
|
+
function checkLint(change) {
|
|
278
|
+
const ext = extname(change.file).toLowerCase();
|
|
279
|
+
if (!JS_TS_EXTENSIONS.has(ext))
|
|
280
|
+
return [];
|
|
281
|
+
const suggestions = [];
|
|
282
|
+
// Check if eslint config exists in the project
|
|
283
|
+
const projectRoot = findProjectRoot(change.fullPath);
|
|
284
|
+
if (!projectRoot)
|
|
285
|
+
return [];
|
|
286
|
+
const eslintConfigs = [
|
|
287
|
+
'.eslintrc.js', '.eslintrc.cjs', '.eslintrc.json', '.eslintrc.yml', '.eslintrc.yaml',
|
|
288
|
+
'eslint.config.js', 'eslint.config.mjs', 'eslint.config.cjs', 'eslint.config.ts',
|
|
289
|
+
];
|
|
290
|
+
const hasEslint = eslintConfigs.some(c => existsSync(join(projectRoot, c)));
|
|
291
|
+
if (!hasEslint)
|
|
292
|
+
return [];
|
|
293
|
+
try {
|
|
294
|
+
execFileSync('npx', ['eslint', '--format', 'compact', change.fullPath], {
|
|
295
|
+
encoding: 'utf-8',
|
|
296
|
+
timeout: 15000,
|
|
297
|
+
cwd: projectRoot,
|
|
298
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
catch (err) {
|
|
302
|
+
const output = err instanceof Error && 'stdout' in err
|
|
303
|
+
? String(err.stdout)
|
|
304
|
+
: '';
|
|
305
|
+
if (output) {
|
|
306
|
+
// Parse eslint compact format: file: line:col - message (rule)
|
|
307
|
+
const errorPattern = /: line (\d+), col \d+, (\w+) - (.+)/g;
|
|
308
|
+
let match;
|
|
309
|
+
while ((match = errorPattern.exec(output)) !== null) {
|
|
310
|
+
const severity = match[2] === 'Error' ? 'error' : 'warning';
|
|
311
|
+
suggestions.push({
|
|
312
|
+
type: severity,
|
|
313
|
+
category: 'lint',
|
|
314
|
+
file: change.file,
|
|
315
|
+
line: parseInt(match[1], 10),
|
|
316
|
+
message: match[3],
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
return suggestions;
|
|
322
|
+
}
|
|
323
|
+
function checkMissingTests(change) {
|
|
324
|
+
const ext = extname(change.file).toLowerCase();
|
|
325
|
+
if (!SOURCE_EXTENSIONS.has(ext))
|
|
326
|
+
return [];
|
|
327
|
+
// Skip test files themselves
|
|
328
|
+
const name = basename(change.file);
|
|
329
|
+
if (name.includes('.test.') || name.includes('.spec.') || name.includes('__test__'))
|
|
330
|
+
return [];
|
|
331
|
+
// Skip non-logic files
|
|
332
|
+
if (name.startsWith('index.') || name.endsWith('.d.ts') || name.includes('.config.'))
|
|
333
|
+
return [];
|
|
334
|
+
const suggestions = [];
|
|
335
|
+
// Check for corresponding test file
|
|
336
|
+
const dir = dirname(change.fullPath);
|
|
337
|
+
const nameWithoutExt = basename(change.file, ext);
|
|
338
|
+
const testPatterns = [
|
|
339
|
+
join(dir, `${nameWithoutExt}.test${ext}`),
|
|
340
|
+
join(dir, `${nameWithoutExt}.spec${ext}`),
|
|
341
|
+
join(dir, '__tests__', `${nameWithoutExt}.test${ext}`),
|
|
342
|
+
join(dir, '__tests__', `${nameWithoutExt}.spec${ext}`),
|
|
343
|
+
// Also check for .test.ts when the source is .ts
|
|
344
|
+
join(dir, `${nameWithoutExt}.test.ts`),
|
|
345
|
+
join(dir, `${nameWithoutExt}.spec.ts`),
|
|
346
|
+
];
|
|
347
|
+
const hasTest = testPatterns.some(p => existsSync(p));
|
|
348
|
+
if (!hasTest) {
|
|
349
|
+
suggestions.push({
|
|
350
|
+
type: 'info',
|
|
351
|
+
category: 'test',
|
|
352
|
+
file: change.file,
|
|
353
|
+
message: `No test file found. Consider creating ${nameWithoutExt}.test${ext}`,
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
return suggestions;
|
|
357
|
+
}
|
|
358
|
+
function checkImports(change) {
|
|
359
|
+
const ext = extname(change.file).toLowerCase();
|
|
360
|
+
if (!JS_TS_EXTENSIONS.has(ext))
|
|
361
|
+
return [];
|
|
362
|
+
const suggestions = [];
|
|
363
|
+
let content;
|
|
364
|
+
try {
|
|
365
|
+
content = readFileSync(change.fullPath, 'utf-8');
|
|
366
|
+
}
|
|
367
|
+
catch {
|
|
368
|
+
return [];
|
|
369
|
+
}
|
|
370
|
+
const lines = content.split('\n');
|
|
371
|
+
// Check for unresolved relative imports
|
|
372
|
+
const importPattern = /(?:import|from)\s+['"](\.[^'"]+)['"]/g;
|
|
373
|
+
let match;
|
|
374
|
+
while ((match = importPattern.exec(content)) !== null) {
|
|
375
|
+
const importPath = match[1];
|
|
376
|
+
const dir = dirname(change.fullPath);
|
|
377
|
+
// Resolve the import — check with and without common extensions
|
|
378
|
+
const resolved = resolve(dir, importPath);
|
|
379
|
+
const resolveExtensions = ['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '/index.ts', '/index.tsx', '/index.js', '/index.jsx', ''];
|
|
380
|
+
const exists = resolveExtensions.some(ext => existsSync(resolved + ext));
|
|
381
|
+
if (!exists) {
|
|
382
|
+
// Find line number
|
|
383
|
+
const lineIdx = lines.findIndex(l => l.includes(importPath));
|
|
384
|
+
suggestions.push({
|
|
385
|
+
type: 'error',
|
|
386
|
+
category: 'import',
|
|
387
|
+
file: change.file,
|
|
388
|
+
line: lineIdx >= 0 ? lineIdx + 1 : undefined,
|
|
389
|
+
message: `Unresolved import: ${importPath}`,
|
|
390
|
+
});
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
return suggestions;
|
|
394
|
+
}
|
|
395
|
+
function checkSecurity(change) {
|
|
396
|
+
let content;
|
|
397
|
+
try {
|
|
398
|
+
content = readFileSync(change.fullPath, 'utf-8');
|
|
399
|
+
}
|
|
400
|
+
catch {
|
|
401
|
+
return [];
|
|
402
|
+
}
|
|
403
|
+
const suggestions = [];
|
|
404
|
+
const lines = content.split('\n');
|
|
405
|
+
for (let i = 0; i < lines.length; i++) {
|
|
406
|
+
const line = lines[i];
|
|
407
|
+
// Skip comments
|
|
408
|
+
const trimmed = line.trimStart();
|
|
409
|
+
if (trimmed.startsWith('//') || trimmed.startsWith('#') || trimmed.startsWith('*'))
|
|
410
|
+
continue;
|
|
411
|
+
for (const { pattern, message, severity } of SECURITY_PATTERNS) {
|
|
412
|
+
if (pattern.test(line)) {
|
|
413
|
+
suggestions.push({
|
|
414
|
+
type: severity,
|
|
415
|
+
category: 'security',
|
|
416
|
+
file: change.file,
|
|
417
|
+
line: i + 1,
|
|
418
|
+
message,
|
|
419
|
+
});
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
return suggestions;
|
|
424
|
+
}
|
|
425
|
+
function checkStyle(change) {
|
|
426
|
+
let content;
|
|
427
|
+
try {
|
|
428
|
+
content = readFileSync(change.fullPath, 'utf-8');
|
|
429
|
+
}
|
|
430
|
+
catch {
|
|
431
|
+
return [];
|
|
432
|
+
}
|
|
433
|
+
const suggestions = [];
|
|
434
|
+
const lines = content.split('\n');
|
|
435
|
+
const ext = extname(change.file).toLowerCase();
|
|
436
|
+
// --- console.log left in (non-test files, non-debug files) ---
|
|
437
|
+
if (JS_TS_EXTENSIONS.has(ext)) {
|
|
438
|
+
const name = basename(change.file);
|
|
439
|
+
const isDebugFile = name.includes('debug') || name.includes('logger') || name.includes('log');
|
|
440
|
+
const isTestFile = name.includes('.test.') || name.includes('.spec.');
|
|
441
|
+
if (!isDebugFile && !isTestFile) {
|
|
442
|
+
for (let i = 0; i < lines.length; i++) {
|
|
443
|
+
const trimmed = lines[i].trimStart();
|
|
444
|
+
if (trimmed.startsWith('//') || trimmed.startsWith('*'))
|
|
445
|
+
continue;
|
|
446
|
+
if (/\bconsole\.log\s*\(/.test(lines[i])) {
|
|
447
|
+
suggestions.push({
|
|
448
|
+
type: 'info',
|
|
449
|
+
category: 'style',
|
|
450
|
+
file: change.file,
|
|
451
|
+
line: i + 1,
|
|
452
|
+
message: 'console.log left in — remove before shipping?',
|
|
453
|
+
});
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
// --- TODO/FIXME/HACK comments ---
|
|
459
|
+
const todoPattern = /\b(TODO|FIXME|HACK|XXX)\b:?\s*(.+)/i;
|
|
460
|
+
for (let i = 0; i < lines.length; i++) {
|
|
461
|
+
const match = todoPattern.exec(lines[i]);
|
|
462
|
+
if (match) {
|
|
463
|
+
suggestions.push({
|
|
464
|
+
type: 'info',
|
|
465
|
+
category: 'style',
|
|
466
|
+
file: change.file,
|
|
467
|
+
line: i + 1,
|
|
468
|
+
message: `${match[1].toUpperCase()}: ${match[2].trim()}`,
|
|
469
|
+
});
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
// --- Large functions (>50 lines) ---
|
|
473
|
+
if (JS_TS_EXTENSIONS.has(ext)) {
|
|
474
|
+
detectLargeFunctions(lines, change.file, suggestions);
|
|
475
|
+
}
|
|
476
|
+
return suggestions;
|
|
477
|
+
}
|
|
478
|
+
function detectLargeFunctions(lines, file, suggestions) {
|
|
479
|
+
// Heuristic: find function/method declarations and track brace depth
|
|
480
|
+
const funcPattern = /(?:(?:export\s+)?(?:async\s+)?function\s+(\w+)|(?:const|let|var)\s+(\w+)\s*=\s*(?:async\s+)?(?:\([^)]*\)|[^=])\s*=>|(\w+)\s*\([^)]*\)\s*(?::\s*\w+\s*)?{)/;
|
|
481
|
+
let currentFunc = null;
|
|
482
|
+
for (let i = 0; i < lines.length; i++) {
|
|
483
|
+
const line = lines[i];
|
|
484
|
+
// Check for function start
|
|
485
|
+
if (!currentFunc) {
|
|
486
|
+
const match = funcPattern.exec(line);
|
|
487
|
+
if (match && line.includes('{')) {
|
|
488
|
+
const name = match[1] || match[2] || match[3] || 'anonymous';
|
|
489
|
+
currentFunc = { name, startLine: i + 1, braceDepth: 0 };
|
|
490
|
+
// Count braces on this line
|
|
491
|
+
for (const ch of line) {
|
|
492
|
+
if (ch === '{')
|
|
493
|
+
currentFunc.braceDepth++;
|
|
494
|
+
if (ch === '}')
|
|
495
|
+
currentFunc.braceDepth--;
|
|
496
|
+
}
|
|
497
|
+
continue;
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
// Track brace depth for current function
|
|
501
|
+
if (currentFunc) {
|
|
502
|
+
for (const ch of line) {
|
|
503
|
+
if (ch === '{')
|
|
504
|
+
currentFunc.braceDepth++;
|
|
505
|
+
if (ch === '}')
|
|
506
|
+
currentFunc.braceDepth--;
|
|
507
|
+
}
|
|
508
|
+
if (currentFunc.braceDepth <= 0) {
|
|
509
|
+
const length = i + 1 - currentFunc.startLine;
|
|
510
|
+
if (length > 50) {
|
|
511
|
+
suggestions.push({
|
|
512
|
+
type: 'info',
|
|
513
|
+
category: 'style',
|
|
514
|
+
file,
|
|
515
|
+
line: currentFunc.startLine,
|
|
516
|
+
message: `Function '${currentFunc.name}' is ${length} lines — consider breaking it up`,
|
|
517
|
+
});
|
|
518
|
+
}
|
|
519
|
+
currentFunc = null;
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
// ---------------------------------------------------------------------------
|
|
525
|
+
// Auto-fix (safe fixes only)
|
|
526
|
+
// ---------------------------------------------------------------------------
|
|
527
|
+
function tryAutoFix(change, suggestions) {
|
|
528
|
+
const applied = [];
|
|
529
|
+
let content;
|
|
530
|
+
try {
|
|
531
|
+
content = readFileSync(change.fullPath, 'utf-8');
|
|
532
|
+
}
|
|
533
|
+
catch {
|
|
534
|
+
return applied;
|
|
535
|
+
}
|
|
536
|
+
let modified = content;
|
|
537
|
+
let didModify = false;
|
|
538
|
+
// Fix 1: Remove unused imports (only if tsc flagged them)
|
|
539
|
+
const unusedImportSuggestions = suggestions.filter(s => s.category === 'type' && s.message.includes('is declared but'));
|
|
540
|
+
if (unusedImportSuggestions.length > 0) {
|
|
541
|
+
const lines = modified.split('\n');
|
|
542
|
+
for (const suggestion of unusedImportSuggestions) {
|
|
543
|
+
if (suggestion.line && suggestion.line <= lines.length) {
|
|
544
|
+
const line = lines[suggestion.line - 1];
|
|
545
|
+
// Only remove if it's a simple single-name import line
|
|
546
|
+
if (/^\s*import\s+\w+\s+from\s+['"]/.test(line) || /^\s*import\s+type\s+\w+\s+from\s+['"]/.test(line)) {
|
|
547
|
+
lines[suggestion.line - 1] = '';
|
|
548
|
+
didModify = true;
|
|
549
|
+
applied.push({
|
|
550
|
+
type: 'fix',
|
|
551
|
+
category: 'import',
|
|
552
|
+
file: change.file,
|
|
553
|
+
line: suggestion.line,
|
|
554
|
+
message: `Removed unused import`,
|
|
555
|
+
fix: `Deleted: ${line.trim()}`,
|
|
556
|
+
});
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
if (didModify) {
|
|
561
|
+
modified = lines.filter(l => l !== '' || !didModify).join('\n');
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
// Fix 2: Remove trailing whitespace
|
|
565
|
+
const trimmed = modified.replace(/[ \t]+$/gm, '');
|
|
566
|
+
if (trimmed !== modified) {
|
|
567
|
+
modified = trimmed;
|
|
568
|
+
didModify = true;
|
|
569
|
+
applied.push({
|
|
570
|
+
type: 'fix',
|
|
571
|
+
category: 'style',
|
|
572
|
+
file: change.file,
|
|
573
|
+
message: 'Removed trailing whitespace',
|
|
574
|
+
});
|
|
575
|
+
}
|
|
576
|
+
if (didModify) {
|
|
577
|
+
try {
|
|
578
|
+
writeFileSync(change.fullPath, modified, 'utf-8');
|
|
579
|
+
}
|
|
580
|
+
catch {
|
|
581
|
+
// Write failed — discard auto-fix
|
|
582
|
+
return [];
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
return applied;
|
|
586
|
+
}
|
|
587
|
+
// ---------------------------------------------------------------------------
|
|
588
|
+
// Utility
|
|
589
|
+
// ---------------------------------------------------------------------------
|
|
590
|
+
function findProjectRoot(filePath) {
|
|
591
|
+
let dir = dirname(filePath);
|
|
592
|
+
const root = resolve('/');
|
|
593
|
+
while (dir !== root) {
|
|
594
|
+
if (existsSync(join(dir, 'package.json')) || existsSync(join(dir, 'tsconfig.json'))) {
|
|
595
|
+
return dir;
|
|
596
|
+
}
|
|
597
|
+
const parent = dirname(dir);
|
|
598
|
+
if (parent === dir)
|
|
599
|
+
break;
|
|
600
|
+
dir = parent;
|
|
601
|
+
}
|
|
602
|
+
return null;
|
|
603
|
+
}
|
|
604
|
+
// ---------------------------------------------------------------------------
|
|
605
|
+
// Output formatting
|
|
606
|
+
// ---------------------------------------------------------------------------
|
|
607
|
+
const CATEGORY_LABELS = {
|
|
608
|
+
type: 'TYPE',
|
|
609
|
+
lint: 'LINT',
|
|
610
|
+
test: 'TEST',
|
|
611
|
+
import: 'IMPORT',
|
|
612
|
+
security: 'SECURITY',
|
|
613
|
+
style: 'STYLE',
|
|
614
|
+
ai: 'AI',
|
|
615
|
+
};
|
|
616
|
+
const CATEGORY_COLORS = {
|
|
617
|
+
type: RED,
|
|
618
|
+
lint: YELLOW,
|
|
619
|
+
test: CYAN,
|
|
620
|
+
import: RED,
|
|
621
|
+
security: RED,
|
|
622
|
+
style: DIM,
|
|
623
|
+
ai: ACCENT,
|
|
624
|
+
};
|
|
625
|
+
function formatSuggestion(s) {
|
|
626
|
+
const label = CATEGORY_LABELS[s.category] || s.category.toUpperCase();
|
|
627
|
+
const colorFn = CATEGORY_COLORS[s.category] || DIM;
|
|
628
|
+
const icon = s.type === 'error' ? RED('!') :
|
|
629
|
+
s.type === 'warning' ? YELLOW('!') :
|
|
630
|
+
s.type === 'fix' ? GREEN('+') :
|
|
631
|
+
DIM('-');
|
|
632
|
+
const lineRef = s.line ? DIM(`:${s.line}`) : '';
|
|
633
|
+
const tag = colorFn(`[${label}]`);
|
|
634
|
+
return ` ${icon} ${tag} ${s.message}${lineRef}`;
|
|
635
|
+
}
|
|
636
|
+
function formatChangeHeader(change) {
|
|
637
|
+
const time = new Date().toLocaleTimeString('en-US', { hour12: false });
|
|
638
|
+
const typeColors = {
|
|
639
|
+
create: GREEN,
|
|
640
|
+
edit: ACCENT,
|
|
641
|
+
delete: RED,
|
|
642
|
+
rename: YELLOW,
|
|
643
|
+
};
|
|
644
|
+
const colorFn = typeColors[change.type] || DIM;
|
|
645
|
+
return ` ${DIM(time)} ${ACCENT_DIM('pair:')} ${chalk.bold(change.file)} ${colorFn(change.type)}`;
|
|
646
|
+
}
|
|
647
|
+
function printSummaryLine(suggestions) {
|
|
648
|
+
const errors = suggestions.filter(s => s.type === 'error').length;
|
|
649
|
+
const warnings = suggestions.filter(s => s.type === 'warning').length;
|
|
650
|
+
const infos = suggestions.filter(s => s.type === 'info').length;
|
|
651
|
+
const fixes = suggestions.filter(s => s.type === 'fix').length;
|
|
652
|
+
const parts = [];
|
|
653
|
+
if (errors > 0)
|
|
654
|
+
parts.push(RED(`${errors} error${errors > 1 ? 's' : ''}`));
|
|
655
|
+
if (warnings > 0)
|
|
656
|
+
parts.push(YELLOW(`${warnings} warning${warnings > 1 ? 's' : ''}`));
|
|
657
|
+
if (infos > 0)
|
|
658
|
+
parts.push(DIM(`${infos} suggestion${infos > 1 ? 's' : ''}`));
|
|
659
|
+
if (fixes > 0)
|
|
660
|
+
parts.push(GREEN(`${fixes} auto-fixed`));
|
|
661
|
+
if (parts.length > 0) {
|
|
662
|
+
process.stderr.write(` ${parts.join(DIM(' | '))}\n`);
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
// ---------------------------------------------------------------------------
|
|
666
|
+
// Analysis pipeline
|
|
667
|
+
// ---------------------------------------------------------------------------
|
|
668
|
+
async function analyzeChanges(changes, config, options) {
|
|
669
|
+
const spinner = ora({
|
|
670
|
+
text: DIM(`Analyzing ${changes.length} file${changes.length > 1 ? 's' : ''}...`),
|
|
671
|
+
color: 'magenta',
|
|
672
|
+
spinner: 'dots',
|
|
673
|
+
stream: process.stderr,
|
|
674
|
+
});
|
|
675
|
+
spinner.start();
|
|
676
|
+
let totalSuggestions = [];
|
|
677
|
+
for (const change of changes) {
|
|
678
|
+
// Skip deleted files — nothing to analyze
|
|
679
|
+
if (change.type === 'delete') {
|
|
680
|
+
spinner.stop();
|
|
681
|
+
process.stderr.write(formatChangeHeader(change) + '\n');
|
|
682
|
+
continue;
|
|
683
|
+
}
|
|
684
|
+
// Skip non-source files for most checks
|
|
685
|
+
const isSource = isSourceFile(change.file);
|
|
686
|
+
const suggestions = [];
|
|
687
|
+
// Run enabled checks
|
|
688
|
+
if (isSource && config.checks.typeErrors) {
|
|
689
|
+
suggestions.push(...checkTypeErrors(change));
|
|
690
|
+
}
|
|
691
|
+
if (isSource && config.checks.lint) {
|
|
692
|
+
suggestions.push(...checkLint(change));
|
|
693
|
+
}
|
|
694
|
+
if (isSource && config.checks.missingTests) {
|
|
695
|
+
suggestions.push(...checkMissingTests(change));
|
|
696
|
+
}
|
|
697
|
+
if (isSource && config.checks.imports) {
|
|
698
|
+
suggestions.push(...checkImports(change));
|
|
699
|
+
}
|
|
700
|
+
if (config.checks.security) {
|
|
701
|
+
suggestions.push(...checkSecurity(change));
|
|
702
|
+
}
|
|
703
|
+
if (isSource && config.checks.style) {
|
|
704
|
+
suggestions.push(...checkStyle(change));
|
|
705
|
+
}
|
|
706
|
+
// Apply auto-fixes if enabled
|
|
707
|
+
let autoFixed = [];
|
|
708
|
+
if (config.autoFix || options.autoFix) {
|
|
709
|
+
autoFixed = tryAutoFix(change, suggestions);
|
|
710
|
+
suggestions.push(...autoFixed);
|
|
711
|
+
sessionStats.fixesApplied += autoFixed.length;
|
|
712
|
+
}
|
|
713
|
+
spinner.stop();
|
|
714
|
+
// In quiet mode, only show errors
|
|
715
|
+
const filtered = config.quiet || options.quiet
|
|
716
|
+
? suggestions.filter(s => s.type === 'error' || s.type === 'fix')
|
|
717
|
+
: suggestions;
|
|
718
|
+
// Display results
|
|
719
|
+
if (filtered.length > 0 || !config.quiet) {
|
|
720
|
+
process.stderr.write(formatChangeHeader(change) + '\n');
|
|
721
|
+
if (filtered.length > 0) {
|
|
722
|
+
for (const s of filtered) {
|
|
723
|
+
process.stderr.write(formatSuggestion(s) + '\n');
|
|
724
|
+
}
|
|
725
|
+
printSummaryLine(filtered);
|
|
726
|
+
// Terminal bell on errors
|
|
727
|
+
if ((config.bell || options.bell) && filtered.some(s => s.type === 'error')) {
|
|
728
|
+
process.stderr.write('\x07');
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
else {
|
|
732
|
+
process.stderr.write(` ${GREEN('+')} ${DIM('No issues')}\n`);
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
// Update stats
|
|
736
|
+
sessionStats.filesAnalyzed++;
|
|
737
|
+
sessionStats.suggestionsShown += filtered.length;
|
|
738
|
+
sessionStats.errorsFound += suggestions.filter(s => s.type === 'error').length;
|
|
739
|
+
totalSuggestions.push(...suggestions);
|
|
740
|
+
}
|
|
741
|
+
// If any suggestions need AI analysis, offer it
|
|
742
|
+
const hasComplexIssues = totalSuggestions.some(s => s.category === 'style' && s.message.includes('lines') ||
|
|
743
|
+
s.type === 'error' && totalSuggestions.filter(x => x.type === 'error').length > 3);
|
|
744
|
+
if (hasComplexIssues && !config.quiet && !options.quiet) {
|
|
745
|
+
process.stderr.write(`\n ${ACCENT('?')} ${DIM('Complex issues detected. Type')} ${chalk.white('kbot pair --analyze')} ${DIM('to get AI suggestions.')}\n`);
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
// ---------------------------------------------------------------------------
|
|
749
|
+
// Main entry: startPairMode
|
|
750
|
+
// ---------------------------------------------------------------------------
|
|
751
|
+
export async function startPairMode(options = {}) {
|
|
752
|
+
// Stop any existing pair session
|
|
753
|
+
if (activeWatcher) {
|
|
754
|
+
stopPairMode();
|
|
755
|
+
}
|
|
756
|
+
// Load config — CLI options override config file
|
|
757
|
+
const config = loadPairConfig();
|
|
758
|
+
if (options.quiet !== undefined)
|
|
759
|
+
config.quiet = options.quiet;
|
|
760
|
+
if (options.autoFix !== undefined)
|
|
761
|
+
config.autoFix = options.autoFix;
|
|
762
|
+
if (options.bell !== undefined)
|
|
763
|
+
config.bell = options.bell;
|
|
764
|
+
if (options.checks)
|
|
765
|
+
config.checks = { ...config.checks, ...options.checks };
|
|
766
|
+
if (options.ignorePatterns)
|
|
767
|
+
config.ignorePatterns = [...config.ignorePatterns, ...options.ignorePatterns];
|
|
768
|
+
const watchPath = resolve(options.path || process.cwd());
|
|
769
|
+
// Verify the path exists and is a directory
|
|
770
|
+
try {
|
|
771
|
+
const stat = statSync(watchPath);
|
|
772
|
+
if (!stat.isDirectory()) {
|
|
773
|
+
process.stderr.write(` ${RED('!')} ${watchPath} is not a directory\n`);
|
|
774
|
+
return;
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
catch {
|
|
778
|
+
process.stderr.write(` ${RED('!')} ${watchPath} does not exist\n`);
|
|
779
|
+
return;
|
|
780
|
+
}
|
|
781
|
+
// Reset session stats
|
|
782
|
+
sessionStats = { filesAnalyzed: 0, suggestionsShown: 0, fixesApplied: 0, errorsFound: 0 };
|
|
783
|
+
// Print banner
|
|
784
|
+
process.stderr.write('\n');
|
|
785
|
+
process.stderr.write(` ${ACCENT('K:BOT')} ${chalk.bold('pair')} ${DIM('— watching for changes')}\n`);
|
|
786
|
+
process.stderr.write(` ${DIM('Path:')} ${watchPath}\n`);
|
|
787
|
+
const enabledChecks = Object.entries(config.checks)
|
|
788
|
+
.filter(([, v]) => v)
|
|
789
|
+
.map(([k]) => k);
|
|
790
|
+
process.stderr.write(` ${DIM('Checks:')} ${enabledChecks.join(', ')}\n`);
|
|
791
|
+
if (config.autoFix) {
|
|
792
|
+
process.stderr.write(` ${DIM('Auto-fix:')} ${GREEN('enabled')}\n`);
|
|
793
|
+
}
|
|
794
|
+
if (config.quiet) {
|
|
795
|
+
process.stderr.write(` ${DIM('Mode:')} ${YELLOW('quiet')} (errors only)\n`);
|
|
796
|
+
}
|
|
797
|
+
process.stderr.write(` ${DIM('Press Ctrl+C to stop')}\n`);
|
|
798
|
+
process.stderr.write('\n');
|
|
799
|
+
// Start watching
|
|
800
|
+
try {
|
|
801
|
+
activeWatcher = watch(watchPath, { recursive: true }, (eventType, filename) => {
|
|
802
|
+
if (!filename)
|
|
803
|
+
return;
|
|
804
|
+
const fullPath = join(watchPath, filename);
|
|
805
|
+
// Ignore filtered paths
|
|
806
|
+
if (shouldIgnore(filename, config.ignorePatterns))
|
|
807
|
+
return;
|
|
808
|
+
// Only watch source files (and JSON configs, YAML, etc.)
|
|
809
|
+
const ext = extname(filename).toLowerCase();
|
|
810
|
+
if (!SOURCE_EXTENSIONS.has(ext) && ext !== '.json' && ext !== '.yaml' && ext !== '.yml' && ext !== '.toml')
|
|
811
|
+
return;
|
|
812
|
+
// Add to pending changes
|
|
813
|
+
pendingChanges.add(filename);
|
|
814
|
+
// Debounce — wait for rapid saves to finish
|
|
815
|
+
if (debounceTimer) {
|
|
816
|
+
clearTimeout(debounceTimer);
|
|
817
|
+
}
|
|
818
|
+
debounceTimer = setTimeout(async () => {
|
|
819
|
+
// Snapshot and clear pending changes
|
|
820
|
+
const batch = [...pendingChanges];
|
|
821
|
+
pendingChanges.clear();
|
|
822
|
+
debounceTimer = null;
|
|
823
|
+
// Classify each change
|
|
824
|
+
const changes = batch.map(file => {
|
|
825
|
+
const full = join(watchPath, file);
|
|
826
|
+
return classifyChange(full, file);
|
|
827
|
+
});
|
|
828
|
+
// Run analysis pipeline
|
|
829
|
+
await analyzeChanges(changes, config, options);
|
|
830
|
+
}, DEBOUNCE_MS);
|
|
831
|
+
});
|
|
832
|
+
activeWatcher.on('error', (err) => {
|
|
833
|
+
process.stderr.write(` ${RED('!')} Watch error: ${err.message}\n`);
|
|
834
|
+
});
|
|
835
|
+
// Keep the process alive until Ctrl+C
|
|
836
|
+
await new Promise((resolve) => {
|
|
837
|
+
const cleanup = () => {
|
|
838
|
+
stopPairMode();
|
|
839
|
+
resolve();
|
|
840
|
+
};
|
|
841
|
+
process.on('SIGINT', cleanup);
|
|
842
|
+
process.on('SIGTERM', cleanup);
|
|
843
|
+
});
|
|
844
|
+
}
|
|
845
|
+
catch (err) {
|
|
846
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
847
|
+
process.stderr.write(` ${RED('!')} Failed to start pair mode: ${message}\n`);
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
export function stopPairMode() {
|
|
851
|
+
if (activeWatcher) {
|
|
852
|
+
activeWatcher.close();
|
|
853
|
+
activeWatcher = null;
|
|
854
|
+
if (debounceTimer) {
|
|
855
|
+
clearTimeout(debounceTimer);
|
|
856
|
+
debounceTimer = null;
|
|
857
|
+
}
|
|
858
|
+
pendingChanges.clear();
|
|
859
|
+
// Print session summary
|
|
860
|
+
process.stderr.write('\n');
|
|
861
|
+
process.stderr.write(` ${ACCENT_DIM('pair:')} ${DIM('Session ended')}\n`);
|
|
862
|
+
process.stderr.write(` ${DIM('Files analyzed:')} ${sessionStats.filesAnalyzed}\n`);
|
|
863
|
+
process.stderr.write(` ${DIM('Suggestions:')} ${sessionStats.suggestionsShown}\n`);
|
|
864
|
+
process.stderr.write(` ${DIM('Errors found:')} ${sessionStats.errorsFound}\n`);
|
|
865
|
+
if (sessionStats.fixesApplied > 0) {
|
|
866
|
+
process.stderr.write(` ${DIM('Auto-fixed:')} ${GREEN(String(sessionStats.fixesApplied))}\n`);
|
|
867
|
+
}
|
|
868
|
+
process.stderr.write('\n');
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
/**
|
|
872
|
+
* Check if pair mode is currently active.
|
|
873
|
+
*/
|
|
874
|
+
export function isPairActive() {
|
|
875
|
+
return activeWatcher !== null;
|
|
876
|
+
}
|
|
877
|
+
/**
|
|
878
|
+
* Get current session stats.
|
|
879
|
+
*/
|
|
880
|
+
export function getPairStats() {
|
|
881
|
+
return { ...sessionStats };
|
|
882
|
+
}
|
|
883
|
+
// ---------------------------------------------------------------------------
|
|
884
|
+
// AI-powered analysis (calls agent loop)
|
|
885
|
+
// ---------------------------------------------------------------------------
|
|
886
|
+
/**
|
|
887
|
+
* Request AI analysis for a specific file. Uses the kbot agent loop to
|
|
888
|
+
* provide deeper suggestions: refactoring, architecture, patterns.
|
|
889
|
+
*
|
|
890
|
+
* This is called when the user explicitly requests it (not on every save).
|
|
891
|
+
*/
|
|
892
|
+
export async function analyzeWithAgent(filePath, agentOptions) {
|
|
893
|
+
const { runAgent } = await import('./agent.js');
|
|
894
|
+
let content;
|
|
895
|
+
try {
|
|
896
|
+
content = readFileSync(filePath, 'utf-8');
|
|
897
|
+
}
|
|
898
|
+
catch {
|
|
899
|
+
return 'Could not read file.';
|
|
900
|
+
}
|
|
901
|
+
const diff = getGitDiff(filePath);
|
|
902
|
+
const ext = extname(filePath).toLowerCase();
|
|
903
|
+
const prompt = [
|
|
904
|
+
`You are a pair programming assistant reviewing a file change.`,
|
|
905
|
+
`File: ${filePath} (${ext})`,
|
|
906
|
+
diff ? `\nRecent changes (git diff):\n\`\`\`\n${diff.slice(0, 3000)}\n\`\`\`` : '',
|
|
907
|
+
`\nFull file:\n\`\`\`${ext.slice(1)}\n${content.slice(0, 8000)}\n\`\`\``,
|
|
908
|
+
`\nProvide concise, actionable suggestions:`,
|
|
909
|
+
`1. Any bugs or logic errors in the changed code`,
|
|
910
|
+
`2. Refactoring opportunities (keep it practical, not academic)`,
|
|
911
|
+
`3. Missing edge cases or error handling`,
|
|
912
|
+
`4. Performance concerns`,
|
|
913
|
+
`\nBe direct. No fluff. If the code is fine, say so.`,
|
|
914
|
+
].join('\n');
|
|
915
|
+
try {
|
|
916
|
+
const response = await runAgent(prompt, {
|
|
917
|
+
agent: agentOptions?.agent || 'coder',
|
|
918
|
+
model: agentOptions?.model,
|
|
919
|
+
tier: agentOptions?.tier,
|
|
920
|
+
skipPlanner: true,
|
|
921
|
+
});
|
|
922
|
+
return response.content;
|
|
923
|
+
}
|
|
924
|
+
catch (err) {
|
|
925
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
926
|
+
return `AI analysis failed: ${message}`;
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
// ---------------------------------------------------------------------------
|
|
930
|
+
// CLI registration
|
|
931
|
+
// ---------------------------------------------------------------------------
|
|
932
|
+
/**
|
|
933
|
+
* Register the `kbot pair` command with the CLI program.
|
|
934
|
+
*
|
|
935
|
+
* Usage from cli.ts:
|
|
936
|
+
* import { registerPairCommand } from './pair.js'
|
|
937
|
+
* registerPairCommand(program)
|
|
938
|
+
*/
|
|
939
|
+
export function registerPairCommand(program) {
|
|
940
|
+
program
|
|
941
|
+
.command('pair [path]')
|
|
942
|
+
.description('Pair programming mode — watch files and get real-time suggestions')
|
|
943
|
+
.option('-q, --quiet', 'Only show errors, suppress suggestions')
|
|
944
|
+
.option('--auto-fix', 'Automatically apply safe fixes (trailing whitespace, unused imports)')
|
|
945
|
+
.option('--bell', 'Sound terminal bell on errors')
|
|
946
|
+
.option('--no-types', 'Disable TypeScript type checking')
|
|
947
|
+
.option('--no-lint', 'Disable ESLint checks')
|
|
948
|
+
.option('--no-tests', 'Disable missing test detection')
|
|
949
|
+
.option('--no-security', 'Disable security scanning')
|
|
950
|
+
.option('--no-style', 'Disable style checks')
|
|
951
|
+
.option('--ignore <patterns>', 'Additional ignore patterns (comma-separated)')
|
|
952
|
+
.option('--config', 'Show current pair config')
|
|
953
|
+
.option('--reset-config', 'Reset pair config to defaults')
|
|
954
|
+
.action(async (pairPath, opts) => {
|
|
955
|
+
// --config: show current config and exit
|
|
956
|
+
if (opts?.config) {
|
|
957
|
+
const config = loadPairConfig();
|
|
958
|
+
process.stderr.write(`\n ${ACCENT('K:BOT')} ${chalk.bold('pair')} ${DIM('config')}\n`);
|
|
959
|
+
process.stderr.write(` ${DIM('Path:')} ${PAIR_CONFIG_PATH}\n\n`);
|
|
960
|
+
process.stderr.write(` ${JSON.stringify(config, null, 2).split('\n').join('\n ')}\n\n`);
|
|
961
|
+
return;
|
|
962
|
+
}
|
|
963
|
+
// --reset-config: reset to defaults
|
|
964
|
+
if (opts?.resetConfig) {
|
|
965
|
+
savePairConfig(DEFAULT_CONFIG);
|
|
966
|
+
process.stderr.write(` ${GREEN('+')} Pair config reset to defaults\n`);
|
|
967
|
+
return;
|
|
968
|
+
}
|
|
969
|
+
const checks = {};
|
|
970
|
+
if (opts?.types === false)
|
|
971
|
+
checks.typeErrors = false;
|
|
972
|
+
if (opts?.lint === false)
|
|
973
|
+
checks.lint = false;
|
|
974
|
+
if (opts?.tests === false)
|
|
975
|
+
checks.missingTests = false;
|
|
976
|
+
if (opts?.security === false)
|
|
977
|
+
checks.security = false;
|
|
978
|
+
if (opts?.style === false)
|
|
979
|
+
checks.style = false;
|
|
980
|
+
const ignorePatterns = opts?.ignore
|
|
981
|
+
? opts.ignore.split(',').map(p => p.trim())
|
|
982
|
+
: undefined;
|
|
983
|
+
await startPairMode({
|
|
984
|
+
path: pairPath || process.cwd(),
|
|
985
|
+
quiet: opts?.quiet,
|
|
986
|
+
autoFix: opts?.autoFix,
|
|
987
|
+
bell: opts?.bell,
|
|
988
|
+
checks: Object.keys(checks).length > 0 ? checks : undefined,
|
|
989
|
+
ignorePatterns,
|
|
990
|
+
});
|
|
991
|
+
});
|
|
992
|
+
}
|
|
993
|
+
//# sourceMappingURL=pair.js.map
|