aiseerr 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/AGENTS.md +14 -0
- package/CODE-REVIEW.md +333 -0
- package/PRD.md +397 -0
- package/README.md +80 -0
- package/ana-suggestions.md +105 -0
- package/dist/cli.js +37 -0
- package/package.json +37 -0
- package/src/cli.ts +118 -0
- package/src/commands/diff.ts +128 -0
- package/src/commands/env.ts +234 -0
- package/src/commands/init.ts +82 -0
- package/src/commands/read.ts +113 -0
- package/src/commands/scout.ts +93 -0
- package/src/commands/tree.ts +133 -0
- package/src/utils/output.ts +123 -0
- package/tests/cli.test.ts +172 -0
- package/tests/diff.test.ts +169 -0
- package/tests/env.test.ts +69 -0
- package/tests/init.test.ts +164 -0
- package/tests/output.test.ts +49 -0
- package/tests/read.test.ts +169 -0
- package/tests/scout.test.ts +248 -0
- package/tests/tree.test.ts +222 -0
- package/tsconfig.json +15 -0
- package/tsup.config.ts +11 -0
package/src/cli.ts
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Entry point for aiseerr.
|
|
5
|
+
* Parses raw `process.argv` and routes to command modules.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { outputError, outputSuccess } from './utils/output';
|
|
9
|
+
import { handleEnvCommand } from './commands/env';
|
|
10
|
+
import { handleTreeCommand } from './commands/tree';
|
|
11
|
+
import { handleReadCommand } from './commands/read';
|
|
12
|
+
import { handleDiffCommand } from './commands/diff';
|
|
13
|
+
import { handleScoutCommand } from './commands/scout';
|
|
14
|
+
import { handleInitCommand } from './commands/init';
|
|
15
|
+
|
|
16
|
+
// Global error handler to guarantee valid JSON on crash
|
|
17
|
+
process.on('uncaughtException', (err) => {
|
|
18
|
+
outputError(err, 1);
|
|
19
|
+
});
|
|
20
|
+
process.on('unhandledRejection', (err) => {
|
|
21
|
+
outputError(err, 1);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
async function main() {
|
|
25
|
+
const args = process.argv.slice(2);
|
|
26
|
+
|
|
27
|
+
if (args.length === 0 || args[0] === 'help' || args.includes('--help') || args.includes('-h')) {
|
|
28
|
+
outputSuccess({
|
|
29
|
+
_help: "aiseerr: Structured Context Gathering for AI Agents",
|
|
30
|
+
commands: {
|
|
31
|
+
"scout": {
|
|
32
|
+
usage: "aiseerr scout [--budget=N]",
|
|
33
|
+
desc: "One-shot comprehensive environment scan. Bundles env, tree, readmes, diffs."
|
|
34
|
+
},
|
|
35
|
+
"env": {
|
|
36
|
+
usage: "aiseerr env",
|
|
37
|
+
desc: "Detect package manager, node version, frameworks, test setups."
|
|
38
|
+
},
|
|
39
|
+
"tree": {
|
|
40
|
+
usage: "aiseerr tree [dir] [--depth=N] [--budget=N]",
|
|
41
|
+
desc: "Explore directory structure with smart json compaction."
|
|
42
|
+
},
|
|
43
|
+
"read": {
|
|
44
|
+
usage: "aiseerr read <file...> [--lines=N-M] [--keys=a,b] [--budget=N]",
|
|
45
|
+
desc: "Extract exact files, specific line ranges, or json keys."
|
|
46
|
+
},
|
|
47
|
+
"diff": {
|
|
48
|
+
usage: "aiseerr diff [--full] [--file=path]",
|
|
49
|
+
desc: "Get git status, changed lines count, or full patch hunks."
|
|
50
|
+
},
|
|
51
|
+
"init": {
|
|
52
|
+
usage: "aiseerr init [--format=cursorrules|claude|agents]",
|
|
53
|
+
desc: "Auto-inject aiseerr usage instructions into project agent rules."
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
global_flags: {
|
|
57
|
+
"--budget=N": "Truncates/summarizes output to fit within N approximate tokens.",
|
|
58
|
+
"--pretty": "Human readable formatting (not recommended for AI LLMs natively)."
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const command = args[0];
|
|
65
|
+
const originalParams = args.slice(1);
|
|
66
|
+
|
|
67
|
+
// Extract global flags
|
|
68
|
+
let budget: number | undefined;
|
|
69
|
+
const commandParams: string[] = [];
|
|
70
|
+
|
|
71
|
+
for (const p of originalParams) {
|
|
72
|
+
if (p.startsWith('--budget=')) {
|
|
73
|
+
budget = parseInt(p.split('=')[1], 10);
|
|
74
|
+
if (isNaN(budget)) {
|
|
75
|
+
outputError('Invalid --budget value. Must be a number.', 1);
|
|
76
|
+
}
|
|
77
|
+
} else if (p === '--pretty') {
|
|
78
|
+
// handled globally in output.ts by inspecting process.argv
|
|
79
|
+
} else {
|
|
80
|
+
commandParams.push(p);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
try {
|
|
85
|
+
let result: any;
|
|
86
|
+
switch (command) {
|
|
87
|
+
case 'scout':
|
|
88
|
+
result = handleScoutCommand(budget);
|
|
89
|
+
break;
|
|
90
|
+
case 'env':
|
|
91
|
+
result = handleEnvCommand();
|
|
92
|
+
break;
|
|
93
|
+
case 'tree':
|
|
94
|
+
result = handleTreeCommand(commandParams, budget);
|
|
95
|
+
break;
|
|
96
|
+
case 'read':
|
|
97
|
+
result = handleReadCommand(commandParams, budget);
|
|
98
|
+
break;
|
|
99
|
+
case 'diff':
|
|
100
|
+
result = handleDiffCommand(commandParams, budget);
|
|
101
|
+
break;
|
|
102
|
+
case 'init':
|
|
103
|
+
result = handleInitCommand(commandParams);
|
|
104
|
+
break;
|
|
105
|
+
default:
|
|
106
|
+
outputError(`Unknown command: ${command}`);
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Commands return plain objects, we funnel them through the budget system
|
|
111
|
+
outputSuccess(result, budget);
|
|
112
|
+
|
|
113
|
+
} catch (error) {
|
|
114
|
+
outputError(error);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
main();
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { execFileSync } from 'child_process';
|
|
2
|
+
import { existsSync } from 'fs';
|
|
3
|
+
import { join } from 'path';
|
|
4
|
+
|
|
5
|
+
const EXEC_TIMEOUT = 10000;
|
|
6
|
+
|
|
7
|
+
function execSafe(command: string, args: string[]): string | null {
|
|
8
|
+
try {
|
|
9
|
+
return execFileSync(command, args, { stdio: 'pipe', encoding: 'utf-8', timeout: EXEC_TIMEOUT }).trim();
|
|
10
|
+
} catch {
|
|
11
|
+
return null;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function handleDiffCommand(args: string[], budget?: number): any {
|
|
16
|
+
let fullDiff = false;
|
|
17
|
+
let targetFile: string | null = null;
|
|
18
|
+
|
|
19
|
+
for (const arg of args) {
|
|
20
|
+
if (arg === '--full') {
|
|
21
|
+
fullDiff = true;
|
|
22
|
+
} else if (arg.startsWith('--file=')) {
|
|
23
|
+
targetFile = arg.split('=')[1];
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const cwd = process.cwd();
|
|
28
|
+
if (!existsSync(join(cwd, '.git'))) {
|
|
29
|
+
throw new Error('Not a git repository');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const branch = execSafe('git', ['rev-parse', '--abbrev-ref', 'HEAD']) || 'unknown';
|
|
33
|
+
|
|
34
|
+
// Get ahead/behind counts (rough fetch is not done here to keep it local/fast)
|
|
35
|
+
let ahead = 0;
|
|
36
|
+
let behind = 0;
|
|
37
|
+
const trackingInfo = execSafe('git', ['rev-list', '--left-right', '--count', 'HEAD...@{u}']);
|
|
38
|
+
if (trackingInfo) {
|
|
39
|
+
const parts = trackingInfo.split('\t');
|
|
40
|
+
if (parts.length === 2) {
|
|
41
|
+
ahead = parseInt(parts[0], 10) || 0;
|
|
42
|
+
behind = parseInt(parts[1], 10) || 0;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Parse porcelain status
|
|
47
|
+
const statusOut = execSafe('git', ['status', '--porcelain']);
|
|
48
|
+
const staged: any[] = [];
|
|
49
|
+
const unstaged: any[] = [];
|
|
50
|
+
const untracked: string[] = [];
|
|
51
|
+
|
|
52
|
+
if (statusOut) {
|
|
53
|
+
const lines = statusOut.split('\n');
|
|
54
|
+
for (const line of lines) {
|
|
55
|
+
if (!line) continue;
|
|
56
|
+
const x = line[0];
|
|
57
|
+
const y = line[1];
|
|
58
|
+
const file = line.slice(3);
|
|
59
|
+
|
|
60
|
+
if (targetFile && file !== targetFile) continue;
|
|
61
|
+
|
|
62
|
+
if (x === '?' && y === '?') {
|
|
63
|
+
untracked.push(file);
|
|
64
|
+
} else {
|
|
65
|
+
if (x !== ' ' && x !== '?') {
|
|
66
|
+
staged.push({ file, status: x });
|
|
67
|
+
}
|
|
68
|
+
if (y !== ' ' && y !== '?') {
|
|
69
|
+
unstaged.push({ file, status: y });
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Augment with numstat
|
|
76
|
+
const numStatStaged = execSafe('git', ['diff', '--cached', '--numstat']);
|
|
77
|
+
if (numStatStaged) {
|
|
78
|
+
const lines = numStatStaged.split('\n');
|
|
79
|
+
for (const line of lines) {
|
|
80
|
+
if (!line) continue;
|
|
81
|
+
const [ins, del, file] = line.split('\t');
|
|
82
|
+
const entry = staged.find(s => s.file === file);
|
|
83
|
+
if (entry) {
|
|
84
|
+
entry.insertions = parseInt(ins, 10) || 0;
|
|
85
|
+
entry.deletions = parseInt(del, 10) || 0;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const numStatUnstaged = execSafe('git', ['diff', '--numstat']);
|
|
91
|
+
if (numStatUnstaged) {
|
|
92
|
+
const lines = numStatUnstaged.split('\n');
|
|
93
|
+
for (const line of lines) {
|
|
94
|
+
if (!line) continue;
|
|
95
|
+
const [ins, del, file] = line.split('\t');
|
|
96
|
+
const entry = unstaged.find(s => s.file === file);
|
|
97
|
+
if (entry) {
|
|
98
|
+
entry.insertions = parseInt(ins, 10) || 0;
|
|
99
|
+
entry.deletions = parseInt(del, 10) || 0;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const result: any = {
|
|
105
|
+
branch,
|
|
106
|
+
ahead,
|
|
107
|
+
behind,
|
|
108
|
+
staged,
|
|
109
|
+
unstaged,
|
|
110
|
+
untracked
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
// If --full is requested, pull the actual diff content
|
|
114
|
+
if (fullDiff) {
|
|
115
|
+
const diffArgs = targetFile ? ['diff', targetFile] : ['diff'];
|
|
116
|
+
const diffStagedArgs = targetFile ? ['diff', '--cached', targetFile] : ['diff', '--cached'];
|
|
117
|
+
|
|
118
|
+
const fullDiffOut = execSafe('git', diffArgs);
|
|
119
|
+
const fullDiffStagedOut = execSafe('git', diffStagedArgs);
|
|
120
|
+
|
|
121
|
+
// We attach the raw unified diff string rather than parsing hunks to save overhead
|
|
122
|
+
// LLMs are perfectly capable of reading raw unified diffs, making custom hunk parsing redundant for v1
|
|
123
|
+
if (fullDiffStagedOut) result.stagedDiff = fullDiffStagedOut;
|
|
124
|
+
if (fullDiffOut) result.unstagedDiff = fullDiffOut;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return result;
|
|
128
|
+
}
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'fs';
|
|
2
|
+
import { join } from 'path';
|
|
3
|
+
import { execFileSync } from 'child_process';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Executes a child process purely for simple string returns
|
|
7
|
+
* Used for fast probes like git status
|
|
8
|
+
*/
|
|
9
|
+
const EXEC_TIMEOUT = 10000;
|
|
10
|
+
|
|
11
|
+
function execSafe(command: string, args: string[]): string | null {
|
|
12
|
+
try {
|
|
13
|
+
return execFileSync(command, args, { stdio: 'pipe', encoding: 'utf-8', timeout: EXEC_TIMEOUT }).trim();
|
|
14
|
+
} catch {
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface ShellInfo {
|
|
20
|
+
name: string;
|
|
21
|
+
path: string | null;
|
|
22
|
+
chain: string;
|
|
23
|
+
setEnv: string;
|
|
24
|
+
pathSep: string;
|
|
25
|
+
pitfalls: string[];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function detectShell(): ShellInfo {
|
|
29
|
+
const platform = process.platform;
|
|
30
|
+
|
|
31
|
+
// Check common shell env vars (order matters)
|
|
32
|
+
const shell = process.env.SHELL // unix default
|
|
33
|
+
|| process.env.PSModulePath && 'powershell' // PowerShell sets this
|
|
34
|
+
|| process.env.ComSpec // cmd.exe fallback
|
|
35
|
+
|| '';
|
|
36
|
+
|
|
37
|
+
const shellLower = shell.toLowerCase();
|
|
38
|
+
|
|
39
|
+
// PowerShell detection (works on both Windows and Unix)
|
|
40
|
+
if (process.env.PSModulePath || shellLower.includes('pwsh') || shellLower.includes('powershell')) {
|
|
41
|
+
return {
|
|
42
|
+
name: shellLower.includes('pwsh') ? 'pwsh' : 'powershell',
|
|
43
|
+
path: shell === 'powershell' ? null : shell,
|
|
44
|
+
chain: ';',
|
|
45
|
+
setEnv: '$env:VAR="value"',
|
|
46
|
+
pathSep: '\\',
|
|
47
|
+
pitfalls: [
|
|
48
|
+
'`&&` does NOT work in PowerShell 5.x. Use `;` or `pwsh 7+`.',
|
|
49
|
+
'Use `$env:VAR` instead of `export VAR=` or `set VAR=`.',
|
|
50
|
+
'Backtick ` is the escape char, not backslash.',
|
|
51
|
+
'Single quotes are literal strings; double quotes allow interpolation.',
|
|
52
|
+
]
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (shellLower.includes('fish')) {
|
|
57
|
+
return {
|
|
58
|
+
name: 'fish',
|
|
59
|
+
path: shell,
|
|
60
|
+
chain: '; and',
|
|
61
|
+
setEnv: 'set -x VAR value',
|
|
62
|
+
pathSep: '/',
|
|
63
|
+
pitfalls: [
|
|
64
|
+
'`&&` and `||` are NOT supported. Use `; and` / `; or`.',
|
|
65
|
+
'Use `set -x VAR value` instead of `export VAR=value`.',
|
|
66
|
+
'Subshells `$(...)` are not supported. Use `(command)` instead.',
|
|
67
|
+
]
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (shellLower.includes('zsh')) {
|
|
72
|
+
return {
|
|
73
|
+
name: 'zsh',
|
|
74
|
+
path: shell,
|
|
75
|
+
chain: '&&',
|
|
76
|
+
setEnv: 'export VAR="value"',
|
|
77
|
+
pathSep: '/',
|
|
78
|
+
pitfalls: [
|
|
79
|
+
'Array indexing starts at 1, not 0 (unlike bash).',
|
|
80
|
+
]
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (shellLower.includes('bash') || shellLower.includes('sh')) {
|
|
85
|
+
return {
|
|
86
|
+
name: shellLower.includes('bash') ? 'bash' : 'sh',
|
|
87
|
+
path: shell,
|
|
88
|
+
chain: '&&',
|
|
89
|
+
setEnv: 'export VAR="value"',
|
|
90
|
+
pathSep: '/',
|
|
91
|
+
pitfalls: []
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Windows cmd.exe
|
|
96
|
+
if (platform === 'win32' && (shellLower.includes('cmd') || !process.env.SHELL)) {
|
|
97
|
+
return {
|
|
98
|
+
name: 'cmd',
|
|
99
|
+
path: shell || 'C:\\Windows\\System32\\cmd.exe',
|
|
100
|
+
chain: '&&',
|
|
101
|
+
setEnv: 'set VAR=value',
|
|
102
|
+
pathSep: '\\',
|
|
103
|
+
pitfalls: [
|
|
104
|
+
'Use `set VAR=value` instead of `export`.',
|
|
105
|
+
'No native support for `$(...)` subshells.',
|
|
106
|
+
'Use `%VAR%` to expand env vars, not `$VAR`.',
|
|
107
|
+
]
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Unknown fallback
|
|
112
|
+
return {
|
|
113
|
+
name: 'unknown',
|
|
114
|
+
path: shell || null,
|
|
115
|
+
chain: '&&',
|
|
116
|
+
setEnv: 'export VAR="value"',
|
|
117
|
+
pathSep: platform === 'win32' ? '\\' : '/',
|
|
118
|
+
pitfalls: ['Shell could not be detected. Assume POSIX-compatible.']
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function handleEnvCommand(): any {
|
|
123
|
+
const cwd = process.cwd();
|
|
124
|
+
const pkgPath = join(cwd, 'package.json');
|
|
125
|
+
|
|
126
|
+
if (!existsSync(pkgPath)) {
|
|
127
|
+
return {
|
|
128
|
+
os: { platform: process.platform, arch: process.arch },
|
|
129
|
+
shell: detectShell(),
|
|
130
|
+
packageManager: null,
|
|
131
|
+
node: { version: process.version, engines: undefined },
|
|
132
|
+
frameworks: [],
|
|
133
|
+
scripts: {},
|
|
134
|
+
_note: 'Not a Node.js project: package.json not found.'
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
let pkg: any;
|
|
139
|
+
try {
|
|
140
|
+
pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
|
|
141
|
+
} catch (e) {
|
|
142
|
+
throw new Error('Failed to parse package.json.');
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// 1. Package Manager Detection
|
|
146
|
+
let pmInfo = { name: 'npm', version: 'unknown' };
|
|
147
|
+
if (existsSync(join(cwd, 'pnpm-lock.yaml'))) {
|
|
148
|
+
pmInfo.name = 'pnpm';
|
|
149
|
+
const ver = execSafe('pnpm', ['--version']);
|
|
150
|
+
if (ver) pmInfo.version = ver;
|
|
151
|
+
} else if (existsSync(join(cwd, 'yarn.lock'))) {
|
|
152
|
+
pmInfo.name = 'yarn';
|
|
153
|
+
const ver = execSafe('yarn', ['--version']);
|
|
154
|
+
if (ver) pmInfo.version = ver;
|
|
155
|
+
} else if (existsSync(join(cwd, 'bun.lockb'))) {
|
|
156
|
+
pmInfo.name = 'bun';
|
|
157
|
+
const ver = execSafe('bun', ['--version']);
|
|
158
|
+
if (ver) pmInfo.version = ver;
|
|
159
|
+
} else {
|
|
160
|
+
// default to npm
|
|
161
|
+
const ver = execSafe('npm', ['--version']);
|
|
162
|
+
if (ver) pmInfo.version = ver;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// 2. Node Version
|
|
166
|
+
const nodeInfo = {
|
|
167
|
+
version: process.version,
|
|
168
|
+
engines: pkg.engines?.node
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
// 3. Frameworks
|
|
172
|
+
const deps = { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) };
|
|
173
|
+
const allDeps = Object.keys(deps);
|
|
174
|
+
const knownFrameworks = ['react', 'vue', 'next', 'nuxt', 'svelte', 'express', 'nestjs', 'tailwindcss', 'prisma', 'vite', 'webpack'];
|
|
175
|
+
const detectedFrameworks = knownFrameworks.filter(f => allDeps.includes(f));
|
|
176
|
+
|
|
177
|
+
// 4. Test Framework
|
|
178
|
+
let testFramework = null;
|
|
179
|
+
if (allDeps.includes('vitest')) {
|
|
180
|
+
testFramework = {
|
|
181
|
+
name: 'vitest',
|
|
182
|
+
runCommand: `npx vitest run --reporter=json`,
|
|
183
|
+
parseHint: `Look for 'testResults[].assertionResults[].failureMessages'`
|
|
184
|
+
};
|
|
185
|
+
} else if (allDeps.includes('jest')) {
|
|
186
|
+
testFramework = {
|
|
187
|
+
name: 'jest',
|
|
188
|
+
runCommand: `npx jest --json`,
|
|
189
|
+
parseHint: `Look for 'testResults[].assertionResults[].failureMessages'`
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// 5. Lint Framework
|
|
194
|
+
let lintFramework = null;
|
|
195
|
+
if (allDeps.includes('eslint')) {
|
|
196
|
+
lintFramework = {
|
|
197
|
+
name: 'eslint',
|
|
198
|
+
runCommand: `npx eslint . --format=json`,
|
|
199
|
+
parseHint: `Array of objects with 'filePath' and 'messages[]'`
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// 6. Git Status
|
|
204
|
+
let git = null;
|
|
205
|
+
if (existsSync(join(cwd, '.git'))) {
|
|
206
|
+
const branch = execSafe('git', ['rev-parse', '--abbrev-ref', 'HEAD']);
|
|
207
|
+
const status = execSafe('git', ['status', '--porcelain']);
|
|
208
|
+
const remote = execSafe('git', ['config', '--get', 'remote.origin.url']);
|
|
209
|
+
git = {
|
|
210
|
+
branch: branch || 'unknown',
|
|
211
|
+
hasUncommitted: !!status,
|
|
212
|
+
remoteUrl: remote || null
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const result: any = {
|
|
217
|
+
os: { platform: process.platform, arch: process.arch },
|
|
218
|
+
shell: detectShell(),
|
|
219
|
+
packageManager: pmInfo,
|
|
220
|
+
node: nodeInfo,
|
|
221
|
+
frameworks: detectedFrameworks,
|
|
222
|
+
scripts: pkg.scripts || {},
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
if (pkg.workspaces) {
|
|
226
|
+
result.workspaces = pkg.workspaces;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
if (testFramework) result.testFramework = testFramework;
|
|
230
|
+
if (lintFramework) result.lintFramework = lintFramework;
|
|
231
|
+
if (git) result.git = git;
|
|
232
|
+
|
|
233
|
+
return result;
|
|
234
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync } from 'fs';
|
|
2
|
+
import { join } from 'path';
|
|
3
|
+
|
|
4
|
+
export function handleInitCommand(args: string[]): any {
|
|
5
|
+
let format: string | null = null;
|
|
6
|
+
for (const arg of args) {
|
|
7
|
+
if (arg.startsWith('--format=')) {
|
|
8
|
+
format = arg.split('=')[1];
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const cwd = process.cwd();
|
|
13
|
+
|
|
14
|
+
const injectTemplate = `
|
|
15
|
+
<!-- aiseerr:start -->
|
|
16
|
+
## Project Context Protocol (powered by aiseerr)
|
|
17
|
+
|
|
18
|
+
Before modifying this project, gather context by running:
|
|
19
|
+
npx aiseerr scout --budget=2000
|
|
20
|
+
|
|
21
|
+
When debugging a failing test:
|
|
22
|
+
npx aiseerr read <file> --lines=<error-range>
|
|
23
|
+
|
|
24
|
+
Before committing changes:
|
|
25
|
+
npx aiseerr diff --full
|
|
26
|
+
<!-- aiseerr:end -->
|
|
27
|
+
`;
|
|
28
|
+
|
|
29
|
+
const filesToSearch = [
|
|
30
|
+
'AGENTS.md',
|
|
31
|
+
'CLAUDE.md',
|
|
32
|
+
'.cursorrules',
|
|
33
|
+
'.github/copilot-instructions.md'
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
let targetFile = 'AGENTS.md'; // Default fallback
|
|
37
|
+
|
|
38
|
+
if (format) {
|
|
39
|
+
if (format === 'claude') targetFile = 'CLAUDE.md';
|
|
40
|
+
else if (format === 'cursorrules') targetFile = '.cursorrules';
|
|
41
|
+
else if (format === 'agents') targetFile = 'AGENTS.md';
|
|
42
|
+
else throw new Error(`Unknown format: ${format}`);
|
|
43
|
+
} else {
|
|
44
|
+
// Smart Detection
|
|
45
|
+
for (const f of filesToSearch) {
|
|
46
|
+
if (existsSync(join(cwd, f))) {
|
|
47
|
+
targetFile = f;
|
|
48
|
+
break;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const targetPath = join(cwd, targetFile);
|
|
54
|
+
let action = 'created';
|
|
55
|
+
let linesAdded = injectTemplate.trim().split('\n').length;
|
|
56
|
+
|
|
57
|
+
if (existsSync(targetPath)) {
|
|
58
|
+
const existingContent = readFileSync(targetPath, 'utf-8');
|
|
59
|
+
if (existingContent.includes('<!-- aiseerr:start -->')) {
|
|
60
|
+
return {
|
|
61
|
+
action: 'skipped',
|
|
62
|
+
target: targetFile,
|
|
63
|
+
reason: 'Already injected'
|
|
64
|
+
};
|
|
65
|
+
} else {
|
|
66
|
+
writeFileSync(targetPath, existingContent + '\n' + injectTemplate.trim() + '\n', 'utf-8');
|
|
67
|
+
action = 'injected';
|
|
68
|
+
}
|
|
69
|
+
} else {
|
|
70
|
+
// In creation mode, maybe add a generic title
|
|
71
|
+
const content = `# AI Agent Rules\n\n${injectTemplate.trim()}\n`;
|
|
72
|
+
writeFileSync(targetPath, content, 'utf-8');
|
|
73
|
+
linesAdded = content.trim().split('\n').length;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return {
|
|
77
|
+
action,
|
|
78
|
+
target: targetFile,
|
|
79
|
+
linesAdded,
|
|
80
|
+
marker: 'aiseerr:start'
|
|
81
|
+
};
|
|
82
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { readFileSync, existsSync, statSync } from 'fs';
|
|
2
|
+
import { estimateTokens } from '../utils/output';
|
|
3
|
+
|
|
4
|
+
export function handleReadCommand(args: string[], budget?: number): any {
|
|
5
|
+
let linesRange: [number, number] | null = null;
|
|
6
|
+
let keysExtract: string[] | null = null;
|
|
7
|
+
const files: string[] = [];
|
|
8
|
+
|
|
9
|
+
for (const arg of args) {
|
|
10
|
+
if (arg.startsWith('--lines=')) {
|
|
11
|
+
const parts = arg.split('=')[1].split('-');
|
|
12
|
+
if (parts.length === 2) {
|
|
13
|
+
linesRange = [parseInt(parts[0], 10), parseInt(parts[1], 10)];
|
|
14
|
+
}
|
|
15
|
+
} else if (arg.startsWith('--keys=')) {
|
|
16
|
+
keysExtract = arg.split('=')[1].split(',').map(k => k.trim());
|
|
17
|
+
} else if (!arg.startsWith('--')) {
|
|
18
|
+
files.push(arg);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
if (files.length === 0) {
|
|
23
|
+
throw new Error('No files provided to read.');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const results = [];
|
|
27
|
+
let currentEstimatedTokens = 0;
|
|
28
|
+
|
|
29
|
+
for (const file of files) {
|
|
30
|
+
if (!existsSync(file)) {
|
|
31
|
+
results.push({ file, error: 'File not found' });
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
const stat = statSync(file);
|
|
35
|
+
if (stat.isDirectory()) {
|
|
36
|
+
results.push({ file, error: 'Target is a directory, not a file' });
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
try {
|
|
41
|
+
const rawContent = readFileSync(file, 'utf-8');
|
|
42
|
+
const linesCount = rawContent.split('\n').length;
|
|
43
|
+
let finalContent: any = rawContent;
|
|
44
|
+
let isTruncated = false;
|
|
45
|
+
let contextMeta: any = undefined;
|
|
46
|
+
|
|
47
|
+
// JSON Keys Extraction Mode
|
|
48
|
+
if (keysExtract && file.endsWith('.json')) {
|
|
49
|
+
try {
|
|
50
|
+
const parsed = JSON.parse(rawContent);
|
|
51
|
+
const extracted: Record<string, any> = {};
|
|
52
|
+
for (const k of keysExtract) {
|
|
53
|
+
if (k in parsed) extracted[k] = parsed[k];
|
|
54
|
+
}
|
|
55
|
+
finalContent = extracted;
|
|
56
|
+
} catch {
|
|
57
|
+
// Fallback to text if parsing fails
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
// Line Range Mode
|
|
61
|
+
else if (linesRange && typeof finalContent === 'string') {
|
|
62
|
+
const [start, end] = linesRange;
|
|
63
|
+
const lines = rawContent.split('\n');
|
|
64
|
+
// 1-indexed for users
|
|
65
|
+
const slice = lines.slice(Math.max(0, start - 1), end);
|
|
66
|
+
finalContent = slice.join('\n');
|
|
67
|
+
contextMeta = { range: [start, Math.min(end, lines.length)] };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Check purely text tokens against remaining budget
|
|
71
|
+
if (budget && typeof finalContent === 'string') {
|
|
72
|
+
const fileTokens = estimateTokens(finalContent);
|
|
73
|
+
if (currentEstimatedTokens + fileTokens > budget) {
|
|
74
|
+
// How much budget do we have left for this file?
|
|
75
|
+
const remainingTokens = Math.max(0, budget - currentEstimatedTokens);
|
|
76
|
+
// Roughly 4 chars per token
|
|
77
|
+
const charLimit = remainingTokens * 4;
|
|
78
|
+
|
|
79
|
+
if (charLimit < 50) {
|
|
80
|
+
// Not enough room even for a snippet
|
|
81
|
+
results.push({ file, lines: linesCount, error: 'Skipped due to token budget limits' });
|
|
82
|
+
break; // Stop processing further files
|
|
83
|
+
} else {
|
|
84
|
+
finalContent = finalContent.slice(0, charLimit) + '\n...[TRUNCATED BY BUDGET]';
|
|
85
|
+
isTruncated = true;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
currentEstimatedTokens += estimateTokens(finalContent);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const fileObj: any = {
|
|
92
|
+
file,
|
|
93
|
+
lines: linesCount,
|
|
94
|
+
content: finalContent
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
if (contextMeta) fileObj.context = contextMeta;
|
|
98
|
+
if (isTruncated) fileObj.truncated = true;
|
|
99
|
+
|
|
100
|
+
results.push(fileObj);
|
|
101
|
+
|
|
102
|
+
} catch (e: any) {
|
|
103
|
+
results.push({ file, error: e.message || 'Failed to read file' });
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// If only one file requested, return just that object to save tokens
|
|
108
|
+
if (files.length === 1 && results.length === 1) {
|
|
109
|
+
return results[0];
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return { files: results };
|
|
113
|
+
}
|