@sean.holung/minicode 0.2.0 → 0.2.1

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.
Files changed (39) hide show
  1. package/README.md +44 -3
  2. package/dist/src/cli/args.js +65 -0
  3. package/dist/src/index.js +109 -26
  4. package/dist/src/session/session-store.js +82 -0
  5. package/dist/src/tools/find-references.js +1 -1
  6. package/dist/src/tools/get-dependencies.js +1 -1
  7. package/dist/src/tools/read-symbol.js +1 -2
  8. package/dist/src/tools/registry.js +26 -61
  9. package/dist/src/tools/search-code-map.js +1 -1
  10. package/dist/src/ui/cli-ink.js +91 -19
  11. package/dist/tests/agent.test.js +2 -3
  12. package/dist/tests/cli-args.test.js +73 -0
  13. package/dist/tests/cli-oneshot.integration.test.js +26 -0
  14. package/dist/tests/dependency-graph.test.js +12 -12
  15. package/dist/tests/file-tools.test.js +2 -3
  16. package/dist/tests/find-references.test.js +6 -6
  17. package/dist/tests/guardrails.test.js +1 -1
  18. package/dist/tests/indexer.test.js +9 -9
  19. package/dist/tests/model-client-openai.test.js +1 -1
  20. package/dist/tests/read-symbol.test.js +16 -17
  21. package/dist/tests/search-code-map.test.js +2 -2
  22. package/dist/tests/session-store.test.js +115 -0
  23. package/dist/tests/session.test.js +1 -1
  24. package/dist/tests/system-prompt.test.js +1 -1
  25. package/dist/tests/tool-registry.test.js +1 -1
  26. package/package.json +7 -2
  27. package/dist/src/agent/agent.js +0 -209
  28. package/dist/src/agent/types.js +0 -1
  29. package/dist/src/model/client.js +0 -374
  30. package/dist/src/prompt/system-prompt.js +0 -91
  31. package/dist/src/safety/guardrails.js +0 -55
  32. package/dist/src/session/session.js +0 -95
  33. package/dist/src/tools/edit-file.js +0 -73
  34. package/dist/src/tools/helpers.js +0 -42
  35. package/dist/src/tools/list-files.js +0 -63
  36. package/dist/src/tools/read-file.js +0 -79
  37. package/dist/src/tools/run-command.js +0 -92
  38. package/dist/src/tools/search.js +0 -153
  39. package/dist/src/tools/write-file.js +0 -44
@@ -1,91 +0,0 @@
1
- import { existsSync } from "node:fs";
2
- import path from "node:path";
3
- function detectProjectType(workspaceRoot) {
4
- const checks = [
5
- { file: "package.json", type: "Node.js / TypeScript" },
6
- { file: "pyproject.toml", type: "Python" },
7
- { file: "requirements.txt", type: "Python" },
8
- { file: "go.mod", type: "Go" },
9
- { file: "Cargo.toml", type: "Rust" },
10
- { file: "pom.xml", type: "Java (Maven)" },
11
- ];
12
- for (const check of checks) {
13
- if (existsSync(path.join(workspaceRoot, check.file))) {
14
- return check.type;
15
- }
16
- }
17
- return "Unknown";
18
- }
19
- function renderToolList(tools) {
20
- return tools
21
- .map((tool) => `- ${tool.name}: ${tool.description}`)
22
- .join("\n");
23
- }
24
- function hasTool(tools, name) {
25
- return tools.some((t) => t.name === name);
26
- }
27
- export function buildSystemPrompt(config, tools, codeMapResult) {
28
- const projectType = detectProjectType(config.workspaceRoot);
29
- const sections = [
30
- "[Identity]",
31
- "You are a coding agent. You help developers read, understand, and modify code in their projects.",
32
- "",
33
- "[Workspace Context]",
34
- `Working directory: ${config.workspaceRoot}`,
35
- `Project type: ${projectType}`,
36
- "",
37
- ];
38
- const hasSearchCodeMap = hasTool(tools, "search_code_map");
39
- if (codeMapResult && codeMapResult.text.length > 0) {
40
- sections.push("[Project Code Map]", codeMapResult.text, "");
41
- const truncated = codeMapResult.totalCount > 0 &&
42
- codeMapResult.shownCount < codeMapResult.totalCount;
43
- if (truncated) {
44
- const hint = hasSearchCodeMap
45
- ? " Use search_code_map to find symbols not listed above."
46
- : "";
47
- sections.push(`Showing ${codeMapResult.shownCount} of ${codeMapResult.totalCount} symbols.${hint}`, "");
48
- }
49
- }
50
- const hasReadSymbol = hasTool(tools, "read_symbol");
51
- const hasFindRefs = hasTool(tools, "find_references");
52
- const hasGetDeps = hasTool(tools, "get_dependencies");
53
- const hasSpecializedTools = hasReadSymbol || hasFindRefs || hasGetDeps || hasSearchCodeMap;
54
- const toolGuidelines = [
55
- "[Tool Usage Guidelines]",
56
- "- Always read a file before editing it.",
57
- "- Prefer edit_file over write_file for existing files.",
58
- "- Run tests or lint after code changes when applicable.",
59
- "- Default to using preferred tools when doing planning, code exploration, or investigation."
60
- ];
61
- if (hasSpecializedTools) {
62
- toolGuidelines.push("", "[Code exploration PREFERRED TOOLS — prefer these over read_file and search]", ...(hasReadSymbol
63
- ? [
64
- "- PREFER read_symbol over read_file for .ts/.tsx/.js/.jsx when you need a function or class. The code map lists all symbols; use read_symbol(name) for targeted reads — it returns only the relevant code and avoids bloating context.",
65
- "- Use read_file only for config files, small files, non-code files, or when the symbol name is unknown.",
66
- ]
67
- : []), ...(hasFindRefs
68
- ? [
69
- "- Use find_references to see what calls or uses a symbol — essential for understanding impact before changes.",
70
- ]
71
- : []), ...(hasGetDeps
72
- ? [
73
- "- Use get_dependencies to see what a symbol depends on — essential for understanding implementation and data flow.",
74
- ]
75
- : []), "- Use search only when you don't know the symbol name; once you find a symbol in the code map or search results, use read_symbol (not read_file) to read it.", "- When tracing code: use get_dependencies to go inward (what does X call?), find_references to go outward (what calls X?).", ...(hasSearchCodeMap
76
- ? [
77
- "- PREFER search_code_map over search. When the code map is truncated, use search_code_map to find symbols by name or substring — then use read_symbol with the result.",
78
- ]
79
- : []));
80
- }
81
- else {
82
- toolGuidelines.push("", "- Use read_file with offset and limit for large files to read only needed portions.", "- Use search to find relevant code before making changes.");
83
- }
84
- sections.push("[Tool Descriptions]", "You have the following tools available:", renderToolList(tools), "", ...toolGuidelines, "", "[Code Reading Strategy]", "- Start with entry points (e.g. index.ts, main) and follow the flow.", ...(hasSpecializedTools
85
- ? [
86
- "- Use find_references to see who uses a symbol; use get_dependencies to see what it calls.",
87
- "- Trace usage outward (find_references) or implementation inward (get_dependencies) as needed.",
88
- ]
89
- : ["- Use search to locate relevant code, then read_file to inspect it."]), "", "[Termination Policy]", "- When the user asks you to do something (edit code, search, run commands, etc.), you MUST use the appropriate tools first. Do not conclude until you have actually performed the work.", "- When the task is complete, respond with a concise summary of what you changed.", "- If you cannot complete the task, explain what is blocking you.", "- Do not respond with empty text. Always provide a summary or explanation.", "- Do not continue exploring once the task is done.", "", "[Safety Rules]", "- Never modify files outside the workspace directory.", "- Never run destructive commands without explicit user confirmation.", "- Ask for clarification if user intent is ambiguous.", "- When asked to perform a task, communicate your execution plan to the user and ask for their confirmation before proceeding with any modifications.");
90
- return sections.join("\n");
91
- }
@@ -1,55 +0,0 @@
1
- import path from "node:path";
2
- const DESTRUCTIVE_COMMAND_PATTERNS = [
3
- /\brm\s+-rf\b/i,
4
- /\bmv\b.+\s+\/dev\/null\b/i,
5
- /\bgit\s+reset\s+--hard\b/i,
6
- /\bgit\s+clean\s+-fdx?\b/i,
7
- ];
8
- export function normalizeWorkspaceRoot(workspaceRoot) {
9
- return path.resolve(workspaceRoot);
10
- }
11
- export function resolveWorkspacePath(requestedPath, workspaceRoot) {
12
- const normalizedRoot = normalizeWorkspaceRoot(workspaceRoot);
13
- const absolutePath = path.resolve(normalizedRoot, requestedPath);
14
- if (!isWithinWorkspacePath(absolutePath, normalizedRoot)) {
15
- throw new Error(`Path "${requestedPath}" resolves outside workspace root "${normalizedRoot}".`);
16
- }
17
- return absolutePath;
18
- }
19
- export function isWithinWorkspacePath(absolutePath, workspaceRoot) {
20
- const normalizedRoot = normalizeWorkspaceRoot(workspaceRoot);
21
- const relative = path.relative(normalizedRoot, absolutePath);
22
- if (relative === "") {
23
- return true;
24
- }
25
- return !relative.startsWith("..") && !path.isAbsolute(relative);
26
- }
27
- export function validatePath(requestedPath, workspaceRoot) {
28
- try {
29
- resolveWorkspacePath(requestedPath, workspaceRoot);
30
- return true;
31
- }
32
- catch {
33
- return false;
34
- }
35
- }
36
- export function validateCommand(command, denylist) {
37
- for (const pattern of denylist) {
38
- if (pattern.test(command)) {
39
- throw new Error(`Command "${command}" blocked by safety denylist (${pattern}).`);
40
- }
41
- }
42
- }
43
- export function isDestructiveCommand(command) {
44
- return DESTRUCTIVE_COMMAND_PATTERNS.some((pattern) => pattern.test(command));
45
- }
46
- export function ensureStepWithinLimit(step, maxSteps) {
47
- if (step >= maxSteps) {
48
- throw new Error(`Reached maximum step limit (${maxSteps}). Stopping tool loop.`);
49
- }
50
- }
51
- export function validateFileReadSize(actualSizeBytes, maxFileSizeBytes) {
52
- if (actualSizeBytes > maxFileSizeBytes) {
53
- throw new Error(`File too large to read (${actualSizeBytes} bytes). Limit is ${maxFileSizeBytes} bytes.`);
54
- }
55
- }
@@ -1,95 +0,0 @@
1
- import { randomUUID } from "node:crypto";
2
- function estimateMessageTokens(message) {
3
- if (message.role === "tool") {
4
- return Math.ceil((message.toolName.length + message.content.length) / 4);
5
- }
6
- const toolCallTokens = message.role === "assistant" && message.toolCalls?.length
7
- ? Math.ceil(JSON.stringify(message.toolCalls).length / 4)
8
- : 0;
9
- return Math.ceil(message.content.length / 4) + toolCallTokens;
10
- }
11
- export class Session {
12
- id;
13
- createdAt;
14
- messages;
15
- constructor(id = randomUUID()) {
16
- this.id = id;
17
- this.createdAt = new Date();
18
- this.messages = [];
19
- }
20
- addMessage(message) {
21
- this.messages.push(message);
22
- }
23
- getMessages() {
24
- return [...this.messages];
25
- }
26
- getTokenEstimate() {
27
- return this.messages.reduce((total, message) => total + estimateMessageTokens(message), 0);
28
- }
29
- trim(maxTokens, keepRecentMessages) {
30
- if (keepRecentMessages < 0) {
31
- return;
32
- }
33
- while (this.getTokenEstimate() > maxTokens &&
34
- this.messages.length > keepRecentMessages) {
35
- const protectedStart = this.getProtectedStart(keepRecentMessages);
36
- if (protectedStart <= 0) {
37
- return;
38
- }
39
- const removed = this.removeOldestChunk(protectedStart);
40
- if (!removed) {
41
- return;
42
- }
43
- }
44
- }
45
- getProtectedStart(keepRecentMessages) {
46
- let protectedStart = Math.max(0, this.messages.length - keepRecentMessages);
47
- const boundaryMessage = this.messages[protectedStart];
48
- if (!boundaryMessage || boundaryMessage.role !== "tool") {
49
- return protectedStart;
50
- }
51
- while (protectedStart > 0 &&
52
- this.messages[protectedStart - 1]?.role === "tool") {
53
- protectedStart -= 1;
54
- }
55
- const potentialToolCallMessage = this.messages[protectedStart - 1];
56
- if (potentialToolCallMessage?.role === "assistant" &&
57
- potentialToolCallMessage.toolCalls?.length) {
58
- protectedStart -= 1;
59
- }
60
- return protectedStart;
61
- }
62
- removeOldestChunk(removableCount) {
63
- if (removableCount <= 0 || this.messages.length === 0) {
64
- return false;
65
- }
66
- const first = this.messages[0];
67
- if (!first) {
68
- return false;
69
- }
70
- if (first.role === "assistant" && first.toolCalls?.length) {
71
- let removeCount = 1;
72
- while (this.messages[removeCount]?.role === "tool") {
73
- removeCount += 1;
74
- }
75
- if (removeCount > removableCount) {
76
- return false;
77
- }
78
- this.messages.splice(0, removeCount);
79
- return true;
80
- }
81
- if (first.role === "tool") {
82
- let removeCount = 1;
83
- while (this.messages[removeCount]?.role === "tool") {
84
- removeCount += 1;
85
- }
86
- if (removeCount > removableCount) {
87
- return false;
88
- }
89
- this.messages.splice(0, removeCount);
90
- return true;
91
- }
92
- this.messages.splice(0, 1);
93
- return true;
94
- }
95
- }
@@ -1,73 +0,0 @@
1
- import path from "node:path";
2
- import { readFile, writeFile } from "node:fs/promises";
3
- import { resolveWorkspacePath } from "../safety/guardrails.js";
4
- import { expectNonEmptyString } from "./helpers.js";
5
- function expectString(input, key) {
6
- const value = input[key];
7
- if (typeof value !== "string") {
8
- throw new Error(`Input "${key}" must be a string.`);
9
- }
10
- return value;
11
- }
12
- function countOccurrences(haystack, needle) {
13
- if (needle.length === 0) {
14
- return 0;
15
- }
16
- let count = 0;
17
- let index = 0;
18
- while (true) {
19
- const found = haystack.indexOf(needle, index);
20
- if (found === -1) {
21
- break;
22
- }
23
- count += 1;
24
- index = found + needle.length;
25
- }
26
- return count;
27
- }
28
- export function createEditFileTool(config, projectIndex) {
29
- return {
30
- name: "edit_file",
31
- description: "Replace exactly one instance of old_string with new_string in a file.",
32
- inputSchema: {
33
- type: "object",
34
- properties: {
35
- path: {
36
- type: "string",
37
- description: "Path to file relative to workspace root.",
38
- },
39
- old_string: {
40
- type: "string",
41
- description: "Exact text to replace (must match once).",
42
- },
43
- new_string: {
44
- type: "string",
45
- description: "Replacement text.",
46
- },
47
- },
48
- required: ["path", "old_string", "new_string"],
49
- additionalProperties: false,
50
- },
51
- execute: async (input) => {
52
- const requestedPath = expectNonEmptyString(input, "path");
53
- const oldString = expectNonEmptyString(input, "old_string");
54
- const newString = expectString(input, "new_string");
55
- const filePath = resolveWorkspacePath(requestedPath, config.workspaceRoot);
56
- const current = await readFile(filePath, "utf8");
57
- const occurrences = countOccurrences(current, oldString);
58
- if (occurrences === 0) {
59
- throw new Error(`old_string was not found in "${requestedPath}".`);
60
- }
61
- if (occurrences > 1) {
62
- throw new Error(`old_string matched ${occurrences} times in "${requestedPath}". It must be unique.`);
63
- }
64
- const updated = current.replace(oldString, newString);
65
- await writeFile(filePath, updated, "utf8");
66
- if (projectIndex) {
67
- const relPath = path.relative(config.workspaceRoot, filePath);
68
- projectIndex.reindexFile(relPath, updated);
69
- }
70
- return `Updated "${requestedPath}" successfully.`;
71
- },
72
- };
73
- }
@@ -1,42 +0,0 @@
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
- }
@@ -1,63 +0,0 @@
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
- }
@@ -1,79 +0,0 @@
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
- }
@@ -1,92 +0,0 @@
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
- }