tina4-nodejs 3.11.14 → 3.11.16

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.
@@ -989,6 +989,349 @@ export function registerDevTools(server: McpServer): void {
989
989
  "Framework version, Node.js version, project info",
990
990
  schemaFromParams([]),
991
991
  );
992
+
993
+ // ── Plan tools ──────────────────────────────────────────────
994
+ //
995
+ // Ported from Python's tina4_python.mcp.tools — names match exactly.
996
+ // The Plan storage format is byte-for-byte compatible across frameworks.
997
+
998
+ const loadPlan = () => require("./plan.js").Plan as typeof import("./plan.js").Plan;
999
+ const loadIndex = () =>
1000
+ require("./projectIndex.js").ProjectIndex as typeof import("./projectIndex.js").ProjectIndex;
1001
+
1002
+ server.registerTool(
1003
+ "plan_current",
1004
+ () => loadPlan().current(),
1005
+ "The active plan: title, steps (done/not), next step, progress",
1006
+ schemaFromParams([]),
1007
+ );
1008
+
1009
+ server.registerTool(
1010
+ "plan_list",
1011
+ () => loadPlan().listPlans(),
1012
+ "All plans in plan/ with progress and which one is active",
1013
+ schemaFromParams([]),
1014
+ );
1015
+
1016
+ server.registerTool(
1017
+ "plan_create",
1018
+ (args) =>
1019
+ loadPlan().create(
1020
+ (args.title as string) || "",
1021
+ (args.goal as string) || "",
1022
+ (args.steps as string[]) || [],
1023
+ args.make_current !== false,
1024
+ ),
1025
+ "Create a new markdown plan in plan/ and make it active",
1026
+ schemaFromParams([
1027
+ { name: "title", type: "string" },
1028
+ { name: "goal", type: "string", default: "" },
1029
+ { name: "steps", type: "array", default: [] },
1030
+ { name: "make_current", type: "boolean", default: true },
1031
+ ]),
1032
+ );
1033
+
1034
+ server.registerTool(
1035
+ "plan_switch_to",
1036
+ (args) => loadPlan().setCurrent((args.name as string) || ""),
1037
+ "Make a different plan the active one",
1038
+ schemaFromParams([{ name: "name", type: "string" }]),
1039
+ );
1040
+
1041
+ server.registerTool(
1042
+ "plan_complete_step",
1043
+ (args) => loadPlan().completeStep((args.index as number) ?? -1),
1044
+ "Tick a step as done (call the moment the step finishes)",
1045
+ schemaFromParams([{ name: "index", type: "integer" }]),
1046
+ );
1047
+
1048
+ server.registerTool(
1049
+ "plan_add_step",
1050
+ (args) => loadPlan().addStep((args.text as string) || ""),
1051
+ "Append a new unchecked step to the current plan",
1052
+ schemaFromParams([{ name: "text", type: "string" }]),
1053
+ );
1054
+
1055
+ server.registerTool(
1056
+ "plan_note",
1057
+ (args) => loadPlan().appendNote((args.text as string) || ""),
1058
+ "Append a timestamped note/breadcrumb to the current plan",
1059
+ schemaFromParams([{ name: "text", type: "string" }]),
1060
+ );
1061
+
1062
+ server.registerTool(
1063
+ "plan_archive",
1064
+ (args) => loadPlan().archive((args.name as string) || ""),
1065
+ "Move a finished plan to plan/done/ and clear the current pointer",
1066
+ schemaFromParams([{ name: "name", type: "string", default: "" }]),
1067
+ );
1068
+
1069
+ server.registerTool(
1070
+ "plan_read",
1071
+ (args) => loadPlan().read((args.name as string) || ""),
1072
+ "Full structured view of any plan by filename",
1073
+ schemaFromParams([{ name: "name", type: "string" }]),
1074
+ );
1075
+
1076
+ server.registerTool(
1077
+ "plan_flesh",
1078
+ async (args) =>
1079
+ await loadPlan().flesh((args.name as string) || "", (args.prompt as string) || ""),
1080
+ "Auto-generate concrete build steps via the AI backend and append them to an existing plan",
1081
+ schemaFromParams([
1082
+ { name: "name", type: "string", default: "" },
1083
+ { name: "prompt", type: "string", default: "" },
1084
+ ]),
1085
+ );
1086
+
1087
+ // ── Project-index tools ─────────────────────────────────────
1088
+
1089
+ server.registerTool(
1090
+ "index_rebuild",
1091
+ () => loadIndex().refresh(),
1092
+ "Refresh the persistent project index (lazy, mtime-based)",
1093
+ schemaFromParams([]),
1094
+ );
1095
+
1096
+ server.registerTool(
1097
+ "index_search",
1098
+ (args) => loadIndex().search((args.query as string) || "", (args.limit as number) || 20),
1099
+ "Find files by path, symbol, route, or summary — use FIRST for 'where is X'",
1100
+ schemaFromParams([
1101
+ { name: "query", type: "string" },
1102
+ { name: "limit", type: "integer", default: 20 },
1103
+ ]),
1104
+ );
1105
+
1106
+ server.registerTool(
1107
+ "index_file",
1108
+ (args) => loadIndex().fileEntry((args.path as string) || ""),
1109
+ "Full index entry for one file: symbols, routes, imports",
1110
+ schemaFromParams([{ name: "path", type: "string" }]),
1111
+ );
1112
+
1113
+ server.registerTool(
1114
+ "index_overview",
1115
+ () => loadIndex().overview(),
1116
+ "Project shape: files by language, routes, models, recent edits",
1117
+ schemaFromParams([]),
1118
+ );
1119
+
1120
+ server.registerTool(
1121
+ "project_overview",
1122
+ () => {
1123
+ const out: Record<string, unknown> = {};
1124
+ try { out.index = loadIndex().overview(); } catch (e) { out.index = { error: (e as Error).message }; }
1125
+ try { out.plans = loadPlan().listPlans(); } catch (e) { out.plans = { error: (e as Error).message }; }
1126
+ try { out.current_plan = loadPlan().current(); } catch (e) { out.current_plan = { error: (e as Error).message }; }
1127
+ try {
1128
+ const db = (globalThis as any).__tina4_db;
1129
+ out.tables = db?.getTables?.() ?? [];
1130
+ } catch (e) { out.tables = { error: (e as Error).message }; }
1131
+ return out;
1132
+ },
1133
+ "One-shot snapshot: index overview, plans, current plan, tables",
1134
+ schemaFromParams([]),
1135
+ );
1136
+
1137
+ // ── file_patch (targeted edit) ──────────────────────────────
1138
+
1139
+ server.registerTool(
1140
+ "file_patch",
1141
+ (args) => {
1142
+ try {
1143
+ const rel = (args.path as string) || "";
1144
+ const oldStr = (args.old_string as string) || "";
1145
+ const newStr = (args.new_string as string) || "";
1146
+ const count = (args.count as number) || 1;
1147
+ const projectRoot = path.resolve(process.cwd());
1148
+ const resolved = path.resolve(projectRoot, rel);
1149
+ if (!resolved.startsWith(projectRoot)) {
1150
+ return { error: `Path escapes project directory: ${rel}` };
1151
+ }
1152
+ if (!fs.existsSync(resolved) || !fs.statSync(resolved).isFile()) {
1153
+ return { error: `File not found: ${rel}` };
1154
+ }
1155
+ const original = fs.readFileSync(resolved, "utf-8");
1156
+ let occurrences = 0;
1157
+ let idx = -1;
1158
+ while ((idx = original.indexOf(oldStr, idx + 1)) !== -1) occurrences++;
1159
+ if (occurrences === 0) return { error: `old_string not found in ${rel}` };
1160
+ if (occurrences !== count) {
1161
+ return {
1162
+ error:
1163
+ `old_string appears ${occurrences} times, expected ${count}. ` +
1164
+ "Expand old_string to make it unique, or set count explicitly.",
1165
+ };
1166
+ }
1167
+ let updated = original;
1168
+ for (let i = 0; i < count; i++) updated = updated.replace(oldStr, newStr);
1169
+ fs.writeFileSync(resolved, updated, "utf-8");
1170
+ try { loadPlan().recordAction("patched", rel); } catch { /* best-effort */ }
1171
+ return {
1172
+ patched: rel,
1173
+ replacements: count,
1174
+ bytes: Buffer.byteLength(updated, "utf-8"),
1175
+ };
1176
+ } catch (e) {
1177
+ return { error: (e as Error).message };
1178
+ }
1179
+ },
1180
+ "Targeted edit: replace old_string with new_string in a file (must match exactly `count` times)",
1181
+ schemaFromParams([
1182
+ { name: "path", type: "string" },
1183
+ { name: "old_string", type: "string" },
1184
+ { name: "new_string", type: "string" },
1185
+ { name: "count", type: "integer", default: 1 },
1186
+ ]),
1187
+ );
1188
+
1189
+ // ── docs_list / docs_search / docs_section ──────────────────
1190
+
1191
+ const frameworkDocPaths = (): string[] => {
1192
+ const projectRoot = path.resolve(process.cwd());
1193
+ const candidates = [
1194
+ path.join(projectRoot, "CLAUDE.md"),
1195
+ path.join(projectRoot, "AGENTS.md"),
1196
+ path.join(projectRoot, "CONVENTIONS.md"),
1197
+ path.join(projectRoot, "README.md"),
1198
+ ];
1199
+ return candidates.filter((p) => { try { return fs.statSync(p).isFile(); } catch { return false; } });
1200
+ };
1201
+
1202
+ server.registerTool(
1203
+ "docs_list",
1204
+ () => frameworkDocPaths().map((p) => ({ name: path.basename(p), bytes: fs.statSync(p).size })),
1205
+ "List framework documentation files available for lookup",
1206
+ schemaFromParams([]),
1207
+ );
1208
+
1209
+ server.registerTool(
1210
+ "docs_search",
1211
+ (args) => {
1212
+ const query = (args.query as string) || "";
1213
+ const limit = (args.limit as number) || 5;
1214
+ const contextLines = (args.context_lines as number) || 4;
1215
+ if (!query || query.length < 2) return { error: "query must be at least 2 characters" };
1216
+ const needle = query.toLowerCase();
1217
+ const hits: Array<{ file: string; line: number; score: number; snippet: string }> = [];
1218
+ for (const p of frameworkDocPaths()) {
1219
+ let lines: string[];
1220
+ try { lines = fs.readFileSync(p, "utf-8").split(/\r?\n/); } catch { continue; }
1221
+ for (let i = 0; i < lines.length; i++) {
1222
+ if (lines[i].toLowerCase().includes(needle)) {
1223
+ const start = Math.max(0, i - contextLines);
1224
+ const end = Math.min(lines.length, i + contextLines + 1);
1225
+ const snippet = lines.slice(start, end).join("\n");
1226
+ let score = 1;
1227
+ if (lines[i].includes(query)) score += 1;
1228
+ if (lines[i].trimStart().startsWith("#")) score += 2;
1229
+ hits.push({ file: path.basename(p), line: i + 1, score, snippet });
1230
+ }
1231
+ }
1232
+ }
1233
+ hits.sort((a, b) => b.score - a.score);
1234
+ return hits.slice(0, Math.max(1, limit));
1235
+ },
1236
+ "Search Tina4 framework docs for a query string (use before guessing)",
1237
+ schemaFromParams([
1238
+ { name: "query", type: "string" },
1239
+ { name: "limit", type: "integer", default: 5 },
1240
+ { name: "context_lines", type: "integer", default: 4 },
1241
+ ]),
1242
+ );
1243
+
1244
+ server.registerTool(
1245
+ "docs_section",
1246
+ (args) => {
1247
+ const file = (args.file as string) || "";
1248
+ const heading = (args.heading as string) || "";
1249
+ const match = frameworkDocPaths().find((p) => path.basename(p) === file);
1250
+ if (!match) return { error: `Unknown doc file: ${file}. Try docs_list() first.` };
1251
+ const lines = fs.readFileSync(match, "utf-8").split(/\r?\n/);
1252
+ const headingLc = heading.toLowerCase().trim();
1253
+ let start = -1;
1254
+ let startLevel = 0;
1255
+ for (let i = 0; i < lines.length; i++) {
1256
+ const stripped = lines[i].replace(/^\s+/, "");
1257
+ if (stripped.startsWith("#")) {
1258
+ const level = stripped.length - stripped.replace(/^#+/, "").length;
1259
+ const title = stripped.slice(level).trim().toLowerCase();
1260
+ if (title.includes(headingLc)) {
1261
+ start = i;
1262
+ startLevel = level;
1263
+ break;
1264
+ }
1265
+ }
1266
+ }
1267
+ if (start < 0) return { error: `Heading '${heading}' not found in ${file}` };
1268
+ let end = lines.length;
1269
+ for (let j = start + 1; j < lines.length; j++) {
1270
+ const stripped = lines[j].replace(/^\s+/, "");
1271
+ if (stripped.startsWith("#")) {
1272
+ const level = stripped.length - stripped.replace(/^#+/, "").length;
1273
+ if (level <= startLevel) { end = j; break; }
1274
+ }
1275
+ }
1276
+ return { file, heading: lines[start].trim(), body: lines.slice(start, end).join("\n") };
1277
+ },
1278
+ "Return a full markdown section from a framework doc file",
1279
+ schemaFromParams([
1280
+ { name: "file", type: "string" },
1281
+ { name: "heading", type: "string" },
1282
+ ]),
1283
+ );
1284
+
1285
+ // ── git_status / deps_list ──────────────────────────────────
1286
+
1287
+ server.registerTool(
1288
+ "git_status",
1289
+ () => {
1290
+ try {
1291
+ const { execFileSync } = require("node:child_process") as typeof import("node:child_process");
1292
+ const cwd = path.resolve(process.cwd());
1293
+ const run = (args: string[]): string => {
1294
+ return execFileSync("git", args, { cwd, timeout: 3000, encoding: "utf-8" }).toString().trim();
1295
+ };
1296
+ try {
1297
+ execFileSync("git", ["rev-parse", "--is-inside-work-tree"], { cwd, timeout: 3000 });
1298
+ } catch {
1299
+ return { error: "Not a git repository" };
1300
+ }
1301
+ return {
1302
+ branch: run(["branch", "--show-current"]),
1303
+ status: run(["status", "--porcelain"]).split(/\r?\n/).filter((l) => l),
1304
+ recent_commits: run(["log", "--oneline", "-5"]).split(/\r?\n/).filter((l) => l),
1305
+ };
1306
+ } catch (e) {
1307
+ return { error: `git unavailable: ${(e as Error).message}` };
1308
+ }
1309
+ },
1310
+ "Show git branch, modified/untracked files, recent commits",
1311
+ schemaFromParams([]),
1312
+ );
1313
+
1314
+ server.registerTool(
1315
+ "deps_list",
1316
+ () => {
1317
+ const pkgPath = path.join(path.resolve(process.cwd()), "package.json");
1318
+ if (!fs.existsSync(pkgPath)) return { error: "No package.json at project root" };
1319
+ try {
1320
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8"));
1321
+ return {
1322
+ name: pkg.name || "",
1323
+ version: pkg.version || "",
1324
+ engines: pkg.engines || {},
1325
+ dependencies: pkg.dependencies || {},
1326
+ devDependencies: pkg.devDependencies || {},
1327
+ };
1328
+ } catch (e) {
1329
+ return { error: `Failed to parse package.json: ${(e as Error).message}` };
1330
+ }
1331
+ },
1332
+ "List this project's declared Node.js dependencies",
1333
+ schemaFromParams([]),
1334
+ );
992
1335
  }
993
1336
 
994
1337
  /** Alias for registerDevTools — parity with PHP/Ruby/Python. */