Initial commit

This commit is contained in:
Jordan
2026-04-01 23:16:45 +01:00
commit 91cfdaee72
200 changed files with 25589 additions and 0 deletions

View File

@@ -0,0 +1,46 @@
import { z } from "zod";
import { handleToolError, validateRequired } from "../helpers/errorHandler.js";
import { getCurrentProjectInfo, callLocalFileEndpoint, buildLocalFileErrorResponse } from "./helpers.js";
export function registerAcaiDeleteTool(server) {
server.tool(
"acai-delete",
"Delete a file inside the project. Destructive operation.",
{
file_path: z.string().describe("Path relative to the project root"),
expected_sha256: z.string().optional().describe("Optional safety check before deletion"),
},
{ readOnlyHint: false, destructiveHint: true },
async ({ file_path, expected_sha256 }) => {
try {
const validationError = validateRequired({ file_path }, ["file_path"], "acai-delete");
if (validationError) return validationError;
const { projectSlug, projectDir } = getCurrentProjectInfo();
const result = await callLocalFileEndpoint("POST", "/api/files/delete", {
project: projectSlug,
projectDir: projectDir,
relativePath: file_path,
expectedSha256: expected_sha256 || "",
});
if (!result.data?.success) {
return buildLocalFileErrorResponse("acai-delete", result, { file_path });
}
const data = result.data;
return {
content: [{
type: "text",
text: JSON.stringify({
success: true,
file_path: data.filePath,
deleted: data.deleted,
}, null, 2),
}],
};
} catch (error) {
return handleToolError(error, "acai-delete", { file_path });
}
}
);
}

View File

@@ -0,0 +1,51 @@
import { z } from "zod";
import { handleToolError, validateRequired } from "../helpers/errorHandler.js";
import { getCurrentProjectInfo, callLocalFileEndpoint, buildLocalFileErrorResponse } from "./helpers.js";
export function registerAcaiGlobTool(server) {
server.tool(
"acai-glob",
"Find project files by path pattern. Returns compact relative paths only.",
{
pattern: z.string().describe("Glob-style pattern relative to the project root, e.g. 'template/estandar/modulos/**/index-base.tpl'"),
base_path: z.string().optional().describe("Optional base directory relative to the project root"),
limit: z.number().int().positive().max(200).optional().describe("Maximum number of paths to return"),
},
{ readOnlyHint: true, destructiveHint: false },
async ({ pattern, base_path, limit }) => {
try {
const validationError = validateRequired({ pattern }, ["pattern"], "acai-glob");
if (validationError) return validationError;
const { projectSlug, projectDir } = getCurrentProjectInfo();
const result = await callLocalFileEndpoint("GET", "/api/files/glob", null, {
project: projectSlug,
projectDir,
pattern,
basePath: base_path,
limit,
});
if (!result.data?.success) {
return buildLocalFileErrorResponse("acai-glob", result, { pattern, base_path });
}
const data = result.data;
return {
content: [{
type: "text",
text: JSON.stringify({
success: true,
pattern: data.pattern,
base_path: data.basePath,
matches: data.matches,
total_matches: data.totalMatches,
truncated: data.truncated,
}, null, 2),
}],
};
} catch (error) {
return handleToolError(error, "acai-glob", { pattern, base_path });
}
}
);
}

View File

@@ -0,0 +1,65 @@
import { z } from "zod";
import { handleToolError, validateRequired } from "../helpers/errorHandler.js";
import { getCurrentProjectInfo, callLocalFileEndpoint, buildLocalFileErrorResponse } from "./helpers.js";
export function registerAcaiGrepTool(server) {
server.tool(
"acai-grep",
"Search text inside project files with compact line-level results. Supports optional glob filtering.",
{
pattern: z.string().describe("Text or regex pattern to search for"),
base_path: z.string().optional().describe("Optional base directory relative to the project root"),
glob: z.string().optional().describe("Optional file path glob filter, e.g. '**/index-base.tpl'"),
limit: z.number().int().positive().max(100).optional().describe("Maximum number of matches to return"),
case_sensitive: z.boolean().optional().describe("Whether matching should be case-sensitive"),
regex: z.boolean().optional().describe("Treat pattern as a regular expression"),
},
{ readOnlyHint: true, destructiveHint: false },
async ({ pattern, base_path, glob, limit, case_sensitive, regex }) => {
try {
const validationError = validateRequired({ pattern }, ["pattern"], "acai-grep");
if (validationError) return validationError;
const { projectSlug, projectDir } = getCurrentProjectInfo();
const result = await callLocalFileEndpoint("POST", "/api/files/grep", {
project: projectSlug,
projectDir,
pattern,
basePath: base_path,
glob,
limit,
caseSensitive: case_sensitive,
regex,
});
if (!result.data?.success) {
return buildLocalFileErrorResponse("acai-grep", result, { pattern, base_path, glob });
}
const data = result.data;
return {
content: [{
type: "text",
text: JSON.stringify({
success: true,
pattern: data.pattern,
base_path: data.basePath,
glob: data.glob,
regex: data.regex,
case_sensitive: data.caseSensitive,
files_scanned: data.filesScanned,
matches: data.matches.map((match) => ({
file_path: match.filePath,
line: match.line,
match_preview: match.matchPreview,
})),
total_matches: data.totalMatches,
truncated: data.truncated,
}, null, 2),
}],
};
} catch (error) {
return handleToolError(error, "acai-grep", { pattern, base_path, glob });
}
}
);
}

View File

@@ -0,0 +1,60 @@
import axios from "axios";
import path from "path";
import { LOCAL_SERVER_URL, getLocalServerHeaders } from "../../config/index.js";
export function getCurrentProjectInfo() {
const projectDir = process.env.ACAI_PROJECT_DIR || "";
if (!projectDir) {
throw new Error("ACAI_PROJECT_DIR not set");
}
return {
projectDir,
projectSlug: path.basename(path.resolve(projectDir)),
};
}
export async function callLocalFileEndpoint(method, endpoint, payload = null, query = null) {
const headers = getLocalServerHeaders();
if (method === "GET") {
const response = await axios.get(`${LOCAL_SERVER_URL}${endpoint}`, {
params: query || undefined,
headers,
timeout: 30000,
validateStatus: (status) => status < 600,
});
return { status: response.status, data: response.data };
}
const response = await axios.post(`${LOCAL_SERVER_URL}${endpoint}`, payload || {}, {
headers,
timeout: 30000,
validateStatus: (status) => status < 600,
});
return { status: response.status, data: response.data };
}
export function buildLocalFileErrorResponse(toolName, result, extra = {}) {
const payload = result?.data || {};
const message =
payload.message ||
payload.error ||
payload.compileError ||
`HTTP ${result?.status || 500}`;
return {
content: [{
type: "text",
text: JSON.stringify({
success: false,
error: {
code: `HTTP_${result.status}`,
message,
context: toolName,
...extra,
...payload,
},
}, null, 2),
}],
isError: true,
};
}

View File

@@ -0,0 +1,15 @@
import { registerAcaiViewTool } from "./view.js";
import { registerAcaiWriteTool } from "./write.js";
import { registerAcaiLineReplaceTool } from "./lineReplace.js";
import { registerAcaiDeleteTool } from "./delete.js";
import { registerAcaiGlobTool } from "./glob.js";
import { registerAcaiGrepTool } from "./grep.js";
export function registerFileTools(server) {
registerAcaiViewTool(server);
registerAcaiGlobTool(server);
registerAcaiGrepTool(server);
registerAcaiWriteTool(server);
registerAcaiLineReplaceTool(server);
registerAcaiDeleteTool(server);
}

View File

@@ -0,0 +1,67 @@
import { z } from "zod";
import { handleToolError, validateRequired } from "../helpers/errorHandler.js";
import { getCurrentProjectInfo, callLocalFileEndpoint, buildLocalFileErrorResponse } from "./helpers.js";
export function registerAcaiLineReplaceTool(server) {
server.tool(
"acai-line-replace",
"Replace a validated line block in an existing file. Preferred for editing existing files while minimizing token usage.",
{
file_path: z.string().describe("Path relative to the project root"),
first_replaced_line: z.number().int().positive().describe("1-indexed first line of the target block"),
last_replaced_line: z.number().int().positive().describe("1-indexed last line of the target block"),
search: z.string().describe("Expected current content for validation. Must match the selected block exactly."),
replace: z.string().describe("Replacement content for the selected block"),
expected_sha256: z.string().optional().describe("Optional full-file sha check from a prior acai-view"),
},
{ readOnlyHint: false, destructiveHint: false },
async ({ file_path, first_replaced_line, last_replaced_line, search, replace, expected_sha256 }) => {
try {
const validationError = validateRequired(
{ file_path, first_replaced_line, last_replaced_line, search },
["file_path", "first_replaced_line", "last_replaced_line", "search"],
"acai-line-replace"
);
if (validationError) return validationError;
const { projectSlug, projectDir } = getCurrentProjectInfo();
const result = await callLocalFileEndpoint("POST", "/api/files/line-replace", {
project: projectSlug,
projectDir: projectDir,
relativePath: file_path,
firstLine: first_replaced_line,
lastLine: last_replaced_line,
search,
replace,
expectedSha256: expected_sha256 || "",
});
if (!result.data?.success) {
return buildLocalFileErrorResponse("acai-line-replace", result, {
file_path,
first_replaced_line,
last_replaced_line,
});
}
const data = result.data;
return {
content: [{
type: "text",
text: JSON.stringify({
success: true,
file_path: data.filePath,
first_replaced_line: data.firstLine,
last_replaced_line: data.lastLine,
new_sha256: data.newSha256,
changed: data.changed,
compiled: data.compiled || false,
compile_result: data.compileResult || null,
}, null, 2),
}],
};
} catch (error) {
return handleToolError(error, "acai-line-replace", { file_path, first_replaced_line, last_replaced_line });
}
}
);
}

View File

@@ -0,0 +1,60 @@
import { z } from "zod";
import { handleToolError, validateRequired } from "../helpers/errorHandler.js";
import { getCurrentProjectInfo, callLocalFileEndpoint, buildLocalFileErrorResponse } from "./helpers.js";
export function registerAcaiViewTool(server) {
server.tool(
"acai-view",
"Read a project file with optional line ranges. Returns only the requested slice plus compact metadata for safe incremental edits.",
{
file_path: z.string().describe("Path relative to the project root"),
start_line: z.number().int().positive().optional().describe("1-indexed start line. Defaults to 1."),
end_line: z.number().int().positive().optional().describe("1-indexed end line. If omitted, the server returns a bounded chunk."),
},
{ readOnlyHint: true, destructiveHint: false },
async ({ file_path, start_line, end_line }) => {
try {
const validationError = validateRequired({ file_path }, ["file_path"], "acai-view");
if (validationError) return validationError;
if (end_line !== undefined && start_line !== undefined && end_line < start_line) {
return {
content: [{ type: "text", text: JSON.stringify({ success: false, error: "end_line must be greater than or equal to start_line" }, null, 2) }],
isError: true,
};
}
const { projectSlug } = getCurrentProjectInfo();
const result = await callLocalFileEndpoint("GET", "/api/files/read", null, {
project: projectSlug,
projectDir: getCurrentProjectInfo().projectDir,
relativePath: file_path,
startLine: start_line,
endLine: end_line,
});
if (!result.data?.success) {
return buildLocalFileErrorResponse("acai-view", result, { file_path });
}
const data = result.data;
return {
content: [{
type: "text",
text: JSON.stringify({
success: true,
file_path: data.filePath,
start_line: data.startLine,
end_line: data.endLine,
total_lines: data.totalLines,
sha256: data.sha256,
content: data.content,
truncated: data.truncated,
}, null, 2),
}],
};
} catch (error) {
return handleToolError(error, "acai-view", { file_path });
}
}
);
}

View File

@@ -0,0 +1,59 @@
import { z } from "zod";
import { handleToolError, validateRequired } from "../helpers/errorHandler.js";
import { getCurrentProjectInfo, callLocalFileEndpoint, buildLocalFileErrorResponse } from "./helpers.js";
export function registerAcaiWriteTool(server) {
server.tool(
"acai-write",
`Write a full file inside the project. Use for new files or full rewrites. Prefer acai-line-replace for targeted edits.
Before writing, check the matching documentation for the file type:
- If the file is an index-base template (\`index-base.tpl\` or \`index-base.html\`), make sure you have read \`docs/module-creation-guide.md\` and \`docs/builder-fields.md\`
- If the file is a \`.js\` or \`.css\`, make sure you have read \`docs/css-js-conventions.md\`
- If the file is a module or global hook PHP file, make sure you have read \`docs/hooks-and-api.md\``,
{
file_path: z.string().describe("Path relative to the project root"),
content: z.string().describe("Full file content to write"),
mode: z.enum(["create_or_overwrite", "create_only"]).optional().default("create_or_overwrite").describe("Write mode"),
expected_sha256: z.string().optional().describe("Optional optimistic concurrency check from a prior acai-view"),
},
{ readOnlyHint: false, destructiveHint: false },
async ({ file_path, content, mode = "create_or_overwrite", expected_sha256 }) => {
try {
const validationError = validateRequired({ file_path }, ["file_path"], "acai-write");
if (validationError) return validationError;
const { projectSlug, projectDir } = getCurrentProjectInfo();
const result = await callLocalFileEndpoint("POST", "/api/files/write", {
project: projectSlug,
projectDir: projectDir,
relativePath: file_path,
content,
mode,
expectedSha256: expected_sha256 || "",
});
if (!result.data?.success) {
return buildLocalFileErrorResponse("acai-write", result, { file_path });
}
const data = result.data;
return {
content: [{
type: "text",
text: JSON.stringify({
success: true,
file_path: data.filePath,
created: data.created,
overwritten: data.overwritten,
sha256: data.sha256,
compiled: data.compiled || false,
compile_result: data.compileResult || null,
}, null, 2),
}],
};
} catch (error) {
return handleToolError(error, "acai-write", { file_path });
}
}
);
}