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
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'fs';
|
|
2
|
+
import { join } from 'path';
|
|
3
|
+
import { handleEnvCommand } from './env';
|
|
4
|
+
import { handleTreeCommand } from './tree';
|
|
5
|
+
import { handleDiffCommand } from './diff';
|
|
6
|
+
|
|
7
|
+
export function handleScoutCommand(budget?: number): any {
|
|
8
|
+
const result: any = {};
|
|
9
|
+
const cwd = process.cwd();
|
|
10
|
+
|
|
11
|
+
// 1. Env (Always)
|
|
12
|
+
try {
|
|
13
|
+
result.env = handleEnvCommand();
|
|
14
|
+
} catch (e: any) {
|
|
15
|
+
result.env = { error: e.message };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// 2. Tree (Always, depth=2)
|
|
19
|
+
try {
|
|
20
|
+
// If a global budget is set, allocate approximately half of it to the tree
|
|
21
|
+
// since tree and diff are typically the largest components.
|
|
22
|
+
const treeBudget = budget ? Math.floor(budget * 0.5) : undefined;
|
|
23
|
+
result.tree = handleTreeCommand(['--depth=2'], treeBudget);
|
|
24
|
+
} catch (e: any) {
|
|
25
|
+
result.tree = { error: e.message };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// 3. Readme
|
|
29
|
+
const readmePaths = ['README.md', 'Readme.md', 'readme.md'];
|
|
30
|
+
let readmeFound = false;
|
|
31
|
+
for (const p of readmePaths) {
|
|
32
|
+
const fullPath = join(cwd, p);
|
|
33
|
+
if (existsSync(fullPath)) {
|
|
34
|
+
try {
|
|
35
|
+
const content = readFileSync(fullPath, 'utf-8');
|
|
36
|
+
const lines = content.split('\n');
|
|
37
|
+
// Truncate to first 60 lines for a quick summary
|
|
38
|
+
if (lines.length > 60) {
|
|
39
|
+
result.readme = lines.slice(0, 60).join('\n') + '\n...[TRUNCATED TO 60 LINES]';
|
|
40
|
+
} else {
|
|
41
|
+
result.readme = content;
|
|
42
|
+
}
|
|
43
|
+
readmeFound = true;
|
|
44
|
+
break;
|
|
45
|
+
} catch {
|
|
46
|
+
// ignore
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
if (!readmeFound) result.readme = null;
|
|
51
|
+
|
|
52
|
+
// 4. Agent Rules
|
|
53
|
+
const rulePaths = ['.cursorrules', 'CLAUDE.md', 'AGENTS.md', '.github/copilot-instructions.md'];
|
|
54
|
+
let rulesFound = false;
|
|
55
|
+
for (const p of rulePaths) {
|
|
56
|
+
const fullPath = join(cwd, p);
|
|
57
|
+
if (existsSync(fullPath)) {
|
|
58
|
+
try {
|
|
59
|
+
result.agentRules = {
|
|
60
|
+
source: p,
|
|
61
|
+
// Limit rules to 500 lines to prevent runaway tokens, though usually they are small
|
|
62
|
+
content: readFileSync(fullPath, 'utf-8').split('\n').slice(0, 500).join('\n')
|
|
63
|
+
};
|
|
64
|
+
rulesFound = true;
|
|
65
|
+
break;
|
|
66
|
+
} catch {
|
|
67
|
+
// ignore
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
if (!rulesFound) result.agentRules = null;
|
|
72
|
+
|
|
73
|
+
// 5. Diff (Stat only, no full diff payload to save tokens in a generic scout)
|
|
74
|
+
try {
|
|
75
|
+
const diffRes = handleDiffCommand([], undefined);
|
|
76
|
+
// Remove the full diffs if they somehow got in
|
|
77
|
+
delete diffRes.stagedDiff;
|
|
78
|
+
delete diffRes.unstagedDiff;
|
|
79
|
+
result.diff = diffRes;
|
|
80
|
+
} catch (e: any) {
|
|
81
|
+
// Expected to fail cleanly if not a git repository
|
|
82
|
+
result.diff = null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// 6. Workspaces
|
|
86
|
+
if (result.env && result.env.workspaces) {
|
|
87
|
+
result.workspaces = result.env.workspaces;
|
|
88
|
+
} else {
|
|
89
|
+
result.workspaces = null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return result;
|
|
93
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { readdirSync, statSync, existsSync, readFileSync } from 'fs';
|
|
2
|
+
import { join, basename } from 'path';
|
|
3
|
+
|
|
4
|
+
function buildIgnoreMatcher(cwd: string): (path: string) => boolean {
|
|
5
|
+
const ignoreFile = join(cwd, '.gitignore');
|
|
6
|
+
const defaults = ['node_modules', '.git', 'dist', '.next', 'build', 'out', 'coverage'];
|
|
7
|
+
|
|
8
|
+
let patterns: string[] = [];
|
|
9
|
+
if (existsSync(ignoreFile)) {
|
|
10
|
+
const lines = readFileSync(ignoreFile, 'utf-8').split('\n');
|
|
11
|
+
patterns = lines
|
|
12
|
+
.map(l => l.trim())
|
|
13
|
+
.filter(l => l && !l.startsWith('#'))
|
|
14
|
+
.map(l => l.replace(/\/$/, '')); // normalize dir ignores
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const allIgnores = new Set([...defaults, ...patterns]);
|
|
18
|
+
|
|
19
|
+
return (path: string) => {
|
|
20
|
+
const name = basename(path);
|
|
21
|
+
// Rough match for v1.0, sufficient for 95% of use cases
|
|
22
|
+
if (allIgnores.has(name)) return true;
|
|
23
|
+
for (const pattern of allIgnores) {
|
|
24
|
+
if (pattern.startsWith('*') && name.endsWith(pattern.slice(1))) return true;
|
|
25
|
+
}
|
|
26
|
+
return false;
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function handleTreeCommand(args: string[], budget?: number): any {
|
|
31
|
+
let targetDir = '.';
|
|
32
|
+
let maxDepth = 3;
|
|
33
|
+
|
|
34
|
+
for (const arg of args) {
|
|
35
|
+
if (arg.startsWith('--depth=')) {
|
|
36
|
+
maxDepth = parseInt(arg.split('=')[1], 10) || 3;
|
|
37
|
+
} else if (!arg.startsWith('--')) {
|
|
38
|
+
targetDir = arg;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const fullTarget = join(process.cwd(), targetDir);
|
|
43
|
+
if (!existsSync(fullTarget)) {
|
|
44
|
+
throw new Error(`Directory not found: ${targetDir}`);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const isIgnored = buildIgnoreMatcher(process.cwd());
|
|
48
|
+
const stats = {
|
|
49
|
+
totalFiles: 0,
|
|
50
|
+
displayed: 0,
|
|
51
|
+
ignoredDirs: new Set<string>()
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
function traverse(currentPath: string, currentDepth: number): any {
|
|
55
|
+
if (currentDepth > maxDepth) {
|
|
56
|
+
return { _more: true }; // Hit depth limit
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
let entries: string[] = [];
|
|
60
|
+
try {
|
|
61
|
+
entries = readdirSync(currentPath);
|
|
62
|
+
} catch {
|
|
63
|
+
return {}; // Permission issues
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const result: Record<string, any> = {};
|
|
67
|
+
const filesOnly: string[] = [];
|
|
68
|
+
let hiddenCount = 0;
|
|
69
|
+
|
|
70
|
+
for (const entry of entries) {
|
|
71
|
+
const entryPath = join(currentPath, entry);
|
|
72
|
+
|
|
73
|
+
if (isIgnored(entryPath) || entry.startsWith('.DS_Store')) {
|
|
74
|
+
const stat = statSync(entryPath, { throwIfNoEntry: false });
|
|
75
|
+
if (stat?.isDirectory()) {
|
|
76
|
+
stats.ignoredDirs.add(entry);
|
|
77
|
+
}
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const stat = statSync(entryPath, { throwIfNoEntry: false });
|
|
82
|
+
if (!stat) continue;
|
|
83
|
+
|
|
84
|
+
if (stat.isDirectory()) {
|
|
85
|
+
const sub = traverse(entryPath, currentDepth + 1);
|
|
86
|
+
if (sub && Object.keys(sub).length > 0) {
|
|
87
|
+
result[entry + '/'] = sub;
|
|
88
|
+
} else if (sub && sub._more) {
|
|
89
|
+
result[entry + '/'] = { _more: true };
|
|
90
|
+
}
|
|
91
|
+
} else {
|
|
92
|
+
stats.totalFiles++;
|
|
93
|
+
|
|
94
|
+
// Basic budget pruning threshold logic: if we are displaying too many files, start summarizing
|
|
95
|
+
// to stay within reasonable bounds even before the global output filter kicks in.
|
|
96
|
+
if (stats.displayed > 200) {
|
|
97
|
+
hiddenCount++;
|
|
98
|
+
} else {
|
|
99
|
+
filesOnly.push(entry);
|
|
100
|
+
stats.displayed++;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (filesOnly.length > 0) {
|
|
106
|
+
if (Object.keys(result).length === 0) {
|
|
107
|
+
// It's a directory with ONLY files. Use the flat array optimization.
|
|
108
|
+
const res: any = { _files: filesOnly };
|
|
109
|
+
if (hiddenCount > 0) res._more = hiddenCount;
|
|
110
|
+
return res;
|
|
111
|
+
} else {
|
|
112
|
+
// Mixed dir
|
|
113
|
+
for (const f of filesOnly) result[f] = {};
|
|
114
|
+
if (hiddenCount > 0) result['_moreReplacedFiles'] = hiddenCount;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return result;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const structure = traverse(fullTarget, 1);
|
|
122
|
+
|
|
123
|
+
return {
|
|
124
|
+
root: targetDir,
|
|
125
|
+
depth: maxDepth,
|
|
126
|
+
structure,
|
|
127
|
+
stats: {
|
|
128
|
+
totalFiles: stats.totalFiles,
|
|
129
|
+
displayed: stats.displayed,
|
|
130
|
+
ignored: Array.from(stats.ignoredDirs)
|
|
131
|
+
}
|
|
132
|
+
};
|
|
133
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Core output management for aiseerr.
|
|
3
|
+
* Guarantees Machine-First protocol: Pure JSON on stdout, structured error on stderr.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
// A very rough token estimator (approximately 4 characters per token for English/code)
|
|
7
|
+
export function estimateTokens(text: string): number {
|
|
8
|
+
return Math.max(1, Math.ceil(text.length / 4));
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// Ensure the final object doesn't exceed the token budget if one is provided
|
|
12
|
+
export function applyBudget(data: any, budget?: number): any {
|
|
13
|
+
if (!budget || budget <= 0) return data;
|
|
14
|
+
|
|
15
|
+
const serialized = JSON.stringify(data);
|
|
16
|
+
const estimated = estimateTokens(serialized);
|
|
17
|
+
|
|
18
|
+
if (estimated > budget) {
|
|
19
|
+
// Actually truncate: serialize to JSON, cut to budget char limit, close cleanly
|
|
20
|
+
const budgetChars = budget * 4;
|
|
21
|
+
|
|
22
|
+
if (typeof data === 'object' && data !== null && !Array.isArray(data)) {
|
|
23
|
+
// Try pruning array values first (largest arrays get trimmed)
|
|
24
|
+
const pruned = { ...data };
|
|
25
|
+
const keys = Object.keys(pruned);
|
|
26
|
+
|
|
27
|
+
// Sort keys by serialized size descending to prune largest first
|
|
28
|
+
const keySizes = keys
|
|
29
|
+
.filter(k => k !== '_meta')
|
|
30
|
+
.map(k => ({ key: k, size: JSON.stringify(pruned[k]).length }))
|
|
31
|
+
.sort((a, b) => b.size - a.size);
|
|
32
|
+
|
|
33
|
+
for (const { key } of keySizes) {
|
|
34
|
+
const currentSize = JSON.stringify(pruned).length;
|
|
35
|
+
if (currentSize <= budgetChars) break;
|
|
36
|
+
|
|
37
|
+
if (Array.isArray(pruned[key]) && pruned[key].length > 0) {
|
|
38
|
+
// Trim array items from the end until under budget
|
|
39
|
+
const arr = [...pruned[key]];
|
|
40
|
+
while (arr.length > 0 && JSON.stringify({ ...pruned, [key]: arr }).length > budgetChars) {
|
|
41
|
+
arr.pop();
|
|
42
|
+
}
|
|
43
|
+
pruned[key] = arr.length > 0 ? arr : `[TRUNCATED: ${pruned[key].length} items removed]`;
|
|
44
|
+
} else if (typeof pruned[key] === 'string' && pruned[key].length > 200) {
|
|
45
|
+
// Truncate long string values
|
|
46
|
+
const allowedLen = Math.max(100, pruned[key].length - (currentSize - budgetChars));
|
|
47
|
+
pruned[key] = pruned[key].slice(0, allowedLen) + '...[TRUNCATED]';
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Final fallback: if still over budget, hard-truncate the JSON string
|
|
52
|
+
const prunedJson = JSON.stringify(pruned);
|
|
53
|
+
if (prunedJson.length > budgetChars) {
|
|
54
|
+
const truncatedStr = prunedJson.slice(0, budgetChars);
|
|
55
|
+
// Try to parse back; if not valid, wrap as raw truncated output
|
|
56
|
+
return {
|
|
57
|
+
_truncated_raw: truncatedStr,
|
|
58
|
+
_meta: {
|
|
59
|
+
tokensEstimate: estimated,
|
|
60
|
+
budget,
|
|
61
|
+
truncated: true,
|
|
62
|
+
hint: `Output exceeded budget of ${budget} tokens. Data was hard-truncated.`
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return {
|
|
68
|
+
...pruned,
|
|
69
|
+
_meta: {
|
|
70
|
+
...(pruned._meta || {}),
|
|
71
|
+
tokensEstimate: estimateTokens(JSON.stringify(pruned)),
|
|
72
|
+
budget,
|
|
73
|
+
truncated: true,
|
|
74
|
+
hint: `Output exceeded budget of ${budget} tokens. Some fields were pruned.`
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Non-object data: hard truncate the serialized string
|
|
80
|
+
const truncated = serialized.slice(0, budgetChars);
|
|
81
|
+
return {
|
|
82
|
+
_truncated_raw: truncated,
|
|
83
|
+
_meta: {
|
|
84
|
+
tokensEstimate: estimated,
|
|
85
|
+
budget,
|
|
86
|
+
truncated: true,
|
|
87
|
+
hint: `Output exceeded budget of ${budget} tokens. Data was hard-truncated.`
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Under budget — attach meta info
|
|
93
|
+
if (typeof data === 'object' && data !== null && !Array.isArray(data)) {
|
|
94
|
+
return {
|
|
95
|
+
...data,
|
|
96
|
+
_meta: {
|
|
97
|
+
...(data._meta || {}),
|
|
98
|
+
tokensEstimate: estimated,
|
|
99
|
+
budget,
|
|
100
|
+
truncated: false
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return data;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function outputSuccess(data: any, budget?: number) {
|
|
109
|
+
const finalData = applyBudget(data, budget);
|
|
110
|
+
const output = JSON.stringify(finalData, null, process.argv.includes('--pretty') ? 2 : undefined) + '\n';
|
|
111
|
+
process.stdout.write(output, () => process.exit(0));
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function outputError(error: unknown, code = 1) {
|
|
115
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
116
|
+
const errorObj = {
|
|
117
|
+
error: message,
|
|
118
|
+
code: code
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
process.stderr.write(JSON.stringify(errorObj, null, process.argv.includes('--pretty') ? 2 : undefined) + '\n');
|
|
122
|
+
process.exit(code);
|
|
123
|
+
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
|
|
3
|
+
// We test the CLI routing logic by extracting the core routing behavior.
|
|
4
|
+
// Since cli.ts calls main() on import and uses process.argv / process.exit,
|
|
5
|
+
// we test the command routing indirectly through the individual handlers,
|
|
6
|
+
// and verify the arg-parsing logic directly.
|
|
7
|
+
|
|
8
|
+
// Mock all command handlers
|
|
9
|
+
vi.mock('../src/commands/env', () => ({ handleEnvCommand: vi.fn(() => ({ mock: 'env' })) }));
|
|
10
|
+
vi.mock('../src/commands/tree', () => ({ handleTreeCommand: vi.fn(() => ({ mock: 'tree' })) }));
|
|
11
|
+
vi.mock('../src/commands/read', () => ({ handleReadCommand: vi.fn(() => ({ mock: 'read' })) }));
|
|
12
|
+
vi.mock('../src/commands/diff', () => ({ handleDiffCommand: vi.fn(() => ({ mock: 'diff' })) }));
|
|
13
|
+
vi.mock('../src/commands/scout', () => ({ handleScoutCommand: vi.fn(() => ({ mock: 'scout' })) }));
|
|
14
|
+
vi.mock('../src/commands/init', () => ({ handleInitCommand: vi.fn(() => ({ mock: 'init' })) }));
|
|
15
|
+
|
|
16
|
+
// Mock output to prevent process.exit calls
|
|
17
|
+
vi.mock('../src/utils/output', () => ({
|
|
18
|
+
outputSuccess: vi.fn(),
|
|
19
|
+
outputError: vi.fn(),
|
|
20
|
+
}));
|
|
21
|
+
|
|
22
|
+
import { handleEnvCommand } from '../src/commands/env';
|
|
23
|
+
import { handleTreeCommand } from '../src/commands/tree';
|
|
24
|
+
import { handleReadCommand } from '../src/commands/read';
|
|
25
|
+
import { handleDiffCommand } from '../src/commands/diff';
|
|
26
|
+
import { handleScoutCommand } from '../src/commands/scout';
|
|
27
|
+
import { handleInitCommand } from '../src/commands/init';
|
|
28
|
+
import { outputSuccess, outputError } from '../src/utils/output';
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Re-implements the CLI arg parsing and routing logic from src/cli.ts
|
|
32
|
+
* to test it in isolation without triggering process.exit or auto-execution.
|
|
33
|
+
*/
|
|
34
|
+
function routeCommand(args: string[]): { command: string; result?: any; error?: string; isHelp?: boolean } {
|
|
35
|
+
if (args.length === 0 || args[0] === 'help' || args.includes('--help') || args.includes('-h')) {
|
|
36
|
+
return { command: 'help', isHelp: true };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const command = args[0];
|
|
40
|
+
const originalParams = args.slice(1);
|
|
41
|
+
|
|
42
|
+
let budget: number | undefined;
|
|
43
|
+
const commandParams: string[] = [];
|
|
44
|
+
|
|
45
|
+
for (const p of originalParams) {
|
|
46
|
+
if (p.startsWith('--budget=')) {
|
|
47
|
+
budget = parseInt(p.split('=')[1], 10);
|
|
48
|
+
if (isNaN(budget)) {
|
|
49
|
+
return { command, error: 'Invalid --budget value. Must be a number.' };
|
|
50
|
+
}
|
|
51
|
+
} else if (p === '--pretty') {
|
|
52
|
+
// handled globally
|
|
53
|
+
} else {
|
|
54
|
+
commandParams.push(p);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
let result: any;
|
|
59
|
+
switch (command) {
|
|
60
|
+
case 'scout':
|
|
61
|
+
result = (handleScoutCommand as any)(budget);
|
|
62
|
+
break;
|
|
63
|
+
case 'env':
|
|
64
|
+
result = (handleEnvCommand as any)();
|
|
65
|
+
break;
|
|
66
|
+
case 'tree':
|
|
67
|
+
result = (handleTreeCommand as any)(commandParams, budget);
|
|
68
|
+
break;
|
|
69
|
+
case 'read':
|
|
70
|
+
result = (handleReadCommand as any)(commandParams, budget);
|
|
71
|
+
break;
|
|
72
|
+
case 'diff':
|
|
73
|
+
result = (handleDiffCommand as any)(commandParams, budget);
|
|
74
|
+
break;
|
|
75
|
+
case 'init':
|
|
76
|
+
result = (handleInitCommand as any)(commandParams);
|
|
77
|
+
break;
|
|
78
|
+
default:
|
|
79
|
+
return { command, error: `Unknown command: ${command}` };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return { command, result };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
describe('CLI Routing', () => {
|
|
86
|
+
beforeEach(() => {
|
|
87
|
+
vi.clearAllMocks();
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('should show help when no arguments provided', () => {
|
|
91
|
+
const res = routeCommand([]);
|
|
92
|
+
expect(res.isHelp).toBe(true);
|
|
93
|
+
expect(res.command).toBe('help');
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('should show help when "help" is the first argument (args[0] === "help" fix)', () => {
|
|
97
|
+
const res = routeCommand(['help']);
|
|
98
|
+
expect(res.isHelp).toBe(true);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('should show help when --help flag is present anywhere', () => {
|
|
102
|
+
const res = routeCommand(['env', '--help']);
|
|
103
|
+
expect(res.isHelp).toBe(true);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('should show help when -h flag is present', () => {
|
|
107
|
+
const res = routeCommand(['-h']);
|
|
108
|
+
expect(res.isHelp).toBe(true);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('should route to env command', () => {
|
|
112
|
+
const res = routeCommand(['env']);
|
|
113
|
+
expect(res.command).toBe('env');
|
|
114
|
+
expect(handleEnvCommand).toHaveBeenCalled();
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('should route to tree command with params', () => {
|
|
118
|
+
routeCommand(['tree', 'src/', '--depth=5']);
|
|
119
|
+
expect(handleTreeCommand).toHaveBeenCalledWith(['src/', '--depth=5'], undefined);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('should route to read command', () => {
|
|
123
|
+
routeCommand(['read', 'file.ts']);
|
|
124
|
+
expect(handleReadCommand).toHaveBeenCalledWith(['file.ts'], undefined);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('should route to diff command', () => {
|
|
128
|
+
routeCommand(['diff', '--full']);
|
|
129
|
+
expect(handleDiffCommand).toHaveBeenCalledWith(['--full'], undefined);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('should route to scout command', () => {
|
|
133
|
+
routeCommand(['scout']);
|
|
134
|
+
expect(handleScoutCommand).toHaveBeenCalledWith(undefined);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('should route to init command', () => {
|
|
138
|
+
routeCommand(['init', '--format=claude']);
|
|
139
|
+
expect(handleInitCommand).toHaveBeenCalledWith(['--format=claude']);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('should return error for unknown commands', () => {
|
|
143
|
+
const res = routeCommand(['foobar']);
|
|
144
|
+
expect(res.error).toBe('Unknown command: foobar');
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('should extract --budget flag and pass as number', () => {
|
|
148
|
+
routeCommand(['scout', '--budget=500']);
|
|
149
|
+
expect(handleScoutCommand).toHaveBeenCalledWith(500);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it('should pass budget to tree command', () => {
|
|
153
|
+
routeCommand(['tree', 'src/', '--budget=1000', '--depth=2']);
|
|
154
|
+
expect(handleTreeCommand).toHaveBeenCalledWith(['src/', '--depth=2'], 1000);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it('should error on invalid budget value (NaN)', () => {
|
|
158
|
+
const res = routeCommand(['env', '--budget=abc']);
|
|
159
|
+
expect(res.error).toContain('Invalid --budget value');
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it('should strip --pretty from command params', () => {
|
|
163
|
+
routeCommand(['tree', 'src/', '--pretty']);
|
|
164
|
+
// --pretty should not be passed to the command handler
|
|
165
|
+
expect(handleTreeCommand).toHaveBeenCalledWith(['src/'], undefined);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it('should handle multiple flags together', () => {
|
|
169
|
+
routeCommand(['read', 'file.ts', '--lines=1-10', '--budget=200', '--pretty']);
|
|
170
|
+
expect(handleReadCommand).toHaveBeenCalledWith(['file.ts', '--lines=1-10'], 200);
|
|
171
|
+
});
|
|
172
|
+
});
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
import { handleDiffCommand } from '../src/commands/diff';
|
|
3
|
+
import * as child_process from 'child_process';
|
|
4
|
+
import * as fs from 'fs';
|
|
5
|
+
|
|
6
|
+
vi.mock('fs', async () => {
|
|
7
|
+
const actual = await vi.importActual<typeof import('fs')>('fs');
|
|
8
|
+
return { ...actual, existsSync: vi.fn() };
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
vi.mock('child_process', () => ({
|
|
12
|
+
execFileSync: vi.fn()
|
|
13
|
+
}));
|
|
14
|
+
|
|
15
|
+
const mockExistsSync = fs.existsSync as any;
|
|
16
|
+
const mockExecFileSync = child_process.execFileSync as any;
|
|
17
|
+
|
|
18
|
+
describe('Diff Command', () => {
|
|
19
|
+
beforeEach(() => {
|
|
20
|
+
vi.clearAllMocks();
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('should throw when not a git repository', () => {
|
|
24
|
+
mockExistsSync.mockReturnValue(false);
|
|
25
|
+
expect(() => handleDiffCommand([])).toThrow('Not a git repository');
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('should return branch, ahead/behind counts, staged/unstaged/untracked', () => {
|
|
29
|
+
mockExistsSync.mockImplementation((p: string) => p.endsWith('.git'));
|
|
30
|
+
mockExecFileSync.mockImplementation((cmd: string, args: string[]) => {
|
|
31
|
+
if (args.includes('--abbrev-ref')) return 'feature-x\n';
|
|
32
|
+
if (args.includes('--left-right')) return '3\t1\n';
|
|
33
|
+
if (args.includes('--porcelain')) return 'M staged.ts\n M unstaged.ts\n?? new.ts\n';
|
|
34
|
+
if (args.includes('--numstat') && args.includes('--cached')) return '10\t2\tstaged.ts\n';
|
|
35
|
+
if (args.includes('--numstat')) return '5\t3\tunstaged.ts\n';
|
|
36
|
+
return '';
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
const result = handleDiffCommand([]);
|
|
40
|
+
expect(result.branch).toBe('feature-x');
|
|
41
|
+
expect(result.ahead).toBe(3);
|
|
42
|
+
expect(result.behind).toBe(1);
|
|
43
|
+
expect(result.staged).toEqual([{ file: 'staged.ts', status: 'M', insertions: 10, deletions: 2 }]);
|
|
44
|
+
expect(result.unstaged).toEqual([{ file: 'unstaged.ts', status: 'M', insertions: 5, deletions: 3 }]);
|
|
45
|
+
expect(result.untracked).toEqual(['new.ts']);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('should handle --full flag and attach raw diff output', () => {
|
|
49
|
+
mockExistsSync.mockImplementation((p: string) => p.endsWith('.git'));
|
|
50
|
+
mockExecFileSync.mockImplementation((cmd: string, args: string[]) => {
|
|
51
|
+
if (args.includes('--abbrev-ref')) return 'main\n';
|
|
52
|
+
if (args.includes('--left-right')) return null;
|
|
53
|
+
if (args.includes('--porcelain')) return '';
|
|
54
|
+
if (args[0] === 'diff' && args.includes('--cached')) return 'staged diff content\n';
|
|
55
|
+
if (args[0] === 'diff' && !args.includes('--cached') && !args.includes('--numstat')) return 'unstaged diff content\n';
|
|
56
|
+
return '';
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
const result = handleDiffCommand(['--full']);
|
|
60
|
+
expect(result.stagedDiff).toBe('staged diff content');
|
|
61
|
+
expect(result.unstagedDiff).toBe('unstaged diff content');
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('should filter by --file flag', () => {
|
|
65
|
+
mockExistsSync.mockImplementation((p: string) => p.endsWith('.git'));
|
|
66
|
+
mockExecFileSync.mockImplementation((cmd: string, args: string[]) => {
|
|
67
|
+
if (args.includes('--abbrev-ref')) return 'main\n';
|
|
68
|
+
if (args.includes('--left-right')) return '';
|
|
69
|
+
if (args.includes('--porcelain')) return 'M target.ts\nM other.ts\n';
|
|
70
|
+
return '';
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
const result = handleDiffCommand(['--file=target.ts']);
|
|
74
|
+
expect(result.staged.length).toBe(1);
|
|
75
|
+
expect(result.staged[0].file).toBe('target.ts');
|
|
76
|
+
// other.ts should be filtered out
|
|
77
|
+
expect(result.unstaged.length).toBe(0);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('should use execFileSync (not execSync) to prevent shell injection', () => {
|
|
81
|
+
// Verify the module uses execFileSync by checking the import
|
|
82
|
+
// execFileSync takes command and args array separately — no shell interpretation
|
|
83
|
+
mockExistsSync.mockImplementation((p: string) => p.endsWith('.git'));
|
|
84
|
+
mockExecFileSync.mockImplementation(() => '');
|
|
85
|
+
|
|
86
|
+
handleDiffCommand([]);
|
|
87
|
+
|
|
88
|
+
// All calls should be to execFileSync (our mock), not execSync
|
|
89
|
+
expect(mockExecFileSync).toHaveBeenCalled();
|
|
90
|
+
// Verify args are passed as arrays (safe), not concatenated strings
|
|
91
|
+
for (const call of mockExecFileSync.mock.calls) {
|
|
92
|
+
expect(call[0]).toBe('git');
|
|
93
|
+
expect(Array.isArray(call[1])).toBe(true);
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('should handle no tracking info gracefully (ahead/behind = 0)', () => {
|
|
98
|
+
mockExistsSync.mockImplementation((p: string) => p.endsWith('.git'));
|
|
99
|
+
mockExecFileSync.mockImplementation((cmd: string, args: string[]) => {
|
|
100
|
+
if (args.includes('--abbrev-ref')) return 'main\n';
|
|
101
|
+
if (args.includes('--left-right')) return null; // no upstream
|
|
102
|
+
if (args.includes('--porcelain')) return '';
|
|
103
|
+
return '';
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
const result = handleDiffCommand([]);
|
|
107
|
+
expect(result.ahead).toBe(0);
|
|
108
|
+
expect(result.behind).toBe(0);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('should handle empty git status (clean working directory)', () => {
|
|
112
|
+
mockExistsSync.mockImplementation((p: string) => p.endsWith('.git'));
|
|
113
|
+
mockExecFileSync.mockImplementation((cmd: string, args: string[]) => {
|
|
114
|
+
if (args.includes('--abbrev-ref')) return 'main\n';
|
|
115
|
+
if (args.includes('--left-right')) return '0\t0\n';
|
|
116
|
+
if (args.includes('--porcelain')) return '';
|
|
117
|
+
return null;
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
const result = handleDiffCommand([]);
|
|
121
|
+
expect(result.staged).toEqual([]);
|
|
122
|
+
expect(result.unstaged).toEqual([]);
|
|
123
|
+
expect(result.untracked).toEqual([]);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('should not attach stagedDiff/unstagedDiff without --full flag', () => {
|
|
127
|
+
mockExistsSync.mockImplementation((p: string) => p.endsWith('.git'));
|
|
128
|
+
mockExecFileSync.mockImplementation((cmd: string, args: string[]) => {
|
|
129
|
+
if (args.includes('--abbrev-ref')) return 'main\n';
|
|
130
|
+
if (args.includes('--left-right')) return '';
|
|
131
|
+
if (args.includes('--porcelain')) return '';
|
|
132
|
+
return '';
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
const result = handleDiffCommand([]);
|
|
136
|
+
expect(result.stagedDiff).toBeUndefined();
|
|
137
|
+
expect(result.unstagedDiff).toBeUndefined();
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('should handle --full with --file to scope diff', () => {
|
|
141
|
+
mockExistsSync.mockImplementation((p: string) => p.endsWith('.git'));
|
|
142
|
+
mockExecFileSync.mockImplementation((cmd: string, args: string[]) => {
|
|
143
|
+
if (args.includes('--abbrev-ref')) return 'main\n';
|
|
144
|
+
if (args.includes('--left-right')) return '';
|
|
145
|
+
if (args.includes('--porcelain')) return '';
|
|
146
|
+
// When --full + --file=target.ts, diff args should include the file
|
|
147
|
+
if (args[0] === 'diff' && args.includes('target.ts')) return 'targeted diff\n';
|
|
148
|
+
if (args[0] === 'diff') return '';
|
|
149
|
+
return '';
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
const result = handleDiffCommand(['--full', '--file=target.ts']);
|
|
153
|
+
// The --file flag should cause diff to be scoped to target.ts
|
|
154
|
+
expect(result).toBeDefined();
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it('should handle branch detection failure', () => {
|
|
158
|
+
mockExistsSync.mockImplementation((p: string) => p.endsWith('.git'));
|
|
159
|
+
mockExecFileSync.mockImplementation((cmd: string, args: string[]) => {
|
|
160
|
+
if (args.includes('--abbrev-ref')) { throw new Error('git failed'); }
|
|
161
|
+
if (args.includes('--left-right')) return null;
|
|
162
|
+
if (args.includes('--porcelain')) return '';
|
|
163
|
+
return null;
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
const result = handleDiffCommand([]);
|
|
167
|
+
expect(result.branch).toBe('unknown');
|
|
168
|
+
});
|
|
169
|
+
});
|