@aeonsparx/pi-unreal 0.0.0-stage → 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.
@@ -0,0 +1,682 @@
1
+ /**
2
+ * pi-unreal — Unreal Engine awareness for the Pi coding agent.
3
+ *
4
+ * Three read-only tools remove the two things an agent gets wrong most often in a UE codebase:
5
+ * which engine a project actually resolves to, and which plugins/modules the engine will see.
6
+ *
7
+ * unreal_resolve_engine — .uproject `EngineAssociation` → absolute engine root + Build.version
8
+ * unreal_project_inspect — modules, targets, plugin references resolved against the real index
9
+ * unreal_plugin_inspect — one .uplugin: modules, version, dependencies, source root
10
+ *
11
+ * Resolution mirrors `FDesktopPlatformWindows::TryGetInstallationPathFromRegistry`
12
+ * (`Engine/Source/Developer/DesktopPlatform/Private/DesktopPlatformBase.cpp:459-511`) and
13
+ * `DesktopPlatformWindows.cpp:101-161`; plugin discovery mirrors
14
+ * `UnrealBuildTool.System.ReadAvailablePlugins` (`Plugins.cs:322`).
15
+ *
16
+ * The extension never writes to the engine, never touches the registry, and blocks the edits
17
+ * this machine's conventions forbid (`.uasset`/`.umap` hand-edits, `clang-format` on `*.cs`).
18
+ */
19
+
20
+ import { execFileSync } from "node:child_process";
21
+ import { isAbsolute, resolve } from "node:path";
22
+ import { Type } from "typebox";
23
+ import { defineTool, type ExtensionAPI, type ExtensionCommandContext, type ExtensionContext, type ToolCallEvent } from "@earendil-works/pi-coding-agent";
24
+
25
+ import { clearCache, cacheStats, computeCacheKey, loadCachedIndex, saveCachedIndex } from "./lib/cache.ts";
26
+ import { parseUplugin, parseUproject, type EngineVersion, type UpluginDescriptor, type UprojectDescriptor } from "./lib/descriptors.ts";
27
+ import { enumerateInstallations, resolveEngineForProject, findProjectFile, type EngineResolution, type ResolutionStatus } from "./lib/engine-resolver.ts";
28
+ import { buildPluginIndex, computeScanDirs, resolveProjectPlugins, type PluginIndex, type PluginReferenceResult } from "./lib/plugin-resolver.ts";
29
+ import { createWindowsRegistryReader, type RegistryReader } from "./lib/registry.ts";
30
+ import { indexTools, unrealModuleCommand, unrealSymbolCommand } from "./lib/pi-tools.ts";
31
+ import { joinPath, normalizePath, parentDir, realFs, truncateLines, type JsonValue } from "./lib/util.ts";
32
+
33
+ /* ──────────────────────────────────────────────────────────────────────────── session state */
34
+
35
+ let registry: RegistryReader | undefined;
36
+ function getRegistry(): RegistryReader {
37
+ registry ??= createWindowsRegistryReader({ timeoutMs: 8000 });
38
+ return registry;
39
+ }
40
+
41
+ let contextCardInjected = false;
42
+
43
+ /** Cached `tasklist` probe: is a UE editor process running right now? */
44
+ let editorProbe: { atMs: number; running: boolean } | undefined;
45
+ function editorRunning(ttlMs = 4000): boolean {
46
+ const now = Date.now();
47
+ if (editorProbe && now - editorProbe.atMs < ttlMs) return editorProbe.running;
48
+ let running = false;
49
+ try {
50
+ const out = execFileSync("tasklist", ["/FO", "CSV", "/NH", "/FI", "IMAGENAME eq UnrealEditor*"], { encoding: "utf8", timeout: 4000, windowsHide: true });
51
+ running = /unrealeditor/i.test(out);
52
+ } catch {
53
+ running = false;
54
+ }
55
+ editorProbe = { atMs: now, running };
56
+ return running;
57
+ }
58
+
59
+ /* ──────────────────────────────────────────────────────────────────────────── shared helpers */
60
+
61
+ /** `structuredContent` must be plain JSON; strips class instances, `undefined`, and Maps. */
62
+ function toJson(value: unknown): JsonValue {
63
+ return JSON.parse(JSON.stringify(value ?? null)) as JsonValue;
64
+ }
65
+
66
+ /** Directory a `.uproject` lives in — the project root UBT uses for `Plugins` discovery. */
67
+ function projectDirOf(descriptor: UprojectDescriptor): string {
68
+ return parentDir(descriptor.path) ?? descriptor.path;
69
+ }
70
+
71
+ function expandPath(input: string, cwd: string): string {
72
+ const trimmed = input.trim().replace(/^"([^"]*)"$/, "$1").replace(/^'([^']*)'$/, "$1");
73
+ return normalizePath(isAbsolute(trimmed) ? trimmed : resolve(cwd, trimmed));
74
+ }
75
+
76
+ interface LocatedProject {
77
+ path?: string;
78
+ notes: string[];
79
+ }
80
+
81
+ /** Resolve the project a request is about: explicit path, then nearest `.uproject` from cwd. */
82
+ function locateProject(explicit: string | undefined, cwd: string, fs = realFs): LocatedProject {
83
+ const notes: string[] = [];
84
+ if (explicit && explicit.trim()) {
85
+ const candidate = expandPath(explicit, cwd);
86
+ if (!fs.exists(candidate)) return { notes: [`path does not exist: ${candidate}`] };
87
+ if (fs.isDirectory(candidate)) {
88
+ const found = fs.listDir(candidate).find((entry) => entry.toLowerCase().endsWith(".uproject"));
89
+ if (!found) return { notes: [`no .uproject directly inside ${candidate}`] };
90
+ return { path: joinPath(candidate, found), notes: [`found ${found} under the given directory`] };
91
+ }
92
+ if (!candidate.toLowerCase().endsWith(".uproject")) {
93
+ notes.push(`given path is not a .uproject; parsed as text where possible`);
94
+ return { path: candidate, notes };
95
+ }
96
+ return { path: candidate, notes };
97
+ }
98
+ const found = findProjectFile(cwd, fs, 4);
99
+ if (found) return { path: found, notes: [`nearest .uproject found from cwd: ${found}`] };
100
+ notes.push(`no .uproject within 4 levels above ${normalizePath(cwd)}; pass project: <path-to.uproject>`);
101
+ return { notes };
102
+ }
103
+
104
+ function formatVersion(version: EngineVersion | undefined): string {
105
+ return version?.raw ?? "unknown";
106
+ }
107
+
108
+ function engineLines(resolution: EngineResolution): string[] {
109
+ const lines: string[] = [];
110
+ if (resolution.status === "resolved") {
111
+ lines.push(
112
+ `engine: ${resolution.engineRoot}`,
113
+ ` version ${formatVersion(resolution.version)} | ${resolution.installedBuild ? "installed build" : "source build"} | association "${resolution.engineAssociation ?? ""}" (${resolution.associationKind}) via ${resolution.source}`,
114
+ ` Build.version: ${resolution.buildVersionPath}`,
115
+ );
116
+ return lines;
117
+ }
118
+ if (resolution.status === "ambiguous") {
119
+ lines.push(`engine: AMBIGUOUS — more than one valid installation matches the association`);
120
+ for (const candidate of resolution.candidates) {
121
+ lines.push(` ${candidate.root} [${candidate.source}] ${formatVersion(candidate.version)}${candidate.validRoot ? "" : " (invalid root)"}`);
122
+ }
123
+ lines.push(` pick one and pass engineRootHint`);
124
+ return lines;
125
+ }
126
+ lines.push(`engine: UNRESOLVED — association "${resolution.engineAssociation ?? ""}" (${resolution.associationKind}) has no matching installation`);
127
+ for (const candidate of resolution.candidates) {
128
+ lines.push(` rejected ${candidate.root} [${candidate.source}] ${formatVersion(candidate.version)}${candidate.notes.length ? ` — ${candidate.notes.join("; ")}` : ""}`);
129
+ }
130
+ for (const note of resolution.remediation) lines.push(` → ${note}`);
131
+ return lines;
132
+ }
133
+
134
+ function pluginLines(results: PluginReferenceResult[], limit: number): string[] {
135
+ const lines: string[] = [];
136
+ const shown = results.slice(0, limit);
137
+ for (const result of shown) {
138
+ const status = result.plugin ? `${result.status} ← ${result.plugin.source}` : result.status;
139
+ lines.push(` ${result.name}: ${status}`);
140
+ for (const note of result.notes) lines.push(` · ${note}`);
141
+ }
142
+ if (results.length > limit) lines.push(` … ${results.length - limit} more references (raise pluginLimit)`);
143
+ const counts = new Map<string, number>();
144
+ for (const result of results) counts.set(result.status, (counts.get(result.status) ?? 0) + 1);
145
+ const summary = [...counts.entries()].map(([status, count]) => `${count} ${status}`).join(", ");
146
+ lines.push(` totals: ${summary} of ${results.length} references`);
147
+ return lines;
148
+ }
149
+
150
+ function descriptorLines(descriptor: UpluginDescriptor): string[] {
151
+ const lines = [
152
+ `plugin: ${descriptor.name}`,
153
+ ` path ${descriptor.path}`,
154
+ ` version ${descriptor.version ?? "unset"}${descriptor.semVersion ? ` (SemVersion ${descriptor.semVersion})` : ""}${descriptor.versionName ? ` "${descriptor.versionName}"` : ""}`,
155
+ ` EngineVersion ${descriptor.engineVersion?.raw ?? "unset"} | category ${descriptor.category ?? "unset"}`,
156
+ ` EnabledByDefault ${descriptor.enabledByDefault} | CanContainContent ${descriptor.canContainContent} | Installed ${descriptor.installed}${descriptor.isBetaVersion ? " | IsBetaVersion true" : ""}`,
157
+ ];
158
+ if (descriptor.supportedTargetPlatforms.length) lines.push(` SupportedTargetPlatforms ${descriptor.supportedTargetPlatforms.join(", ")}`);
159
+ lines.push(` modules (${descriptor.modules.length}):`);
160
+ for (const entry of descriptor.modules) {
161
+ const flags = [
162
+ entry.descriptor.type ?? "Runtime",
163
+ entry.descriptor.loadingPhase ? `phase ${entry.descriptor.loadingPhase}` : undefined,
164
+ entry.descriptor.platformAllowList.length ? `allow [${entry.descriptor.platformAllowList.join(",")}]` : undefined,
165
+ entry.descriptor.platformDenyList.length ? `deny [${entry.descriptor.platformDenyList.join(",")}]` : undefined,
166
+ entry.descriptor.additionalDependencies.length ? `deps ${entry.descriptor.additionalDependencies.join(",")}` : undefined,
167
+ ]
168
+ .filter(Boolean)
169
+ .join(" | ");
170
+ lines.push(` ${entry.moduleName} — ${flags}`);
171
+ }
172
+ if (descriptor.plugins.length) {
173
+ lines.push(` plugin dependencies (${descriptor.plugins.length}):`);
174
+ for (const ref of descriptor.plugins) {
175
+ lines.push(` ${ref.name}${ref.enabled ? "" : " (Enabled: false)"}${ref.optional ? " optional" : ""}${ref.activate ? " activate" : ""}${ref.semVersion ? ` v${ref.semVersion}` : ""}`);
176
+ }
177
+ }
178
+ for (const diagnostic of descriptor.diagnostics) lines.push(` ! ${diagnostic}`);
179
+ return lines;
180
+ }
181
+
182
+ async function getIndexFor(
183
+ engineRoot: string | undefined,
184
+ projectDir: string | undefined,
185
+ descriptor: UprojectDescriptor | undefined,
186
+ version: EngineVersion | undefined,
187
+ ): Promise<{ index: PluginIndex; cache: "hit" | "miss" }> {
188
+ const scanOptions = {
189
+ engineRoot,
190
+ projectDir,
191
+ additionalPluginDirectories: descriptor?.additionalPluginDirectories,
192
+ additionalRootDirectories: descriptor?.additionalRootDirectories,
193
+ };
194
+ const scanDirs = computeScanDirs(scanOptions).map((spec) => spec.dir);
195
+ const { key, roots } = computeCacheKey({ engineRoot, projectDir, scannedDirs: scanDirs, versionChangelist: version?.changelist });
196
+ const cached = loadCachedIndex(key);
197
+ if (cached) return { index: cached, cache: "hit" };
198
+ const index = await buildPluginIndex(scanOptions);
199
+ saveCachedIndex(index, key, roots);
200
+ return { index, cache: "miss" };
201
+ }
202
+
203
+ /* ──────────────────────────────────────────────────────────────────────────── schemas */
204
+
205
+ const VersionSchema = Type.Object({
206
+ major: Type.Number(),
207
+ minor: Type.Number(),
208
+ patch: Type.Number(),
209
+ changelist: Type.Number(),
210
+ branchName: Type.Optional(Type.String()),
211
+ raw: Type.String(),
212
+ });
213
+
214
+ const CandidateSchema = Type.Object({
215
+ identifier: Type.String(),
216
+ root: Type.String(),
217
+ source: Type.String(),
218
+ rank: Type.Number(),
219
+ directoryExists: Type.Boolean(),
220
+ validRoot: Type.Boolean(),
221
+ version: Type.Optional(VersionSchema),
222
+ versionMajorMinor: Type.Optional(Type.String()),
223
+ versionMatches: Type.Boolean(),
224
+ notes: Type.Array(Type.String()),
225
+ });
226
+
227
+ const EngineOutputSchema = Type.Object({
228
+ status: Type.Union([Type.Literal("resolved"), Type.Literal("ambiguous"), Type.Literal("unresolved")]),
229
+ projectPath: Type.Optional(Type.String()),
230
+ engineAssociation: Type.Optional(Type.String()),
231
+ associationKind: Type.String(),
232
+ engineRoot: Type.Optional(Type.String()),
233
+ identifier: Type.Optional(Type.String()),
234
+ version: Type.Optional(VersionSchema),
235
+ source: Type.Optional(Type.String()),
236
+ installedBuild: Type.Optional(Type.Boolean()),
237
+ buildVersionPath: Type.Optional(Type.String()),
238
+ candidates: Type.Array(CandidateSchema),
239
+ diagnostics: Type.Array(Type.String()),
240
+ remediation: Type.Array(Type.String()),
241
+ });
242
+
243
+ const PluginSummarySchema = Type.Object({
244
+ name: Type.String(),
245
+ path: Type.String(),
246
+ source: Type.String(),
247
+ version: Type.Optional(Type.String()),
248
+ engineVersionRaw: Type.Optional(Type.String()),
249
+ modules: Type.Array(Type.Object({ moduleName: Type.String(), type: Type.Optional(Type.String()), loadingPhase: Type.Optional(Type.String()) })),
250
+ });
251
+
252
+ const ProjectOutputSchema = Type.Object({
253
+ projectPath: Type.String(),
254
+ engine: EngineOutputSchema,
255
+ modules: Type.Array(Type.Object({ moduleName: Type.String(), type: Type.Optional(Type.String()), loadingPhase: Type.Optional(Type.String()) })),
256
+ targetPlatforms: Type.Array(Type.String()),
257
+ pluginReferences: Type.Array(
258
+ Type.Object({
259
+ name: Type.String(),
260
+ status: Type.String(),
261
+ plugin: Type.Optional(PluginSummarySchema),
262
+ notes: Type.Array(Type.String()),
263
+ }),
264
+ ),
265
+ pluginIndex: Type.Object({ engineRoot: Type.Optional(Type.String()), scannedDirs: Type.Array(Type.String()), scannedFiles: Type.Number(), cache: Type.String() }),
266
+ diagnostics: Type.Array(Type.String()),
267
+ });
268
+
269
+ const PluginOutputSchema = Type.Object({
270
+ pluginPath: Type.String(),
271
+ name: Type.String(),
272
+ source: Type.String(),
273
+ version: Type.Optional(Type.String()),
274
+ semVersion: Type.Optional(Type.String()),
275
+ engineVersionRaw: Type.Optional(Type.String()),
276
+ enabledByDefault: Type.Boolean(),
277
+ canContainContent: Type.Boolean(),
278
+ installed: Type.Boolean(),
279
+ supportedTargetPlatforms: Type.Array(Type.String()),
280
+ modules: Type.Array(Type.Object({ moduleName: Type.String(), type: Type.Optional(Type.String()), loadingPhase: Type.Optional(Type.String()) })),
281
+ dependencies: Type.Array(Type.Object({ name: Type.String(), status: Type.String(), notes: Type.Array(Type.String()) })),
282
+ diagnostics: Type.Array(Type.String()),
283
+ });
284
+
285
+ const EngineParams = Type.Object({
286
+ project: Type.Optional(Type.String({ description: "Path to the .uproject, or a directory containing one. Defaults to the nearest .uproject from the working directory." })),
287
+ engineRootHint: Type.Optional(Type.String({ description: "Absolute engine root to trust above registry and environment sources." })),
288
+ });
289
+
290
+ const ProjectParams = Type.Object({
291
+ project: Type.Optional(Type.String({ description: "Path to the .uproject, or a directory containing one." })),
292
+ engineRootHint: Type.Optional(Type.String({ description: "Absolute engine root to trust above registry sources." })),
293
+ includePlugins: Type.Optional(Type.Boolean({ description: "Resolve .uproject plugin references against the on-disk plugin index. Default true." })),
294
+ pluginLimit: Type.Optional(Type.Number({ description: "Maximum plugin references to render. Default 40." })),
295
+ });
296
+
297
+ const PluginParams = Type.Object({
298
+ plugin: Type.Optional(Type.String({ description: "Plugin name, or a path to a .uplugin. Defaults to the .uplugin nearest the working directory." })),
299
+ project: Type.Optional(Type.String({ description: "Project used to build the plugin index. Defaults to the nearest .uproject." })),
300
+ engineRootHint: Type.Optional(Type.String({ description: "Absolute engine root to trust above registry sources." })),
301
+ });
302
+
303
+ /* ──────────────────────────────────────────────────────────────────────────── tools */
304
+
305
+ const resolveEngineTool = defineTool({
306
+ name: "unreal_resolve_engine",
307
+ label: "UE engine",
308
+ description: "Resolve a .uproject EngineAssociation to the absolute engine root, version, and installation type.",
309
+ promptSnippet: "Resolve which Unreal Engine installation a .uproject points at",
310
+ promptGuidelines: [
311
+ "Call unreal_resolve_engine before using any Engine/... path; never guess an engine root from a directory name.",
312
+ "Treat status 'unresolved' as a blocker to report, not a thing to work around.",
313
+ ],
314
+ parameters: EngineParams,
315
+ outputSchema: EngineOutputSchema,
316
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true },
317
+ executionMode: "parallel",
318
+
319
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
320
+ const located = locateProject(params.project, ctx.cwd);
321
+ if (!located.path) {
322
+ return {
323
+ content: [{ type: "text", text: `No project file: ${located.notes.join(" | ")}` }],
324
+ details: { status: "unresolved" as ResolutionStatus, associationKind: "unknown" as const, candidates: [], diagnostics: located.notes, remediation: ["pass project: <path/to/Project.uproject>"] },
325
+ structuredContent: toJson({ status: "unresolved", associationKind: "unknown", candidates: [], diagnostics: located.notes, remediation: ["pass project: <path/to/Project.uproject>"] }),
326
+ isError: true,
327
+ };
328
+ }
329
+ const resolution = await resolveEngineForProject(located.path, {
330
+ registry: getRegistry(),
331
+ userEngineRoots: params.engineRootHint ? [params.engineRootHint] : [],
332
+ });
333
+ const lines = [`project: ${resolution.projectPath ?? located.path}`, ...engineLines(resolution), ...resolution.diagnostics.map((note) => ` ! ${note}`)];
334
+ return {
335
+ content: [{ type: "text", text: truncateLines(lines.join("\n"), 60).text }],
336
+ details: resolution,
337
+ structuredContent: toJson(resolution),
338
+ isError: resolution.status === "unresolved",
339
+ };
340
+ },
341
+ });
342
+
343
+ const projectInspectTool = defineTool({
344
+ name: "unreal_project_inspect",
345
+ label: "UE project",
346
+ description: "Inspect a .uproject: engine resolution, game modules, target platforms, and every plugin reference resolved against the plugins that exist on disk.",
347
+ promptSnippet: "Inspect a UE project's modules, targets, and plugin references",
348
+ promptGuidelines: [
349
+ "Use unreal_project_inspect before adding a module, plugin reference, or Build.cs dependency — plugin names must be verified against the index, not remembered.",
350
+ "Report 'missing' plugin references verbatim: the name, the scanned roots, and whether the reference is Optional.",
351
+ ],
352
+ parameters: ProjectParams,
353
+ outputSchema: ProjectOutputSchema,
354
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true },
355
+ executionMode: "sequential",
356
+
357
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
358
+ const located = locateProject(params.project, ctx.cwd);
359
+ if (!located.path || !located.path.toLowerCase().endsWith(".uproject")) {
360
+ const notes = located.path ? [`not a .uproject: ${located.path}`] : located.notes;
361
+ return { content: [{ type: "text", text: `Cannot inspect project: ${notes.join(" | ")}` }], details: undefined, isError: true };
362
+ }
363
+ const descriptor = parseUproject(located.path, realFs);
364
+ const resolution = await resolveEngineForProject(located.path, {
365
+ registry: getRegistry(),
366
+ userEngineRoots: params.engineRootHint ? [params.engineRootHint] : [],
367
+ });
368
+
369
+ const lines: string[] = [`project: ${descriptor.path}`, `association: "${descriptor.engineAssociation ?? ""}" (${resolution.associationKind})`, ...engineLines(resolution)];
370
+ lines.push(`modules (${descriptor.modules.length}): ${descriptor.modules.map((entry) => `${entry.moduleName} ${entry.descriptor.type ?? "Runtime"}`).join(", ") || "none"}`);
371
+ if (descriptor.targetPlatforms.length) lines.push(`targetPlatforms: ${descriptor.targetPlatforms.join(", ")}`);
372
+ if (descriptor.additionalPluginDirectories.length) lines.push(`additionalPluginDirectories: ${descriptor.additionalPluginDirectories.join(", ")}`);
373
+
374
+ const pluginResults: PluginReferenceResult[] = [];
375
+ let pluginIndex: PluginIndex | undefined;
376
+ let cacheState = "skipped";
377
+ if (params.includePlugins !== false && resolution.status !== "unresolved") {
378
+ const built = await getIndexFor(resolution.engineRoot, projectDirOf(descriptor), descriptor, resolution.version);
379
+ pluginIndex = built.index;
380
+ cacheState = built.cache;
381
+ pluginResults.push(...resolveProjectPlugins(descriptor, built.index, resolution.version));
382
+ lines.push(`plugin index: ${built.index.scannedFiles} .uplugin over [${built.index.scannedDirs.join(" | ")}] (cache ${built.cache})`);
383
+ lines.push("plugin references:");
384
+ lines.push(...pluginLines(pluginResults, params.pluginLimit ?? 40));
385
+ } else {
386
+ lines.push(`plugin references: ${descriptor.plugins.length} declared, not resolved against disk`);
387
+ }
388
+ for (const diagnostic of descriptor.diagnostics) lines.push(` ! ${diagnostic}`);
389
+
390
+ const report = {
391
+ projectPath: descriptor.path,
392
+ engine: resolution,
393
+ modules: descriptor.modules.map((entry) => ({ moduleName: entry.moduleName, type: entry.descriptor.type, loadingPhase: entry.descriptor.loadingPhase })),
394
+ targetPlatforms: descriptor.targetPlatforms,
395
+ pluginReferences: pluginResults.map((result) => ({
396
+ name: result.name,
397
+ status: result.status,
398
+ plugin: result.plugin ? { name: result.plugin.name, path: result.plugin.path, source: result.plugin.source, version: result.plugin.version, engineVersionRaw: result.plugin.engineVersionRaw, modules: result.plugin.modules } : undefined,
399
+ notes: result.notes,
400
+ })),
401
+ pluginIndex: { engineRoot: pluginIndex?.engineRoot, scannedDirs: pluginIndex?.scannedDirs ?? [], scannedFiles: pluginIndex?.scannedFiles ?? 0, cache: cacheState },
402
+ diagnostics: descriptor.diagnostics,
403
+ };
404
+ return {
405
+ content: [{ type: "text", text: truncateLines(lines.join("\n"), 140).text }],
406
+ details: report,
407
+ structuredContent: toJson(report),
408
+ };
409
+ },
410
+ });
411
+
412
+ const pluginInspectTool = defineTool({
413
+ name: "unreal_plugin_inspect",
414
+ label: "UE plugin",
415
+ description: "Inspect one .uplugin: version fields, modules with loading phases, declared plugin dependencies, and target platform gating.",
416
+ promptSnippet: "Inspect a .uplugin descriptor and its module list",
417
+ promptGuidelines: [
418
+ "Use unreal_plugin_inspect before adding a module dependency so the module name, Type, and LoadingPhase come from the descriptor.",
419
+ ],
420
+ parameters: PluginParams,
421
+ outputSchema: PluginOutputSchema,
422
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true },
423
+ executionMode: "sequential",
424
+
425
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
426
+ const located = locateProject(params.project, ctx.cwd);
427
+ const resolution = located.path && located.path.toLowerCase().endsWith(".uproject")
428
+ ? await resolveEngineForProject(located.path, { registry: getRegistry(), userEngineRoots: params.engineRootHint ? [params.engineRootHint] : [] })
429
+ : undefined;
430
+ const descriptor = located.path ? parseUproject(located.path, realFs) : undefined;
431
+ let index: PluginIndex | undefined;
432
+ if (resolution?.status !== "unresolved" && (located.path || descriptor)) {
433
+ index = (await getIndexFor(resolution?.engineRoot, descriptor ? projectDirOf(descriptor) : undefined, descriptor, resolution?.version)).index;
434
+ }
435
+
436
+ let pluginPath: string | undefined;
437
+ const notes: string[] = [];
438
+ const wanted = params.plugin?.trim();
439
+ if (wanted) {
440
+ const asPath = expandPath(wanted, ctx.cwd);
441
+ if (asPath.toLowerCase().endsWith(".uplugin") && realFs.exists(asPath)) pluginPath = asPath;
442
+ else if (index) {
443
+ const bucket = index.byName.get(wanted.toLowerCase());
444
+ if (bucket?.[0]) {
445
+ pluginPath = bucket[0].path;
446
+ notes.push(`resolved name "${wanted}" to ${bucket[0].source} plugin`);
447
+ } else notes.push(`no plugin named "${wanted}" in the index`);
448
+ }
449
+ } else if (index) {
450
+ const cwd = normalizePath(ctx.cwd);
451
+ pluginPath = index.entries
452
+ .filter((entry) => cwd.startsWith(entry.dir + "/") || entry.dir.startsWith(cwd))
453
+ .sort((a, b) => b.dir.length - a.dir.length)[0]?.path;
454
+ if (pluginPath) notes.push(`nearest .uplugin to cwd: ${pluginPath}`);
455
+ }
456
+ if (!pluginPath) {
457
+ return {
458
+ content: [{ type: "text", text: `No plugin resolved. ${notes.join(" | ") || "pass plugin: <name or path.uplugin>"}` }],
459
+ details: undefined,
460
+ isError: true,
461
+ };
462
+ }
463
+
464
+ const uplugin = parseUplugin(pluginPath, realFs);
465
+ const dependencyResults = uplugin.plugins.map((ref) => ({ name: ref.name, status: "unresolved", notes: [] as string[] }));
466
+ if (index) {
467
+ dependencyResults.length = 0;
468
+ for (const ref of uplugin.plugins) {
469
+ const bucket = index.byName.get(ref.name.toLowerCase());
470
+ const winner = bucket?.[0];
471
+ dependencyResults.push({
472
+ name: ref.name,
473
+ status: !ref.enabled ? "disabled" : winner ? "resolved" : "missing",
474
+ notes: winner ? [`${winner.source} ${winner.path}`] : [`not found in [${index.scannedDirs.join(" | ")}]`],
475
+ });
476
+ }
477
+ }
478
+
479
+ const source = index?.entries.find((entry) => entry.path === pluginPath)?.source ?? (pluginPath.includes("/Engine/Plugins/") ? "Engine" : "Project");
480
+ const lines = [...descriptorLines(uplugin), ...notes.map((note) => ` · ${note}`)];
481
+ const report = {
482
+ pluginPath: uplugin.path,
483
+ name: uplugin.name,
484
+ source,
485
+ version: uplugin.version,
486
+ semVersion: uplugin.semVersion,
487
+ engineVersionRaw: uplugin.engineVersion?.raw,
488
+ enabledByDefault: uplugin.enabledByDefault,
489
+ canContainContent: uplugin.canContainContent,
490
+ installed: uplugin.installed,
491
+ supportedTargetPlatforms: uplugin.supportedTargetPlatforms,
492
+ modules: uplugin.modules.map((entry) => ({ moduleName: entry.moduleName, type: entry.descriptor.type, loadingPhase: entry.descriptor.loadingPhase })),
493
+ dependencies: dependencyResults,
494
+ diagnostics: uplugin.diagnostics,
495
+ };
496
+ return { content: [{ type: "text", text: truncateLines(lines.join("\n"), 120).text }], details: report, structuredContent: toJson(report) };
497
+ },
498
+ });
499
+
500
+ /* ──────────────────────────────────────────────────────────────────────────── commands */
501
+
502
+ async function reportProject(args: string, ctx: ExtensionCommandContext): Promise<void> {
503
+ const located = locateProject(args || undefined, ctx.cwd);
504
+ if (!located.path) {
505
+ ctx.ui.notify(`No .uproject found. ${located.notes.join(" | ")}`, "warning");
506
+ return;
507
+ }
508
+ const resolution = await resolveEngineForProject(located.path, { registry: getRegistry() });
509
+ const descriptor = located.path.toLowerCase().endsWith(".uproject") ? parseUproject(located.path, realFs) : undefined;
510
+ const lines = [`project ${located.path}`, ...engineLines(resolution)];
511
+ if (descriptor) {
512
+ lines.push(`modules: ${descriptor.modules.map((entry) => `${entry.moduleName} (${entry.descriptor.type ?? "Runtime"})`).join(", ") || "none"}`);
513
+ lines.push(`plugins declared: ${descriptor.plugins.length}`);
514
+ if (resolution.status !== "unresolved") {
515
+ const built = await getIndexFor(resolution.engineRoot, projectDirOf(descriptor), descriptor, resolution.version);
516
+ lines.push(`plugin index: ${built.index.scannedFiles} .uplugin (cache ${built.cache})`);
517
+ lines.push(...pluginLines(resolveProjectPlugins(descriptor, built.index, resolution.version), 25));
518
+ }
519
+ }
520
+ ctx.ui.notify(truncateLines(lines.join("\n"), 40).text, "info");
521
+ }
522
+
523
+ async function reportEngines(ctx: ExtensionCommandContext): Promise<void> {
524
+ const installs = await enumerateInstallations({ registry: getRegistry() });
525
+ const lines = installs.map(
526
+ (install) =>
527
+ `${install.identifier.padEnd(10)} ${install.source.padEnd(18)} ${install.validRoot ? "valid " : "INVALID"} ${install.root}${install.version ? ` ${install.version.raw}` : ""}`,
528
+ );
529
+ ctx.ui.notify(lines.length ? `engine installations (${lines.length}):\n${lines.join("\n")}` : "no engine installations discovered", "info");
530
+ }
531
+
532
+ /* ──────────────────────────────────────────────────────────────────────────── context card */
533
+
534
+ async function buildContextCard(cwd: string): Promise<string | undefined> {
535
+ const project = findProjectFile(cwd, realFs, 4);
536
+ if (!project) return undefined;
537
+ const descriptor = parseUproject(project, realFs);
538
+ const resolution = await resolveEngineForProject(project, { registry: getRegistry() });
539
+ const lines = [
540
+ "Unreal Engine context (auto-detected at session start; re-verify with unreal_resolve_engine):",
541
+ ` project ${project}`,
542
+ ...engineLines(resolution).map((line) => ` ${line}`),
543
+ ` modules: ${descriptor.modules.map((entry) => `${entry.moduleName}(${entry.descriptor.type ?? "Runtime"})`).join(" ") || "none"}`,
544
+ ` plugin references declared: ${descriptor.plugins.length}`,
545
+ " engine sources for this machine live under C:/Program Files/Epic Games (UE_*) plus source builds; registry + dir-scan are consulted by the tools — do not infer.",
546
+ " conventions: editor closed before C++ edits; never hand-edit .uasset/.umap; never clang-format .cs.",
547
+ " tools: unreal_resolve_engine, unreal_project_inspect, unreal_plugin_inspect. Commands: /unreal, /unreal-engines, /unreal-cache.",
548
+ ];
549
+ return lines.join("\n");
550
+ }
551
+
552
+ /* ──────────────────────────────────────────────────────────────────────────── gate */
553
+
554
+ const BINARY_ASSET_EXT = [".uasset", ".umap"];
555
+ /** UE's own sourceFileExtensions (minus `cs`, which is C# and not live-compiled by the editor). */
556
+ const CPP_EXT = [".cpp", ".cc", ".cxx", ".c", ".h", ".hpp", ".hxx", ".inl", ".cpp.inl", ".hpp.inl", ".template", ".inc"];
557
+
558
+ function targetPath(input: Record<string, unknown> | undefined): string | undefined {
559
+ const value = input?.path;
560
+ return typeof value === "string" && value.trim() ? normalizePath(value) : undefined;
561
+ }
562
+
563
+ /** Machine conventions enforced on every write: binary assets, live-editor C++ edits, clang-format on .cs. */
564
+ export function gateCall(
565
+ event: ToolCallEvent,
566
+ ctx: ExtensionContext,
567
+ options: { editorRunning?: boolean } = {},
568
+ ): { block?: boolean; reason?: string } | undefined {
569
+ const input = event.input as Record<string, unknown> | undefined;
570
+
571
+ if (event.toolName === "edit" || event.toolName === "write") {
572
+ const path = targetPath(input);
573
+ if (path) {
574
+ const lower = path.toLowerCase();
575
+ const assetExt = BINARY_ASSET_EXT.find((ext) => lower.endsWith(ext));
576
+ if (assetExt) {
577
+ return {
578
+ block: true,
579
+ reason: `${assetExt} is a cooked binary asset. Author it in the running editor (asset tools / UI) — hand-editing corrupts it, and a source-control diff of it is meaningless.`,
580
+ };
581
+ }
582
+ if (CPP_EXT.some((ext) => lower.endsWith(ext)) && (options.editorRunning ?? editorRunning())) {
583
+ ctx.ui.notify(`UnrealEditor is running while editing ${path} — newly compiled C++ will not load into the running editor. Close the editor, then build with UnrealEngine-BuildProjectEditor.`, "warning");
584
+ }
585
+ }
586
+ }
587
+
588
+ if (event.toolName === "bash" || event.toolName === "powershell") {
589
+ const command = typeof input?.command === "string" ? input.command : "";
590
+ if (/clang-format/i.test(command) && /[^\s'"]+\.cs\b/i.test(command)) {
591
+ return {
592
+ block: true,
593
+ reason: "clang-format must never touch *.cs — it applies the C# language rule and can empty the file (verified on Source/Lp.Target.cs). Format C++ with Script/Dev/Format-Cpp.ps1.",
594
+ };
595
+ }
596
+ }
597
+ return undefined;
598
+ }
599
+
600
+ /* ──────────────────────────────────────────────────────────────────────────── entry */
601
+
602
+ export default function (pi: ExtensionAPI) {
603
+ pi.registerTool(resolveEngineTool);
604
+ pi.registerTool(projectInspectTool);
605
+ pi.registerTool(pluginInspectTool);
606
+ for (const tool of indexTools) pi.registerTool(tool);
607
+
608
+ pi.registerCommand("unreal-symbol", {
609
+ description: "Where is this UE symbol defined? (project + plugins, then engine if an engine index exists)",
610
+ async handler(query: string, ctx: ExtensionCommandContext) {
611
+ const term = query.trim();
612
+ if (!term) {
613
+ ctx.ui.notify("usage: /unreal-symbol <symbol> (qualified works: AActor:Tick)", "info");
614
+ return;
615
+ }
616
+ ctx.ui.notify("indexing / looking up…", "info");
617
+ const text = await unrealSymbolCommand(term, ctx.cwd);
618
+ ctx.ui.notify(truncateLines(text, 40).text, "info");
619
+ },
620
+ });
621
+
622
+ pi.registerCommand("unreal-module", {
623
+ description: "Module directory, dependency lists, defines file, standard and SDK (from UnrealBuildTool Query)",
624
+ async handler(name: string, ctx: ExtensionCommandContext) {
625
+ const module = name.trim();
626
+ if (!module) {
627
+ ctx.ui.notify("usage: /unreal-module <ModuleName>", "info");
628
+ return;
629
+ }
630
+ ctx.ui.notify("running UnrealBuildTool Query (cached after the first run)…", "info");
631
+ const text = await unrealModuleCommand(module, ctx.cwd);
632
+ ctx.ui.notify(truncateLines(text, 40).text, "info");
633
+ },
634
+ });
635
+
636
+ pi.registerCommand("unreal", {
637
+ description: "Inspect the Unreal Engine project for this directory (engine, modules, plugins)",
638
+ handler: async (args, ctx) => {
639
+ await reportProject(args, ctx);
640
+ },
641
+ });
642
+
643
+ pi.registerCommand("unreal-engines", {
644
+ description: "List every Unreal Engine installation this machine can resolve",
645
+ handler: async (_args, ctx) => {
646
+ await reportEngines(ctx);
647
+ },
648
+ });
649
+
650
+ pi.registerCommand("unreal-cache", {
651
+ description: "Show or clear the pi-unreal plugin index cache: /unreal-cache [stats|clear]",
652
+ handler: async (args, ctx) => {
653
+ if (args.trim().toLowerCase() === "clear") {
654
+ const cleared = clearCache();
655
+ ctx.ui.notify(`Cleared ${cleared.removed} cached indexes (${cleared.dir})`, "info");
656
+ return;
657
+ }
658
+ const stats = cacheStats();
659
+ ctx.ui.notify(`plugin index cache: ${stats.files} files, ${stats.entries} plugin entries, ${(stats.bytes / 1024).toFixed(0)} KB\n${stats.dir}`, "info");
660
+ },
661
+ });
662
+
663
+ pi.on("session_start", () => {
664
+ contextCardInjected = false;
665
+ editorProbe = undefined;
666
+ });
667
+
668
+ pi.on("before_agent_start", async (event, ctx) => {
669
+ if (event.messageType !== "user") return;
670
+ if (contextCardInjected) return;
671
+ try {
672
+ const card = await buildContextCard(ctx.cwd);
673
+ if (!card) return;
674
+ contextCardInjected = true;
675
+ return { message: { customType: "unreal:context", content: card, display: true, details: { source: "pi-unreal" } } };
676
+ } catch {
677
+ return undefined;
678
+ }
679
+ });
680
+
681
+ pi.on("tool_call", (event, ctx) => gateCall(event, ctx));
682
+ }