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/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
+ }