@sean.holung/minicode 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/README.md +241 -0
- package/dist/src/agent/agent.js +209 -0
- package/dist/src/agent/config.js +151 -0
- package/dist/src/agent/types.js +1 -0
- package/dist/src/index.js +138 -0
- package/dist/src/indexer/cache.js +121 -0
- package/dist/src/indexer/code-map.js +92 -0
- package/dist/src/indexer/plugin-loader.js +78 -0
- package/dist/src/indexer/plugins/typescript.js +327 -0
- package/dist/src/indexer/project-index.js +145 -0
- package/dist/src/indexer/types.js +1 -0
- package/dist/src/model/client.js +374 -0
- package/dist/src/prompt/system-prompt.js +91 -0
- package/dist/src/safety/guardrails.js +55 -0
- package/dist/src/session/session.js +95 -0
- package/dist/src/tools/edit-file.js +73 -0
- package/dist/src/tools/find-references.js +52 -0
- package/dist/src/tools/get-dependencies.js +56 -0
- package/dist/src/tools/helpers.js +42 -0
- package/dist/src/tools/list-files.js +63 -0
- package/dist/src/tools/read-file.js +79 -0
- package/dist/src/tools/read-symbol.js +96 -0
- package/dist/src/tools/registry.js +68 -0
- package/dist/src/tools/run-command.js +92 -0
- package/dist/src/tools/search-code-map.js +72 -0
- package/dist/src/tools/search.js +153 -0
- package/dist/src/tools/write-file.js +44 -0
- package/dist/src/ui/app.js +31 -0
- package/dist/src/ui/cli-ink.js +168 -0
- package/dist/src/ui/components/activity-pane.js +35 -0
- package/dist/src/ui/components/header-bar.js +6 -0
- package/dist/src/ui/components/input-composer.js +46 -0
- package/dist/src/ui/components/tool-timeline-item.js +37 -0
- package/dist/src/ui/events.js +1 -0
- package/dist/src/ui/state/ui-store.js +89 -0
- package/dist/src/ui/theme.js +23 -0
- package/dist/tests/agent.test.js +130 -0
- package/dist/tests/cache.test.js +37 -0
- package/dist/tests/config.test.js +37 -0
- package/dist/tests/dependency-graph.test.js +27 -0
- package/dist/tests/file-tools.test.js +73 -0
- package/dist/tests/find-references.test.js +30 -0
- package/dist/tests/get-dependencies.test.js +35 -0
- package/dist/tests/guardrails.test.js +18 -0
- package/dist/tests/indexer.test.js +201 -0
- package/dist/tests/model-client-openai.test.js +84 -0
- package/dist/tests/read-symbol.test.js +83 -0
- package/dist/tests/search-code-map.test.js +30 -0
- package/dist/tests/session.test.js +37 -0
- package/dist/tests/system-prompt.test.js +82 -0
- package/dist/tests/test-utils.js +18 -0
- package/dist/tests/tool-registry.test.js +41 -0
- package/package.json +43 -0
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { expectNonEmptyString, expectOptionalNumber, } from "./helpers.js";
|
|
2
|
+
const DEFAULT_LIMIT = 50;
|
|
3
|
+
export function createFindReferencesTool(projectIndex) {
|
|
4
|
+
return {
|
|
5
|
+
name: "find_references",
|
|
6
|
+
description: "Find all symbols that reference or call a given symbol. Use to understand impact before changes. Prefer over search when you know the symbol name.",
|
|
7
|
+
inputSchema: {
|
|
8
|
+
type: "object",
|
|
9
|
+
properties: {
|
|
10
|
+
name: {
|
|
11
|
+
type: "string",
|
|
12
|
+
description: "Symbol name or qualified name to find references for.",
|
|
13
|
+
},
|
|
14
|
+
skip: {
|
|
15
|
+
type: "number",
|
|
16
|
+
description: "Number of results to skip (for pagination). Default 0.",
|
|
17
|
+
},
|
|
18
|
+
limit: {
|
|
19
|
+
type: "number",
|
|
20
|
+
description: "Max number of results to return. Default 50.",
|
|
21
|
+
},
|
|
22
|
+
},
|
|
23
|
+
required: ["name"],
|
|
24
|
+
additionalProperties: false,
|
|
25
|
+
},
|
|
26
|
+
execute: async (input) => {
|
|
27
|
+
const name = expectNonEmptyString(input, "name");
|
|
28
|
+
const skip = Math.max(0, expectOptionalNumber(input, "skip") ?? 0);
|
|
29
|
+
const limit = Math.max(1, Math.min(100, expectOptionalNumber(input, "limit") ?? DEFAULT_LIMIT));
|
|
30
|
+
const symbol = projectIndex.getSymbol(name);
|
|
31
|
+
if (!symbol) {
|
|
32
|
+
return `Symbol "${name}" not found in the project index.`;
|
|
33
|
+
}
|
|
34
|
+
const refs = projectIndex.dependencyEdges.filter((e) => e.to === symbol.qualifiedName || e.to === symbol.name);
|
|
35
|
+
if (refs.length === 0) {
|
|
36
|
+
return `No references found for "${name}".`;
|
|
37
|
+
}
|
|
38
|
+
const shown = refs.slice(skip, skip + limit);
|
|
39
|
+
const lines = shown.map((e) => `- ${e.from} (${e.kind})`);
|
|
40
|
+
const remaining = refs.length - skip - shown.length;
|
|
41
|
+
const footer = remaining > 0
|
|
42
|
+
? `\n... and ${remaining} more (use skip: ${skip + limit}, limit: ${limit} for next page)`
|
|
43
|
+
: "";
|
|
44
|
+
return [
|
|
45
|
+
`# References to ${symbol.qualifiedName} (${refs.length} total)`,
|
|
46
|
+
"",
|
|
47
|
+
...lines,
|
|
48
|
+
footer,
|
|
49
|
+
].join("\n");
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { expectNonEmptyString, expectOptionalNumber } from "./helpers.js";
|
|
2
|
+
export function createGetDependenciesTool(projectIndex) {
|
|
3
|
+
return {
|
|
4
|
+
name: "get_dependencies",
|
|
5
|
+
description: "Get the dependency cone of a symbol — everything it calls or depends on. Use to understand implementation and data flow. Prefer over reading full files.",
|
|
6
|
+
inputSchema: {
|
|
7
|
+
type: "object",
|
|
8
|
+
properties: {
|
|
9
|
+
name: {
|
|
10
|
+
type: "string",
|
|
11
|
+
description: "Symbol name or qualified name.",
|
|
12
|
+
},
|
|
13
|
+
depth: {
|
|
14
|
+
type: "number",
|
|
15
|
+
description: "How many levels of dependencies to include. Default 1.",
|
|
16
|
+
},
|
|
17
|
+
skip: {
|
|
18
|
+
type: "number",
|
|
19
|
+
description: "Number of results to skip (for pagination). Default 0.",
|
|
20
|
+
},
|
|
21
|
+
limit: {
|
|
22
|
+
type: "number",
|
|
23
|
+
description: "Max number of results to return. Default 50.",
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
required: ["name"],
|
|
27
|
+
additionalProperties: false,
|
|
28
|
+
},
|
|
29
|
+
execute: async (input) => {
|
|
30
|
+
const name = expectNonEmptyString(input, "name");
|
|
31
|
+
const depth = expectOptionalNumber(input, "depth") ?? 1;
|
|
32
|
+
const skip = Math.max(0, expectOptionalNumber(input, "skip") ?? 0);
|
|
33
|
+
const limit = Math.max(1, Math.min(100, expectOptionalNumber(input, "limit") ?? 50));
|
|
34
|
+
const symbol = projectIndex.getSymbol(name);
|
|
35
|
+
if (!symbol) {
|
|
36
|
+
return `Symbol "${name}" not found in the project index.`;
|
|
37
|
+
}
|
|
38
|
+
const cone = projectIndex.getDependencyCone(name, depth);
|
|
39
|
+
const shown = cone.slice(skip, skip + limit);
|
|
40
|
+
const lines = shown.map((s) => {
|
|
41
|
+
const header = `${s.kind} ${s.qualifiedName}`;
|
|
42
|
+
return `${header}\n ${s.signature}`;
|
|
43
|
+
});
|
|
44
|
+
const remaining = cone.length - skip - shown.length;
|
|
45
|
+
const footer = remaining > 0
|
|
46
|
+
? `\n\n... and ${remaining} more (use skip: ${skip + limit}, limit: ${limit} for next page)`
|
|
47
|
+
: "";
|
|
48
|
+
return [
|
|
49
|
+
`# Dependencies of ${symbol.qualifiedName} (depth ${depth}, ${cone.length} total)`,
|
|
50
|
+
"",
|
|
51
|
+
...lines,
|
|
52
|
+
footer,
|
|
53
|
+
].join("\n\n");
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
export function expectNonEmptyString(input, key) {
|
|
2
|
+
const value = input[key];
|
|
3
|
+
if (typeof value !== "string" || value.trim().length === 0) {
|
|
4
|
+
throw new Error(`Input "${key}" must be a non-empty string.`);
|
|
5
|
+
}
|
|
6
|
+
return value;
|
|
7
|
+
}
|
|
8
|
+
export function expectOptionalBoolean(input, key) {
|
|
9
|
+
const value = input[key];
|
|
10
|
+
if (value === undefined) {
|
|
11
|
+
return undefined;
|
|
12
|
+
}
|
|
13
|
+
if (typeof value !== "boolean") {
|
|
14
|
+
throw new Error(`Input "${key}" must be a boolean when provided.`);
|
|
15
|
+
}
|
|
16
|
+
return value;
|
|
17
|
+
}
|
|
18
|
+
export function expectOptionalNumber(input, key) {
|
|
19
|
+
const value = input[key];
|
|
20
|
+
if (value === undefined) {
|
|
21
|
+
return undefined;
|
|
22
|
+
}
|
|
23
|
+
if (typeof value !== "number" || !Number.isFinite(value)) {
|
|
24
|
+
throw new Error(`Input "${key}" must be a finite number when provided.`);
|
|
25
|
+
}
|
|
26
|
+
return value;
|
|
27
|
+
}
|
|
28
|
+
export function formatWithLineNumbers(text, startLine = 1, limit) {
|
|
29
|
+
const lines = text.split(/\r?\n/);
|
|
30
|
+
const beginIndex = Math.max(startLine - 1, 0);
|
|
31
|
+
const endIndex = limit === undefined
|
|
32
|
+
? lines.length
|
|
33
|
+
: Math.min(lines.length, beginIndex + Math.max(limit, 0));
|
|
34
|
+
const output = [];
|
|
35
|
+
for (let index = beginIndex; index < endIndex; index += 1) {
|
|
36
|
+
output.push(`${index + 1}|${lines[index] ?? ""}`);
|
|
37
|
+
}
|
|
38
|
+
return output.join("\n");
|
|
39
|
+
}
|
|
40
|
+
export function toJson(input) {
|
|
41
|
+
return JSON.stringify(input);
|
|
42
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { readdir } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { resolveWorkspacePath } from "../safety/guardrails.js";
|
|
4
|
+
import { expectOptionalNumber } from "./helpers.js";
|
|
5
|
+
const EXCLUDED_DIRS = new Set(["node_modules", ".git", ".minicode"]);
|
|
6
|
+
const DEFAULT_LIMIT = 200;
|
|
7
|
+
function parsePath(input) {
|
|
8
|
+
const value = input.path;
|
|
9
|
+
if (value === undefined) {
|
|
10
|
+
return ".";
|
|
11
|
+
}
|
|
12
|
+
if (typeof value !== "string" || value.trim().length === 0) {
|
|
13
|
+
throw new Error(`Input "path" must be a non-empty string when provided.`);
|
|
14
|
+
}
|
|
15
|
+
return value;
|
|
16
|
+
}
|
|
17
|
+
export function createListFilesTool(config) {
|
|
18
|
+
return {
|
|
19
|
+
name: "list_files",
|
|
20
|
+
description: "List files and directories at a path.",
|
|
21
|
+
inputSchema: {
|
|
22
|
+
type: "object",
|
|
23
|
+
properties: {
|
|
24
|
+
path: {
|
|
25
|
+
type: "string",
|
|
26
|
+
description: "Optional path to list, relative to workspace root. Defaults to current workspace root.",
|
|
27
|
+
},
|
|
28
|
+
skip: {
|
|
29
|
+
type: "number",
|
|
30
|
+
description: "Number of entries to skip (for pagination). Default 0.",
|
|
31
|
+
},
|
|
32
|
+
limit: {
|
|
33
|
+
type: "number",
|
|
34
|
+
description: "Max number of entries to return. Default 200.",
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
additionalProperties: false,
|
|
38
|
+
},
|
|
39
|
+
execute: async (input) => {
|
|
40
|
+
const requestedPath = parsePath(input);
|
|
41
|
+
const skip = Math.max(0, expectOptionalNumber(input, "skip") ?? 0);
|
|
42
|
+
const limit = Math.max(1, Math.min(500, expectOptionalNumber(input, "limit") ?? DEFAULT_LIMIT));
|
|
43
|
+
const dirPath = resolveWorkspacePath(requestedPath, config.workspaceRoot);
|
|
44
|
+
const entries = await readdir(dirPath, { withFileTypes: true });
|
|
45
|
+
const filtered = entries.filter((entry) => !(entry.isDirectory() &&
|
|
46
|
+
EXCLUDED_DIRS.has(entry.name)));
|
|
47
|
+
const sorted = filtered.sort((a, b) => a.name.localeCompare(b.name));
|
|
48
|
+
const listed = sorted
|
|
49
|
+
.slice(skip, skip + limit)
|
|
50
|
+
.map((entry) => entry.isDirectory()
|
|
51
|
+
? `[dir] ${path.join(requestedPath, entry.name)}`
|
|
52
|
+
: `[file] ${path.join(requestedPath, entry.name)}`);
|
|
53
|
+
if (listed.length === 0) {
|
|
54
|
+
return `Directory "${requestedPath}" is empty.`;
|
|
55
|
+
}
|
|
56
|
+
const remaining = sorted.length - skip - listed.length;
|
|
57
|
+
const footer = remaining > 0
|
|
58
|
+
? `\n... and ${remaining} more (use skip: ${skip + limit}, limit: ${limit} for next page)`
|
|
59
|
+
: "";
|
|
60
|
+
return listed.join("\n") + footer;
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { readFile, stat } from "node:fs/promises";
|
|
2
|
+
import { resolveWorkspacePath, validateFileReadSize, } from "../safety/guardrails.js";
|
|
3
|
+
import { expectNonEmptyString, expectOptionalNumber, formatWithLineNumbers, } from "./helpers.js";
|
|
4
|
+
function parseLineOffset(totalLines, rawOffset) {
|
|
5
|
+
if (rawOffset === undefined) {
|
|
6
|
+
return 1;
|
|
7
|
+
}
|
|
8
|
+
if (!Number.isInteger(rawOffset) || rawOffset === 0) {
|
|
9
|
+
throw new Error(`"offset" must be a non-zero integer when provided.`);
|
|
10
|
+
}
|
|
11
|
+
if (rawOffset > 0) {
|
|
12
|
+
return rawOffset;
|
|
13
|
+
}
|
|
14
|
+
return Math.max(1, totalLines + rawOffset + 1);
|
|
15
|
+
}
|
|
16
|
+
const DEFAULT_LINE_LIMIT = 500;
|
|
17
|
+
function parseLimit(rawLimit, totalLines) {
|
|
18
|
+
if (rawLimit !== undefined) {
|
|
19
|
+
if (!Number.isInteger(rawLimit) || rawLimit < 0) {
|
|
20
|
+
throw new Error(`"limit" must be a non-negative integer when provided.`);
|
|
21
|
+
}
|
|
22
|
+
return rawLimit;
|
|
23
|
+
}
|
|
24
|
+
if (totalLines > DEFAULT_LINE_LIMIT) {
|
|
25
|
+
return DEFAULT_LINE_LIMIT;
|
|
26
|
+
}
|
|
27
|
+
return totalLines;
|
|
28
|
+
}
|
|
29
|
+
export function createReadFileTool(config) {
|
|
30
|
+
return {
|
|
31
|
+
name: "read_file",
|
|
32
|
+
description: "Read file contents with line numbers. Use for config files, non-code files, or when symbol name is unknown. For code files (.ts/.tsx/.js/.jsx), prefer read_symbol when the symbol is in the code map. For large files, use offset and limit.",
|
|
33
|
+
inputSchema: {
|
|
34
|
+
type: "object",
|
|
35
|
+
properties: {
|
|
36
|
+
path: {
|
|
37
|
+
type: "string",
|
|
38
|
+
description: "Path to the file relative to the workspace root.",
|
|
39
|
+
},
|
|
40
|
+
offset: {
|
|
41
|
+
type: "number",
|
|
42
|
+
description: "Optional 1-based line number to start from. Negative numbers count from file end.",
|
|
43
|
+
},
|
|
44
|
+
limit: {
|
|
45
|
+
type: "number",
|
|
46
|
+
description: "Optional maximum number of lines to return.",
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
required: ["path"],
|
|
50
|
+
additionalProperties: false,
|
|
51
|
+
},
|
|
52
|
+
execute: async (input) => {
|
|
53
|
+
const requestedPath = expectNonEmptyString(input, "path");
|
|
54
|
+
const offset = expectOptionalNumber(input, "offset");
|
|
55
|
+
const limit = expectOptionalNumber(input, "limit");
|
|
56
|
+
const filePath = resolveWorkspacePath(requestedPath, config.workspaceRoot);
|
|
57
|
+
const fileStat = await stat(filePath);
|
|
58
|
+
if (!fileStat.isFile()) {
|
|
59
|
+
throw new Error(`"${requestedPath}" is not a file.`);
|
|
60
|
+
}
|
|
61
|
+
validateFileReadSize(fileStat.size, config.maxFileSizeBytes);
|
|
62
|
+
const content = await readFile(filePath, "utf8");
|
|
63
|
+
if (content.length === 0) {
|
|
64
|
+
return "File is empty.";
|
|
65
|
+
}
|
|
66
|
+
const lines = content.split(/\r?\n/);
|
|
67
|
+
const startLine = parseLineOffset(lines.length, offset);
|
|
68
|
+
const effectiveLimit = parseLimit(limit, lines.length);
|
|
69
|
+
const output = formatWithLineNumbers(content, startLine, effectiveLimit);
|
|
70
|
+
if (limit === undefined &&
|
|
71
|
+
lines.length > DEFAULT_LINE_LIMIT &&
|
|
72
|
+
effectiveLimit < lines.length) {
|
|
73
|
+
const remaining = Math.max(0, lines.length - (startLine - 1) - effectiveLimit);
|
|
74
|
+
return `${output}\n\n[... truncated, ${remaining} more lines. Use offset and limit to read specific sections.]`;
|
|
75
|
+
}
|
|
76
|
+
return output;
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { readFile, stat } from "node:fs/promises";
|
|
2
|
+
import { resolveWorkspacePath, validateFileReadSize, } from "../safety/guardrails.js";
|
|
3
|
+
import { expectNonEmptyString, expectOptionalBoolean } from "./helpers.js";
|
|
4
|
+
const LEADING_CONTEXT_LINES = 3;
|
|
5
|
+
export function createReadSymbolTool(config, projectIndex) {
|
|
6
|
+
return {
|
|
7
|
+
name: "read_symbol",
|
|
8
|
+
description: "Read a specific function, class, or type definition by name. " +
|
|
9
|
+
"Returns the symbol's source code, referenced types, callers, and callees. " +
|
|
10
|
+
"PREFER this over read_file for .ts/.tsx/.js/.jsx — use the code map to find symbol names.",
|
|
11
|
+
inputSchema: {
|
|
12
|
+
type: "object",
|
|
13
|
+
properties: {
|
|
14
|
+
name: {
|
|
15
|
+
type: "string",
|
|
16
|
+
description: "Symbol name or qualified name (e.g. 'parseResponse' or 'CodingAgent.runTurn').",
|
|
17
|
+
},
|
|
18
|
+
includeBody: {
|
|
19
|
+
type: "boolean",
|
|
20
|
+
description: "If false, return signature only. Defaults to true.",
|
|
21
|
+
},
|
|
22
|
+
},
|
|
23
|
+
required: ["name"],
|
|
24
|
+
additionalProperties: false,
|
|
25
|
+
},
|
|
26
|
+
execute: async (input) => {
|
|
27
|
+
const name = expectNonEmptyString(input, "name");
|
|
28
|
+
const includeBody = expectOptionalBoolean(input, "includeBody") ?? true;
|
|
29
|
+
const symbol = projectIndex.getSymbol(name);
|
|
30
|
+
if (!symbol) {
|
|
31
|
+
return `Symbol "${name}" not found in the project index. Try using search to find it, or use read_file to read the full file.`;
|
|
32
|
+
}
|
|
33
|
+
const filePath = resolveWorkspacePath(symbol.filePath, config.workspaceRoot);
|
|
34
|
+
const fileStat = await stat(filePath);
|
|
35
|
+
if (!fileStat.isFile()) {
|
|
36
|
+
return `File "${symbol.filePath}" is not a file.`;
|
|
37
|
+
}
|
|
38
|
+
validateFileReadSize(fileStat.size, config.maxFileSizeBytes);
|
|
39
|
+
const content = await readFile(filePath, "utf8");
|
|
40
|
+
const lines = content.split(/\r?\n/);
|
|
41
|
+
if (includeBody) {
|
|
42
|
+
const startLine = Math.max(1, symbol.startLine - LEADING_CONTEXT_LINES);
|
|
43
|
+
const endLine = Math.min(lines.length, symbol.endLine);
|
|
44
|
+
const excerptLines = lines.slice(startLine - 1, endLine);
|
|
45
|
+
const formatted = excerptLines
|
|
46
|
+
.map((line, i) => `${startLine + i}|${line}`)
|
|
47
|
+
.join("\n");
|
|
48
|
+
const parts = [
|
|
49
|
+
`# ${symbol.qualifiedName} (${symbol.kind})`,
|
|
50
|
+
`File: ${symbol.filePath}`,
|
|
51
|
+
`Lines: ${symbol.startLine}-${symbol.endLine}`,
|
|
52
|
+
"",
|
|
53
|
+
];
|
|
54
|
+
if (symbol.docComment) {
|
|
55
|
+
parts.push("## Description", "", symbol.docComment, "");
|
|
56
|
+
}
|
|
57
|
+
parts.push(formatted);
|
|
58
|
+
const usedBy = projectIndex.dependencyEdges
|
|
59
|
+
.filter((e) => e.to === symbol.qualifiedName || e.to === symbol.name)
|
|
60
|
+
.slice(0, 5)
|
|
61
|
+
.map((e) => e.from);
|
|
62
|
+
if (usedBy.length > 0) {
|
|
63
|
+
parts.push("", "## Used by", "", usedBy.map((s) => `- ${s}`).join("\n"));
|
|
64
|
+
}
|
|
65
|
+
const calls = projectIndex.dependencyEdges
|
|
66
|
+
.filter((e) => e.from === symbol.qualifiedName || e.from === symbol.name)
|
|
67
|
+
.slice(0, 5)
|
|
68
|
+
.map((e) => e.to);
|
|
69
|
+
if (calls.length > 0) {
|
|
70
|
+
parts.push("", "## Calls", "", calls.map((s) => `- ${s}`).join("\n"));
|
|
71
|
+
}
|
|
72
|
+
const cone = projectIndex.getDependencyCone(name, 1);
|
|
73
|
+
const typeRefs = cone.filter((s) => s.qualifiedName !== symbol.qualifiedName &&
|
|
74
|
+
(s.kind === "interface" || s.kind === "type"));
|
|
75
|
+
if (typeRefs.length > 0) {
|
|
76
|
+
parts.push("", "## Referenced Types", "");
|
|
77
|
+
for (const ref of typeRefs) {
|
|
78
|
+
parts.push(`### ${ref.qualifiedName}`, ref.signature, "");
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return parts.join("\n");
|
|
82
|
+
}
|
|
83
|
+
const sigParts = [
|
|
84
|
+
`# ${symbol.qualifiedName} (${symbol.kind})`,
|
|
85
|
+
`File: ${symbol.filePath}`,
|
|
86
|
+
`Lines: ${symbol.startLine}-${symbol.endLine}`,
|
|
87
|
+
"",
|
|
88
|
+
];
|
|
89
|
+
if (symbol.docComment) {
|
|
90
|
+
sigParts.push("## Description", "", symbol.docComment, "");
|
|
91
|
+
}
|
|
92
|
+
sigParts.push(symbol.signature);
|
|
93
|
+
return sigParts.join("\n");
|
|
94
|
+
},
|
|
95
|
+
};
|
|
96
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { createEditFileTool } from "./edit-file.js";
|
|
2
|
+
import { createFindReferencesTool } from "./find-references.js";
|
|
3
|
+
import { createGetDependenciesTool } from "./get-dependencies.js";
|
|
4
|
+
import { createListFilesTool } from "./list-files.js";
|
|
5
|
+
import { createReadFileTool } from "./read-file.js";
|
|
6
|
+
import { createReadSymbolTool } from "./read-symbol.js";
|
|
7
|
+
import { createRunCommandTool } from "./run-command.js";
|
|
8
|
+
import { createSearchCodeMapTool } from "./search-code-map.js";
|
|
9
|
+
import { createSearchTool } from "./search.js";
|
|
10
|
+
import { createWriteFileTool } from "./write-file.js";
|
|
11
|
+
function ensureInputObject(input) {
|
|
12
|
+
if (typeof input !== "object" || input === null || Array.isArray(input)) {
|
|
13
|
+
throw new Error("Tool input must be a JSON object.");
|
|
14
|
+
}
|
|
15
|
+
return input;
|
|
16
|
+
}
|
|
17
|
+
function toToolSchema(tool) {
|
|
18
|
+
return {
|
|
19
|
+
name: tool.name,
|
|
20
|
+
description: tool.description,
|
|
21
|
+
input_schema: tool.inputSchema,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
export class ToolRegistry {
|
|
25
|
+
toolsByName = new Map();
|
|
26
|
+
constructor(tools) {
|
|
27
|
+
for (const tool of tools) {
|
|
28
|
+
if (this.toolsByName.has(tool.name)) {
|
|
29
|
+
throw new Error(`Duplicate tool registration for "${tool.name}".`);
|
|
30
|
+
}
|
|
31
|
+
this.toolsByName.set(tool.name, tool);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
static createDefault(config, projectIndex) {
|
|
35
|
+
const tools = [
|
|
36
|
+
createReadFileTool(config),
|
|
37
|
+
createWriteFileTool(config, projectIndex),
|
|
38
|
+
createEditFileTool(config, projectIndex),
|
|
39
|
+
createSearchTool(config),
|
|
40
|
+
createListFilesTool(config),
|
|
41
|
+
createRunCommandTool(config),
|
|
42
|
+
];
|
|
43
|
+
if (projectIndex) {
|
|
44
|
+
tools.splice(1, 0, createReadSymbolTool(config, projectIndex));
|
|
45
|
+
tools.splice(2, 0, createFindReferencesTool(projectIndex));
|
|
46
|
+
tools.splice(3, 0, createGetDependenciesTool(projectIndex));
|
|
47
|
+
tools.splice(4, 0, createSearchCodeMapTool(projectIndex));
|
|
48
|
+
}
|
|
49
|
+
return new ToolRegistry(tools);
|
|
50
|
+
}
|
|
51
|
+
getToolSchemas() {
|
|
52
|
+
return [...this.toolsByName.values()].map(toToolSchema);
|
|
53
|
+
}
|
|
54
|
+
async execute(name, input) {
|
|
55
|
+
const tool = this.toolsByName.get(name);
|
|
56
|
+
if (!tool) {
|
|
57
|
+
return `Tool error: Unknown tool "${name}".`;
|
|
58
|
+
}
|
|
59
|
+
try {
|
|
60
|
+
const inputObject = ensureInputObject(input);
|
|
61
|
+
return await tool.execute(inputObject);
|
|
62
|
+
}
|
|
63
|
+
catch (error) {
|
|
64
|
+
const message = error instanceof Error ? error.message : "Unknown tool failure";
|
|
65
|
+
return `Tool error (${name}): ${message}`;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { isDestructiveCommand, validateCommand, } from "../safety/guardrails.js";
|
|
3
|
+
import { expectNonEmptyString, expectOptionalNumber } from "./helpers.js";
|
|
4
|
+
function executeBashCommand(command, cwd, timeoutMs) {
|
|
5
|
+
return new Promise((resolve, reject) => {
|
|
6
|
+
const child = spawn("bash", ["-lc", command], { cwd });
|
|
7
|
+
let stdout = "";
|
|
8
|
+
let stderr = "";
|
|
9
|
+
let timedOut = false;
|
|
10
|
+
const timeout = setTimeout(() => {
|
|
11
|
+
timedOut = true;
|
|
12
|
+
child.kill("SIGTERM");
|
|
13
|
+
}, timeoutMs);
|
|
14
|
+
child.stdout.on("data", (chunk) => {
|
|
15
|
+
stdout += chunk.toString();
|
|
16
|
+
});
|
|
17
|
+
child.stderr.on("data", (chunk) => {
|
|
18
|
+
stderr += chunk.toString();
|
|
19
|
+
});
|
|
20
|
+
child.on("error", (error) => {
|
|
21
|
+
clearTimeout(timeout);
|
|
22
|
+
reject(error);
|
|
23
|
+
});
|
|
24
|
+
child.on("close", (code) => {
|
|
25
|
+
clearTimeout(timeout);
|
|
26
|
+
resolve({
|
|
27
|
+
stdout: stdout.trimEnd(),
|
|
28
|
+
stderr: stderr.trimEnd(),
|
|
29
|
+
exitCode: code,
|
|
30
|
+
timedOut,
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
function parseTimeout(rawTimeout, defaultTimeoutMs) {
|
|
36
|
+
if (rawTimeout === undefined) {
|
|
37
|
+
return defaultTimeoutMs;
|
|
38
|
+
}
|
|
39
|
+
if (!Number.isInteger(rawTimeout) || rawTimeout <= 0) {
|
|
40
|
+
throw new Error(`"timeout" must be a positive integer in milliseconds.`);
|
|
41
|
+
}
|
|
42
|
+
return Math.min(rawTimeout, 10 * 60 * 1000);
|
|
43
|
+
}
|
|
44
|
+
export function createRunCommandTool(config) {
|
|
45
|
+
return {
|
|
46
|
+
name: "run_command",
|
|
47
|
+
description: "Run a shell command in the workspace and return stdout/stderr/exit code.",
|
|
48
|
+
inputSchema: {
|
|
49
|
+
type: "object",
|
|
50
|
+
properties: {
|
|
51
|
+
command: {
|
|
52
|
+
type: "string",
|
|
53
|
+
description: "Shell command to execute within workspace root.",
|
|
54
|
+
},
|
|
55
|
+
timeout: {
|
|
56
|
+
type: "number",
|
|
57
|
+
description: "Optional timeout in milliseconds. Defaults to configured command timeout.",
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
required: ["command"],
|
|
61
|
+
additionalProperties: false,
|
|
62
|
+
},
|
|
63
|
+
execute: async (input) => {
|
|
64
|
+
const command = expectNonEmptyString(input, "command");
|
|
65
|
+
const rawTimeout = expectOptionalNumber(input, "timeout");
|
|
66
|
+
const timeoutMs = parseTimeout(rawTimeout, config.commandTimeoutMs);
|
|
67
|
+
validateCommand(command, config.commandDenylist);
|
|
68
|
+
if (config.confirmDestructive && isDestructiveCommand(command)) {
|
|
69
|
+
throw new Error(`Command "${command}" appears destructive and requires explicit user confirmation.`);
|
|
70
|
+
}
|
|
71
|
+
const result = await executeBashCommand(command, config.workspaceRoot, timeoutMs);
|
|
72
|
+
const maxStdoutChars = 8_000;
|
|
73
|
+
const maxStderrChars = 4_000;
|
|
74
|
+
let stdoutOut = result.stdout.length > 0 ? result.stdout : "(empty)";
|
|
75
|
+
if (stdoutOut.length > maxStdoutChars) {
|
|
76
|
+
stdoutOut = `${stdoutOut.slice(0, maxStdoutChars)}\n[... truncated, ${result.stdout.length - maxStdoutChars} more chars ...]`;
|
|
77
|
+
}
|
|
78
|
+
let stderrOut = result.stderr.length > 0 ? result.stderr : "(empty)";
|
|
79
|
+
if (stderrOut.length > maxStderrChars) {
|
|
80
|
+
stderrOut = `${stderrOut.slice(0, maxStderrChars)}\n[... truncated, ${result.stderr.length - maxStderrChars} more chars ...]`;
|
|
81
|
+
}
|
|
82
|
+
return [
|
|
83
|
+
`exit_code: ${result.exitCode ?? "null"}`,
|
|
84
|
+
`timed_out: ${result.timedOut ? "true" : "false"}`,
|
|
85
|
+
"stdout:",
|
|
86
|
+
stdoutOut,
|
|
87
|
+
"stderr:",
|
|
88
|
+
stderrOut,
|
|
89
|
+
].join("\n");
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { expectNonEmptyString, expectOptionalNumber, } from "./helpers.js";
|
|
2
|
+
const DEFAULT_LIMIT = 30;
|
|
3
|
+
function matchesPattern(text, pattern) {
|
|
4
|
+
const lowerText = text.toLowerCase();
|
|
5
|
+
const lowerPattern = pattern.toLowerCase();
|
|
6
|
+
return lowerText.includes(lowerPattern);
|
|
7
|
+
}
|
|
8
|
+
export function createSearchCodeMapTool(projectIndex) {
|
|
9
|
+
return {
|
|
10
|
+
name: "search_code_map",
|
|
11
|
+
description: "Search the full project index for symbols by name or substring. " +
|
|
12
|
+
"Use when the code map is truncated and you need to find a symbol not listed. " +
|
|
13
|
+
"Returns qualified names and file paths; use read_symbol with the result.",
|
|
14
|
+
inputSchema: {
|
|
15
|
+
type: "object",
|
|
16
|
+
properties: {
|
|
17
|
+
pattern: {
|
|
18
|
+
type: "string",
|
|
19
|
+
description: "Substring to match against symbol name or qualified name (case-insensitive).",
|
|
20
|
+
},
|
|
21
|
+
kind: {
|
|
22
|
+
type: "string",
|
|
23
|
+
description: "Optional filter by symbol kind: function, class, interface, type, variable, method.",
|
|
24
|
+
},
|
|
25
|
+
limit: {
|
|
26
|
+
type: "number",
|
|
27
|
+
description: "Max results to return. Default 30.",
|
|
28
|
+
},
|
|
29
|
+
skip: {
|
|
30
|
+
type: "number",
|
|
31
|
+
description: "Number of results to skip (for pagination). Default 0.",
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
required: ["pattern"],
|
|
35
|
+
additionalProperties: false,
|
|
36
|
+
},
|
|
37
|
+
execute: async (input) => {
|
|
38
|
+
const pattern = expectNonEmptyString(input, "pattern");
|
|
39
|
+
const kindFilter = input.kind;
|
|
40
|
+
const kind = typeof kindFilter === "string" && kindFilter.trim().length > 0
|
|
41
|
+
? kindFilter.trim().toLowerCase()
|
|
42
|
+
: undefined;
|
|
43
|
+
const limit = Math.max(1, Math.min(100, expectOptionalNumber(input, "limit") ?? DEFAULT_LIMIT));
|
|
44
|
+
const skip = Math.max(0, expectOptionalNumber(input, "skip") ?? 0);
|
|
45
|
+
const symbols = [...projectIndex.symbols.values()];
|
|
46
|
+
const matches = symbols.filter((sym) => {
|
|
47
|
+
if (!matchesPattern(sym.name, pattern) && !matchesPattern(sym.qualifiedName, pattern)) {
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
if (kind && sym.kind !== kind) {
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
return true;
|
|
54
|
+
});
|
|
55
|
+
const shown = matches.slice(skip, skip + limit);
|
|
56
|
+
const lines = shown.map((s) => `- ${s.qualifiedName} (${s.kind}) — ${s.filePath}`);
|
|
57
|
+
const remaining = matches.length - skip - shown.length;
|
|
58
|
+
const footer = remaining > 0
|
|
59
|
+
? `\n... and ${remaining} more (use skip: ${skip + limit}, limit: ${limit} for next page)`
|
|
60
|
+
: "";
|
|
61
|
+
if (matches.length === 0) {
|
|
62
|
+
return `No symbols matching "${pattern}"${kind ? ` (kind: ${kind})` : ""}. Try a shorter or different pattern.`;
|
|
63
|
+
}
|
|
64
|
+
return [
|
|
65
|
+
`# Symbols matching "${pattern}" (${matches.length} total)`,
|
|
66
|
+
"",
|
|
67
|
+
...lines,
|
|
68
|
+
footer,
|
|
69
|
+
].join("\n");
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
}
|