@atelierai/uco 1.0.8 → 1.0.10

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 (35) hide show
  1. package/CHANGELOG.md +32 -8
  2. package/package.json +2 -2
  3. package/vendor/plugin/com.atelierai.unity.copilot/CHANGELOG.md +4 -4
  4. package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/API/Tool/ToolGroups.SetEnabled.cs +92 -92
  5. package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/CopilotServerManager.cs +0 -147
  6. package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/Startup.Editor.cs +16 -0
  7. package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/UI/Window/MainWindowEditor.CopilotServer.cs +3 -3
  8. package/vendor/plugin/com.atelierai.unity.copilot/Editor/Scripts/Utils/ToolGroupRegistry.cs +461 -460
  9. package/vendor/plugin/com.atelierai.unity.copilot/Editor/UI/uss/CopilotPromptsWindow.uss +11 -11
  10. package/vendor/plugin/com.atelierai.unity.copilot/Editor/UI/uss/CopilotResourcesWindow.uss +16 -16
  11. package/vendor/plugin/com.atelierai.unity.copilot/Editor/UI/uss/CopilotToolsWindow.uss +36 -36
  12. package/vendor/plugin/com.atelierai.unity.copilot/Editor/UI/uss/MainWindow.uss +183 -183
  13. package/vendor/plugin/com.atelierai.unity.copilot/Editor/UI/uss/common/{_mcp-list-window.uss → _copilot-list-window.uss} +18 -18
  14. package/vendor/plugin/com.atelierai.unity.copilot/Editor/UI/uss/common/_list-view.uss +77 -77
  15. package/vendor/plugin/com.atelierai.unity.copilot/Editor/UI/uxml/CopilotPromptsWindow.uxml +18 -18
  16. package/vendor/plugin/com.atelierai.unity.copilot/Editor/UI/uxml/CopilotResourcesWindow.uxml +18 -18
  17. package/vendor/plugin/com.atelierai.unity.copilot/Editor/UI/uxml/CopilotToolsWindow.uxml +18 -18
  18. package/vendor/plugin/com.atelierai.unity.copilot/Editor/UI/uxml/MainWindow.uxml +115 -115
  19. package/vendor/plugin/com.atelierai.unity.copilot/Plugins/ReflectorNet.dll +0 -0
  20. package/vendor/plugin/com.atelierai.unity.copilot/Plugins/Uco.Framework.Common.dll +0 -0
  21. package/vendor/plugin/com.atelierai.unity.copilot/Plugins/Uco.Framework.dll +0 -0
  22. package/vendor/plugin/com.atelierai.unity.copilot/README.md +88 -88
  23. package/vendor/plugin/com.atelierai.unity.copilot/Runtime/Unity/Logs/BufferedFileLogStorage.cs +208 -207
  24. package/vendor/plugin/com.atelierai.unity.copilot/Runtime/Unity/Logs/FileLogStorage.cs +3 -1
  25. package/vendor/plugin/com.atelierai.unity.copilot/Runtime/Unity/Logs/UnityLogCollector.cs +282 -280
  26. package/vendor/plugin/com.atelierai.unity.copilot/Runtime/UnityCopilotPlugin.cs +1 -1
  27. package/vendor/plugin/com.atelierai.unity.copilot/Tests/Editor/DependencyResolver/NuGetExtractorDevelopmentDependencyTests.cs +148 -148
  28. package/vendor/plugin/com.atelierai.unity.copilot/Tests/Editor/DependencyResolver/NuGetExtractorFlatLayoutTests.cs +166 -166
  29. package/vendor/plugin/com.atelierai.unity.copilot/Tests/Editor/DependencyResolver/NuGetInstallManifestTests.cs +436 -436
  30. package/vendor/plugin/com.atelierai.unity.copilot/Tests/Editor/DependencyResolver/NuGetLegacyMigrationTests.cs +1 -1
  31. package/vendor/plugin/com.atelierai.unity.copilot/Tests/Editor/DependencyResolver/NuGetPackageInstallerStaleVersionCleanupTests.cs +200 -200
  32. package/vendor/plugin/com.atelierai.unity.copilot/Tests/Editor/Environment/EnvironmentUtilsTests.cs +1 -1
  33. package/vendor/plugin/com.atelierai.unity.copilot/Tests/Editor/Tool/GameObject/TestJsonSchema.cs +199 -199
  34. package/vendor/plugin/com.atelierai.unity.copilot/package.json +1 -1
  35. /package/vendor/plugin/com.atelierai.unity.copilot/Editor/UI/uss/common/{_mcp-list-window.uss.meta → _copilot-list-window.uss.meta} +0 -0
@@ -1,460 +1,461 @@
1
- /*
2
- * Design inspired by MCP for Unity (CoplayDev/unity-mcp), Copyright (c) Coplay Inc., MIT License.
3
- */
4
-
5
- /*
6
- ┌──────────────────────────────────────────────────────────────────┐
7
- │ Author: Ivan Murzak (https://github.com/IvanMurzak) │
8
- │ Repository: GitHub (https://github.com/IvanMurzak/Unity-MCP) │
9
- │ Copyright (c) 2025 Ivan Murzak │
10
- │ Licensed under the Apache License, Version 2.0. │
11
- │ See the LICENSE file in the project root for more information. │
12
- └──────────────────────────────────────────────────────────────────┘
13
- */
14
-
15
- #nullable enable
16
- using System;
17
- using System.Collections.Generic;
18
- using System.Linq;
19
- using System.Reflection;
20
- using System.Text;
21
- using com.AtelierAI.Unity.Copilot.Runtime.Attributes;
22
- using com.AtelierAI.Uco.Framework;
23
- using UnityEditor;
24
-
25
- namespace com.AtelierAI.Unity.Copilot.Editor.Utils
26
- {
27
- /// <summary>
28
- /// Editor-side static registry that indexes <c>Tool_*</c> partial-class roots
29
- /// decorated with <see cref="ToolGroupAttribute"/> and tracks the
30
- /// enabled state for each discovered group.
31
- /// </summary>
32
- /// <remarks>
33
- /// <para>
34
- /// Initialized on <c>[InitializeOnLoad]</c> domain reload. The scan walks
35
- /// <c>TypeCache.GetTypesWithAttribute&lt;ToolGroupAttribute&gt;()</c>,
36
- /// and for each marked class enumerates public/non-public instance and static
37
- /// methods, reflecting any attribute whose simple type-name is
38
- /// <c>UcoToolAttribute</c> (the external NuGet attribute we are
39
- /// deliberately *not* taking a hard compile-time dependency on, so the
40
- /// upstream package can evolve without breaking this file).
41
- /// </para>
42
- /// <para>
43
- /// Enabled state is persisted to <see cref="EditorPrefs"/> as a compact
44
- /// pipe-separated <c>group=0|group=1</c> string under
45
- /// <see cref="EditorPrefsKey"/>. We avoid JSON here to keep this file
46
- /// dependency-free; the schema is tiny and fully under our control.
47
- /// </para>
48
- /// <para>
49
- /// <b>Enforcement note.</b> This class is purely a registry +
50
- /// persistence layer. Currently <c>IsToolEnabled</c> is informational
51
- /// the upstream <c>IRunTool.RunCallTool</c> pipeline is *not* hooked,
52
- /// so soft-disabled tools remain callable. Enforcement would require
53
- /// either a request-pipeline interceptor or hooking the
54
- /// <c>ToolDisabled</c> branch on the upstream tool-manager.
55
- /// </para>
56
- /// </remarks>
57
- [InitializeOnLoad]
58
- public static class ToolGroupRegistry
59
- {
60
- // ---------------------------------------------------------------------
61
- // Indices (rebuilt every domain reload via the static ctor).
62
- // ---------------------------------------------------------------------
63
- static readonly Dictionary<string, List<string>> s_groupToTools =
64
- new(StringComparer.Ordinal);
65
- static readonly Dictionary<string, string> s_toolToGroup =
66
- new(StringComparer.Ordinal);
67
- static readonly Dictionary<string, bool> s_enabled =
68
- new(StringComparer.Ordinal);
69
- static readonly Dictionary<string, bool> s_defaultEnabled =
70
- new(StringComparer.Ordinal);
71
- static readonly Dictionary<string, string?> s_descriptions =
72
- new(StringComparer.Ordinal);
73
- static readonly HashSet<string> s_knownGroups =
74
- new(StringComparer.Ordinal);
75
-
76
- static readonly Dictionary<string, string[]> s_aliasesByCanonical =
77
- new(StringComparer.Ordinal)
78
- {
79
- ["assets"] = new[] { "asset" },
80
- ["automation"] = new[] { "batch" },
81
- ["build"] = new[] { "builds" },
82
- ["camera"] = new[] { "cameras", "cinemachine" },
83
- ["diagnostics"] = new[] { "console", "logs" },
84
- ["docs"] = new[] { "documentation" },
85
- ["editor"] = new[] { "application" },
86
- ["gameobject"] = new[] { "game-object", "gameobjects" },
87
- ["graphics"] = new[] { "rendering", "frame-debugger" },
88
- ["instances"] = new[] { "instance" },
89
- ["objects"] = new[] { "object" },
90
- ["operations"] = new[] { "jobs", "editor-operations" },
91
- ["packages"] = new[] { "package" },
92
- ["physics"] = Array.Empty<string>(),
93
- ["profiler"] = new[] { "profiling" },
94
- ["reflection"] = new[] { "reflect", "type", "types", "schema" },
95
- ["scene"] = new[] { "scenes" },
96
- ["screenshot"] = new[] { "screenshots", "capture" },
97
- ["scripting"] = new[] { "script", "scripts" },
98
- ["tests"] = new[] { "test", "testing" },
99
- ["texture"] = new[] { "textures" },
100
- ["tools"] = new[] { "tool", "core" },
101
- ["ui"] = new[] { "user-interface" },
102
- ["vfx"] = new[] { "visual-effects" }
103
- };
104
-
105
- static readonly Dictionary<string, string> s_aliasToCanonical = BuildAliasIndex();
106
-
107
- static readonly Dictionary<string, string> s_prefixToCanonical =
108
- new(StringComparer.OrdinalIgnoreCase)
109
- {
110
- ["assets"] = "assets",
111
- ["batch"] = "automation",
112
- ["build"] = "build",
113
- ["camera"] = "camera",
114
- ["console"] = "diagnostics",
115
- ["docs"] = "docs",
116
- ["editor"] = "editor",
117
- ["frame"] = "graphics",
118
- ["gameobject"] = "gameobject",
119
- ["graphics"] = "graphics",
120
- ["instance"] = "instances",
121
- ["object"] = "objects",
122
- ["package"] = "packages",
123
- ["physics"] = "physics",
124
- ["profiler"] = "profiler",
125
- ["reflection"] = "reflection",
126
- ["scene"] = "scene",
127
- ["screenshot"] = "screenshot",
128
- ["script"] = "scripting",
129
- ["tests"] = "tests",
130
- ["texture"] = "texture",
131
- ["tool"] = "tools",
132
- ["tools"] = "tools",
133
- ["type"] = "reflection",
134
- ["ui"] = "ui",
135
- ["unity"] = "tools",
136
- ["vfx"] = "vfx"
137
- };
138
-
139
- /// <summary>
140
- /// EditorPrefs key used to persist the per-group enabled flags.
141
- /// The stored value is a pipe-separated list of <c>group=0|group=1</c>
142
- /// tokens. Unknown groups in the stored value are ignored on load.
143
- /// </summary>
144
- public const string EditorPrefsKey = "UnityMcp.ToolGroups.Enabled";
145
-
146
- // ---------------------------------------------------------------------
147
- // Static initialization.
148
- // ---------------------------------------------------------------------
149
- static ToolGroupRegistry()
150
- {
151
- try
152
- {
153
- Scan();
154
- LoadEnabledFromPrefs();
155
- }
156
- catch (Exception ex)
157
- {
158
- // Never let registry construction take down a domain reload.
159
- // Log via UnityEngine.Debug only the plugin logger is not
160
- // guaranteed to be initialized this early in startup.
161
- UnityEngine.Debug.LogWarning(
162
- $"[ToolGroupRegistry] initialization failed: {ex.GetType().Name}: {ex.Message}");
163
- }
164
- }
165
-
166
- // ---------------------------------------------------------------------
167
- // Public API.
168
- // ---------------------------------------------------------------------
169
-
170
- /// <summary>All known group names, sorted ascending.</summary>
171
- public static IReadOnlyList<string> AllGroups()
172
- => s_knownGroups.OrderBy(s => s, StringComparer.Ordinal).ToList();
173
-
174
- public static string? ResolveCanonicalGroup(string? group)
175
- {
176
- if (string.IsNullOrWhiteSpace(group)) return null;
177
- var normalized = group.Trim();
178
- if (s_aliasToCanonical.TryGetValue(normalized, out var canonical) &&
179
- s_knownGroups.Contains(canonical))
180
- return canonical;
181
- return s_knownGroups.Contains(normalized) ? normalized : null;
182
- }
183
-
184
- public static IReadOnlyList<string> AliasesForGroup(string group)
185
- {
186
- var canonical = ResolveCanonicalGroup(group) ?? group;
187
- return s_aliasesByCanonical.TryGetValue(canonical, out var aliases)
188
- ? aliases.ToArray()
189
- : Array.Empty<string>();
190
- }
191
-
192
- /// <summary>
193
- /// True if the tool is in an enabled group, or is ungrouped (ungrouped
194
- /// tools are always considered enabled they were not opted into the
195
- /// group system).
196
- /// </summary>
197
- public static bool IsToolEnabled(string toolName)
198
- {
199
- if (string.IsNullOrEmpty(toolName))
200
- return true;
201
- if (!s_toolToGroup.TryGetValue(toolName, out var group))
202
- return true;
203
- return s_enabled.TryGetValue(group, out var enabled) ? enabled : true;
204
- }
205
-
206
- /// <summary>True if the group is currently enabled.</summary>
207
- public static bool IsGroupEnabled(string group)
208
- {
209
- var canonical = ResolveCanonicalGroup(group) ?? group;
210
- return s_enabled.TryGetValue(canonical, out var enabled) ? enabled : true;
211
- }
212
-
213
- /// <summary>
214
- /// Actual callability at the current request boundary. Group state is still a
215
- /// soft preference, so a populated group remains effectively callable even
216
- /// when its requested state is disabled.
217
- /// </summary>
218
- public static bool IsGroupEffectivelyEnabled(string group)
219
- {
220
- var canonical = ResolveCanonicalGroup(group);
221
- return canonical != null && s_groupToTools.TryGetValue(canonical, out var tools) && tools.Count > 0;
222
- }
223
-
224
- /// <summary>
225
- /// Toggle a group's enabled state and persist the new state to
226
- /// EditorPrefs. Throws <see cref="ArgumentException"/> if the group
227
- /// name is not known to the registry.
228
- /// </summary>
229
- public static void SetGroupEnabled(string group, bool enabled)
230
- {
231
- if (string.IsNullOrWhiteSpace(group))
232
- throw new ArgumentException("Group name must be non-empty.", nameof(group));
233
- var canonical = ResolveCanonicalGroup(group);
234
- if (canonical == null)
235
- throw new ArgumentException(
236
- $"Unknown group '{group}'. Known: {string.Join(", ", s_knownGroups)}",
237
- nameof(group));
238
-
239
- s_enabled[canonical] = enabled;
240
- SaveEnabledToPrefs();
241
- }
242
-
243
- /// <summary>Tool ids that belong to <paramref name="group"/>, or empty when unknown.</summary>
244
- public static IReadOnlyList<string> ToolsInGroup(string group)
245
- => s_groupToTools.TryGetValue(ResolveCanonicalGroup(group) ?? group, out var list)
246
- ? list.ToList()
247
- : new List<string>();
248
-
249
- /// <summary>True when the group is known to the registry.</summary>
250
- public static bool IsKnownGroup(string group) => ResolveCanonicalGroup(group) != null;
251
-
252
- /// <summary>The default-enabled flag declared by the attribute. Unknown groups → true.</summary>
253
- public static bool GetDefaultEnabled(string group)
254
- => s_defaultEnabled.TryGetValue(ResolveCanonicalGroup(group) ?? group, out var v) ? v : true;
255
-
256
- /// <summary>Optional human-readable description from the attribute, or null.</summary>
257
- public static string? GetDescription(string group)
258
- => s_descriptions.TryGetValue(ResolveCanonicalGroup(group) ?? group, out var d) ? d : null;
259
-
260
- /// <summary>
261
- /// Force a re-scan of the loaded assemblies. Useful in tests; the
262
- /// registry self-initializes on domain reload so production code does
263
- /// not need to call this.
264
- /// </summary>
265
- public static void Rebuild()
266
- {
267
- s_groupToTools.Clear();
268
- s_toolToGroup.Clear();
269
- s_enabled.Clear();
270
- s_defaultEnabled.Clear();
271
- s_descriptions.Clear();
272
- s_knownGroups.Clear();
273
- Scan();
274
- LoadEnabledFromPrefs();
275
- }
276
-
277
- // ---------------------------------------------------------------------
278
- // Implementation.
279
- // ---------------------------------------------------------------------
280
-
281
- static void Scan()
282
- {
283
- // Production tool declarations predate ToolGroupAttribute, so discover the
284
- // actual UcoTool methods and classify their stable tool IDs. An explicit
285
- // ToolGroupAttribute on a declaring type remains the override seam.
286
- var methods = TypeCache.GetMethodsWithAttribute<UcoToolAttribute>();
287
- foreach (var method in methods)
288
- {
289
- var toolName = ExtractToolName(method);
290
- if (string.IsNullOrWhiteSpace(toolName)) continue;
291
-
292
- var groupAttr = method.DeclaringType?.GetCustomAttribute<ToolGroupAttribute>(inherit: false);
293
- var explicitGroup = groupAttr == null ? null : ResolveAliasWithoutKnownCheck(groupAttr.Group);
294
- var groupName = !string.IsNullOrWhiteSpace(explicitGroup)
295
- ? explicitGroup!
296
- : ClassifyTool(toolName!);
297
- RegisterGroup(groupName, groupAttr?.DefaultEnabled ?? true, groupAttr?.Description);
298
- RegisterTool(toolName!, groupName);
299
- }
300
- }
301
-
302
- static void RegisterGroup(string groupName, bool defaultEnabled, string? description)
303
- {
304
- s_knownGroups.Add(groupName);
305
- s_defaultEnabled[groupName] = defaultEnabled;
306
- if (description != null) s_descriptions[groupName] = description;
307
- else if (!s_descriptions.ContainsKey(groupName))
308
- s_descriptions[groupName] = $"Production tools in the '{groupName}' capability group.";
309
- if (!s_enabled.ContainsKey(groupName)) s_enabled[groupName] = defaultEnabled;
310
- }
311
-
312
- static void RegisterTool(string toolName, string groupName)
313
- {
314
- if (!s_groupToTools.TryGetValue(groupName, out var list))
315
- {
316
- list = new List<string>();
317
- s_groupToTools[groupName] = list;
318
- }
319
- if (!list.Contains(toolName)) list.Add(toolName);
320
-
321
- if (s_toolToGroup.TryGetValue(toolName, out var existing) && existing != groupName)
322
- {
323
- UnityEngine.Debug.LogWarning(
324
- $"[ToolGroupRegistry] tool '{toolName}' already mapped to group " +
325
- $"'{existing}'; ignoring duplicate mapping to '{groupName}'.");
326
- return;
327
- }
328
- s_toolToGroup[toolName] = groupName;
329
- }
330
-
331
- static string ClassifyTool(string toolName)
332
- {
333
- if (toolName.StartsWith("editor-operation-", StringComparison.OrdinalIgnoreCase))
334
- return "operations";
335
- var separator = toolName.IndexOf('-');
336
- var prefix = separator < 0 ? toolName : toolName.Substring(0, separator);
337
- return s_prefixToCanonical.TryGetValue(prefix, out var canonical)
338
- ? canonical
339
- : prefix.ToLowerInvariant();
340
- }
341
-
342
- static Dictionary<string, string> BuildAliasIndex()
343
- {
344
- var result = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
345
- foreach (var pair in s_aliasesByCanonical)
346
- {
347
- result[pair.Key] = pair.Key;
348
- foreach (var alias in pair.Value) result[alias] = pair.Key;
349
- }
350
- return result;
351
- }
352
-
353
- static string ResolveAliasWithoutKnownCheck(string group)
354
- {
355
- if (string.IsNullOrWhiteSpace(group)) return string.Empty;
356
- return s_aliasToCanonical.TryGetValue(group.Trim(), out var canonical)
357
- ? canonical
358
- : group.Trim().ToLowerInvariant();
359
- }
360
-
361
- /// <summary>
362
- /// Reflect a method's custom attributes looking for one whose simple
363
- /// type name is <c>UcoToolAttribute</c>, and pull out the tool
364
- /// id via its <c>Name</c> property. We intentionally avoid a typed
365
- /// reference to the external NuGet attribute so this file does not
366
- /// require linking against <c>Uco.Framework.dll</c> at compile time
367
- /// the asmdef already references it for the rest of the codebase,
368
- /// but loose coupling here keeps the registry resilient to upstream
369
- /// renames of the property surface.
370
- /// </summary>
371
- static string? ExtractToolName(MethodInfo method)
372
- {
373
- object[] attrs;
374
- try { attrs = method.GetCustomAttributes(inherit: false); }
375
- catch { return null; }
376
-
377
- foreach (var attr in attrs)
378
- {
379
- if (attr == null) continue;
380
- var typeName = attr.GetType().Name;
381
- if (typeName != "UcoToolAttribute")
382
- continue;
383
-
384
- // Probe a small set of likely member names. The upstream
385
- // attribute uses 'Name' (verified via TestProject), but we
386
- // try a fallback 'Id' to stay forward-compatible.
387
- var nameValue = ReadStringMember(attr, "Name")
388
- ?? ReadStringMember(attr, "Id");
389
- if (!string.IsNullOrWhiteSpace(nameValue))
390
- return nameValue;
391
- }
392
- return null;
393
- }
394
-
395
- static string? ReadStringMember(object instance, string memberName)
396
- {
397
- var type = instance.GetType();
398
- var prop = type.GetProperty(memberName,
399
- BindingFlags.Public | BindingFlags.Instance);
400
- if (prop != null)
401
- {
402
- try { return prop.GetValue(instance) as string; }
403
- catch { return null; }
404
- }
405
- var field = type.GetField(memberName,
406
- BindingFlags.Public | BindingFlags.Instance);
407
- if (field != null)
408
- {
409
- try { return field.GetValue(instance) as string; }
410
- catch { return null; }
411
- }
412
- return null;
413
- }
414
-
415
- // ---------------------------------------------------------------------
416
- // Persistence.
417
- // ---------------------------------------------------------------------
418
-
419
- static void LoadEnabledFromPrefs()
420
- {
421
- var raw = EditorPrefs.GetString(EditorPrefsKey, string.Empty);
422
- if (string.IsNullOrEmpty(raw))
423
- return;
424
-
425
- // Format: group=0|group=1|...
426
- foreach (var entry in raw.Split('|'))
427
- {
428
- if (string.IsNullOrWhiteSpace(entry)) continue;
429
- var sep = entry.IndexOf('=');
430
- if (sep <= 0 || sep >= entry.Length - 1) continue;
431
-
432
- var name = entry.Substring(0, sep);
433
- var flag = entry.Substring(sep + 1);
434
- if (!s_knownGroups.Contains(name)) continue; // ignore stale
435
- if (flag == "1") s_enabled[name] = true;
436
- else if (flag == "0") s_enabled[name] = false;
437
- // anything else: ignore (use the attribute default)
438
- }
439
- }
440
-
441
- static void SaveEnabledToPrefs()
442
- {
443
- if (s_enabled.Count == 0)
444
- {
445
- EditorPrefs.DeleteKey(EditorPrefsKey);
446
- return;
447
- }
448
-
449
- var sb = new StringBuilder(s_enabled.Count * 12);
450
- var first = true;
451
- foreach (var kv in s_enabled.OrderBy(p => p.Key, StringComparer.Ordinal))
452
- {
453
- if (!first) sb.Append('|');
454
- sb.Append(kv.Key).Append('=').Append(kv.Value ? '1' : '0');
455
- first = false;
456
- }
457
- EditorPrefs.SetString(EditorPrefsKey, sb.ToString());
458
- }
459
- }
460
- }
1
+ /*
2
+ * Design inspired by MCP for Unity (CoplayDev/unity-mcp), Copyright (c) Coplay Inc., MIT License.
3
+ */
4
+
5
+ /*
6
+ ┌──────────────────────────────────────────────────────────────────┐
7
+ │ Author: Ivan Murzak (https://github.com/IvanMurzak) │
8
+ │ Repository: GitHub (https://github.com/IvanMurzak/Unity-MCP) │
9
+ │ Copyright (c) 2025 Ivan Murzak │
10
+ │ Licensed under the Apache License, Version 2.0. │
11
+ │ See the LICENSE file in the project root for more information. │
12
+ └──────────────────────────────────────────────────────────────────┘
13
+ */
14
+
15
+ #nullable enable
16
+ using System;
17
+ using System.Collections.Generic;
18
+ using System.Linq;
19
+ using System.Reflection;
20
+ using System.Text;
21
+ using com.AtelierAI.Unity.Copilot.Runtime.Attributes;
22
+ using com.AtelierAI.Unity.Copilot.Editor.Branding;
23
+ using com.AtelierAI.Uco.Framework;
24
+ using UnityEditor;
25
+
26
+ namespace com.AtelierAI.Unity.Copilot.Editor.Utils
27
+ {
28
+ /// <summary>
29
+ /// Editor-side static registry that indexes <c>Tool_*</c> partial-class roots
30
+ /// decorated with <see cref="ToolGroupAttribute"/> and tracks the
31
+ /// enabled state for each discovered group.
32
+ /// </summary>
33
+ /// <remarks>
34
+ /// <para>
35
+ /// Initialized on <c>[InitializeOnLoad]</c> domain reload. The scan walks
36
+ /// <c>TypeCache.GetTypesWithAttribute&lt;ToolGroupAttribute&gt;()</c>,
37
+ /// and for each marked class enumerates public/non-public instance and static
38
+ /// methods, reflecting any attribute whose simple type-name is
39
+ /// <c>UcoToolAttribute</c> (the external NuGet attribute we are
40
+ /// deliberately *not* taking a hard compile-time dependency on, so the
41
+ /// upstream package can evolve without breaking this file).
42
+ /// </para>
43
+ /// <para>
44
+ /// Enabled state is persisted to <see cref="EditorPrefs"/> as a compact
45
+ /// pipe-separated <c>group=0|group=1</c> string under
46
+ /// <see cref="EditorPrefsKey"/>. We avoid JSON here to keep this file
47
+ /// dependency-free; the schema is tiny and fully under our control.
48
+ /// </para>
49
+ /// <para>
50
+ /// <b>Enforcement note.</b> This class is purely a registry +
51
+ /// persistence layer. Currently <c>IsToolEnabled</c> is informational
52
+ /// the upstream <c>IRunTool.RunCallTool</c> pipeline is *not* hooked,
53
+ /// so soft-disabled tools remain callable. Enforcement would require
54
+ /// either a request-pipeline interceptor or hooking the
55
+ /// <c>ToolDisabled</c> branch on the upstream tool-manager.
56
+ /// </para>
57
+ /// </remarks>
58
+ [InitializeOnLoad]
59
+ public static class ToolGroupRegistry
60
+ {
61
+ // ---------------------------------------------------------------------
62
+ // Indices (rebuilt every domain reload via the static ctor).
63
+ // ---------------------------------------------------------------------
64
+ static readonly Dictionary<string, List<string>> s_groupToTools =
65
+ new(StringComparer.Ordinal);
66
+ static readonly Dictionary<string, string> s_toolToGroup =
67
+ new(StringComparer.Ordinal);
68
+ static readonly Dictionary<string, bool> s_enabled =
69
+ new(StringComparer.Ordinal);
70
+ static readonly Dictionary<string, bool> s_defaultEnabled =
71
+ new(StringComparer.Ordinal);
72
+ static readonly Dictionary<string, string?> s_descriptions =
73
+ new(StringComparer.Ordinal);
74
+ static readonly HashSet<string> s_knownGroups =
75
+ new(StringComparer.Ordinal);
76
+
77
+ static readonly Dictionary<string, string[]> s_aliasesByCanonical =
78
+ new(StringComparer.Ordinal)
79
+ {
80
+ ["assets"] = new[] { "asset" },
81
+ ["automation"] = new[] { "batch" },
82
+ ["build"] = new[] { "builds" },
83
+ ["camera"] = new[] { "cameras", "cinemachine" },
84
+ ["diagnostics"] = new[] { "console", "logs" },
85
+ ["docs"] = new[] { "documentation" },
86
+ ["editor"] = new[] { "application" },
87
+ ["gameobject"] = new[] { "game-object", "gameobjects" },
88
+ ["graphics"] = new[] { "rendering", "frame-debugger" },
89
+ ["instances"] = new[] { "instance" },
90
+ ["objects"] = new[] { "object" },
91
+ ["operations"] = new[] { "jobs", "editor-operations" },
92
+ ["packages"] = new[] { "package" },
93
+ ["physics"] = Array.Empty<string>(),
94
+ ["profiler"] = new[] { "profiling" },
95
+ ["reflection"] = new[] { "reflect", "type", "types", "schema" },
96
+ ["scene"] = new[] { "scenes" },
97
+ ["screenshot"] = new[] { "screenshots", "capture" },
98
+ ["scripting"] = new[] { "script", "scripts" },
99
+ ["tests"] = new[] { "test", "testing" },
100
+ ["texture"] = new[] { "textures" },
101
+ ["tools"] = new[] { "tool", "core" },
102
+ ["ui"] = new[] { "user-interface" },
103
+ ["vfx"] = new[] { "visual-effects" }
104
+ };
105
+
106
+ static readonly Dictionary<string, string> s_aliasToCanonical = BuildAliasIndex();
107
+
108
+ static readonly Dictionary<string, string> s_prefixToCanonical =
109
+ new(StringComparer.OrdinalIgnoreCase)
110
+ {
111
+ ["assets"] = "assets",
112
+ ["batch"] = "automation",
113
+ ["build"] = "build",
114
+ ["camera"] = "camera",
115
+ ["console"] = "diagnostics",
116
+ ["docs"] = "docs",
117
+ ["editor"] = "editor",
118
+ ["frame"] = "graphics",
119
+ ["gameobject"] = "gameobject",
120
+ ["graphics"] = "graphics",
121
+ ["instance"] = "instances",
122
+ ["object"] = "objects",
123
+ ["package"] = "packages",
124
+ ["physics"] = "physics",
125
+ ["profiler"] = "profiler",
126
+ ["reflection"] = "reflection",
127
+ ["scene"] = "scene",
128
+ ["screenshot"] = "screenshot",
129
+ ["script"] = "scripting",
130
+ ["tests"] = "tests",
131
+ ["texture"] = "texture",
132
+ ["tool"] = "tools",
133
+ ["tools"] = "tools",
134
+ ["type"] = "reflection",
135
+ ["ui"] = "ui",
136
+ ["unity"] = "tools",
137
+ ["vfx"] = "vfx"
138
+ };
139
+
140
+ /// <summary>
141
+ /// EditorPrefs key used to persist the per-group enabled flags.
142
+ /// The stored value is a pipe-separated list of <c>group=0|group=1</c>
143
+ /// tokens. Unknown groups in the stored value are ignored on load.
144
+ /// </summary>
145
+ public const string EditorPrefsKey = ProductInfo.EditorPrefsPrefix + ".ToolGroups.Enabled";
146
+
147
+ // ---------------------------------------------------------------------
148
+ // Static initialization.
149
+ // ---------------------------------------------------------------------
150
+ static ToolGroupRegistry()
151
+ {
152
+ try
153
+ {
154
+ Scan();
155
+ LoadEnabledFromPrefs();
156
+ }
157
+ catch (Exception ex)
158
+ {
159
+ // Never let registry construction take down a domain reload.
160
+ // Log via UnityEngine.Debug only the plugin logger is not
161
+ // guaranteed to be initialized this early in startup.
162
+ UnityEngine.Debug.LogWarning(
163
+ $"[ToolGroupRegistry] initialization failed: {ex.GetType().Name}: {ex.Message}");
164
+ }
165
+ }
166
+
167
+ // ---------------------------------------------------------------------
168
+ // Public API.
169
+ // ---------------------------------------------------------------------
170
+
171
+ /// <summary>All known group names, sorted ascending.</summary>
172
+ public static IReadOnlyList<string> AllGroups()
173
+ => s_knownGroups.OrderBy(s => s, StringComparer.Ordinal).ToList();
174
+
175
+ public static string? ResolveCanonicalGroup(string? group)
176
+ {
177
+ if (string.IsNullOrWhiteSpace(group)) return null;
178
+ var normalized = group.Trim();
179
+ if (s_aliasToCanonical.TryGetValue(normalized, out var canonical) &&
180
+ s_knownGroups.Contains(canonical))
181
+ return canonical;
182
+ return s_knownGroups.Contains(normalized) ? normalized : null;
183
+ }
184
+
185
+ public static IReadOnlyList<string> AliasesForGroup(string group)
186
+ {
187
+ var canonical = ResolveCanonicalGroup(group) ?? group;
188
+ return s_aliasesByCanonical.TryGetValue(canonical, out var aliases)
189
+ ? aliases.ToArray()
190
+ : Array.Empty<string>();
191
+ }
192
+
193
+ /// <summary>
194
+ /// True if the tool is in an enabled group, or is ungrouped (ungrouped
195
+ /// tools are always considered enabled — they were not opted into the
196
+ /// group system).
197
+ /// </summary>
198
+ public static bool IsToolEnabled(string toolName)
199
+ {
200
+ if (string.IsNullOrEmpty(toolName))
201
+ return true;
202
+ if (!s_toolToGroup.TryGetValue(toolName, out var group))
203
+ return true;
204
+ return s_enabled.TryGetValue(group, out var enabled) ? enabled : true;
205
+ }
206
+
207
+ /// <summary>True if the group is currently enabled.</summary>
208
+ public static bool IsGroupEnabled(string group)
209
+ {
210
+ var canonical = ResolveCanonicalGroup(group) ?? group;
211
+ return s_enabled.TryGetValue(canonical, out var enabled) ? enabled : true;
212
+ }
213
+
214
+ /// <summary>
215
+ /// Actual callability at the current request boundary. Group state is still a
216
+ /// soft preference, so a populated group remains effectively callable even
217
+ /// when its requested state is disabled.
218
+ /// </summary>
219
+ public static bool IsGroupEffectivelyEnabled(string group)
220
+ {
221
+ var canonical = ResolveCanonicalGroup(group);
222
+ return canonical != null && s_groupToTools.TryGetValue(canonical, out var tools) && tools.Count > 0;
223
+ }
224
+
225
+ /// <summary>
226
+ /// Toggle a group's enabled state and persist the new state to
227
+ /// EditorPrefs. Throws <see cref="ArgumentException"/> if the group
228
+ /// name is not known to the registry.
229
+ /// </summary>
230
+ public static void SetGroupEnabled(string group, bool enabled)
231
+ {
232
+ if (string.IsNullOrWhiteSpace(group))
233
+ throw new ArgumentException("Group name must be non-empty.", nameof(group));
234
+ var canonical = ResolveCanonicalGroup(group);
235
+ if (canonical == null)
236
+ throw new ArgumentException(
237
+ $"Unknown group '{group}'. Known: {string.Join(", ", s_knownGroups)}",
238
+ nameof(group));
239
+
240
+ s_enabled[canonical] = enabled;
241
+ SaveEnabledToPrefs();
242
+ }
243
+
244
+ /// <summary>Tool ids that belong to <paramref name="group"/>, or empty when unknown.</summary>
245
+ public static IReadOnlyList<string> ToolsInGroup(string group)
246
+ => s_groupToTools.TryGetValue(ResolveCanonicalGroup(group) ?? group, out var list)
247
+ ? list.ToList()
248
+ : new List<string>();
249
+
250
+ /// <summary>True when the group is known to the registry.</summary>
251
+ public static bool IsKnownGroup(string group) => ResolveCanonicalGroup(group) != null;
252
+
253
+ /// <summary>The default-enabled flag declared by the attribute. Unknown groups → true.</summary>
254
+ public static bool GetDefaultEnabled(string group)
255
+ => s_defaultEnabled.TryGetValue(ResolveCanonicalGroup(group) ?? group, out var v) ? v : true;
256
+
257
+ /// <summary>Optional human-readable description from the attribute, or null.</summary>
258
+ public static string? GetDescription(string group)
259
+ => s_descriptions.TryGetValue(ResolveCanonicalGroup(group) ?? group, out var d) ? d : null;
260
+
261
+ /// <summary>
262
+ /// Force a re-scan of the loaded assemblies. Useful in tests; the
263
+ /// registry self-initializes on domain reload so production code does
264
+ /// not need to call this.
265
+ /// </summary>
266
+ public static void Rebuild()
267
+ {
268
+ s_groupToTools.Clear();
269
+ s_toolToGroup.Clear();
270
+ s_enabled.Clear();
271
+ s_defaultEnabled.Clear();
272
+ s_descriptions.Clear();
273
+ s_knownGroups.Clear();
274
+ Scan();
275
+ LoadEnabledFromPrefs();
276
+ }
277
+
278
+ // ---------------------------------------------------------------------
279
+ // Implementation.
280
+ // ---------------------------------------------------------------------
281
+
282
+ static void Scan()
283
+ {
284
+ // Production tool declarations predate ToolGroupAttribute, so discover the
285
+ // actual UcoTool methods and classify their stable tool IDs. An explicit
286
+ // ToolGroupAttribute on a declaring type remains the override seam.
287
+ var methods = TypeCache.GetMethodsWithAttribute<UcoToolAttribute>();
288
+ foreach (var method in methods)
289
+ {
290
+ var toolName = ExtractToolName(method);
291
+ if (string.IsNullOrWhiteSpace(toolName)) continue;
292
+
293
+ var groupAttr = method.DeclaringType?.GetCustomAttribute<ToolGroupAttribute>(inherit: false);
294
+ var explicitGroup = groupAttr == null ? null : ResolveAliasWithoutKnownCheck(groupAttr.Group);
295
+ var groupName = !string.IsNullOrWhiteSpace(explicitGroup)
296
+ ? explicitGroup!
297
+ : ClassifyTool(toolName!);
298
+ RegisterGroup(groupName, groupAttr?.DefaultEnabled ?? true, groupAttr?.Description);
299
+ RegisterTool(toolName!, groupName);
300
+ }
301
+ }
302
+
303
+ static void RegisterGroup(string groupName, bool defaultEnabled, string? description)
304
+ {
305
+ s_knownGroups.Add(groupName);
306
+ s_defaultEnabled[groupName] = defaultEnabled;
307
+ if (description != null) s_descriptions[groupName] = description;
308
+ else if (!s_descriptions.ContainsKey(groupName))
309
+ s_descriptions[groupName] = $"Production tools in the '{groupName}' capability group.";
310
+ if (!s_enabled.ContainsKey(groupName)) s_enabled[groupName] = defaultEnabled;
311
+ }
312
+
313
+ static void RegisterTool(string toolName, string groupName)
314
+ {
315
+ if (!s_groupToTools.TryGetValue(groupName, out var list))
316
+ {
317
+ list = new List<string>();
318
+ s_groupToTools[groupName] = list;
319
+ }
320
+ if (!list.Contains(toolName)) list.Add(toolName);
321
+
322
+ if (s_toolToGroup.TryGetValue(toolName, out var existing) && existing != groupName)
323
+ {
324
+ UnityEngine.Debug.LogWarning(
325
+ $"[ToolGroupRegistry] tool '{toolName}' already mapped to group " +
326
+ $"'{existing}'; ignoring duplicate mapping to '{groupName}'.");
327
+ return;
328
+ }
329
+ s_toolToGroup[toolName] = groupName;
330
+ }
331
+
332
+ static string ClassifyTool(string toolName)
333
+ {
334
+ if (toolName.StartsWith("editor-operation-", StringComparison.OrdinalIgnoreCase))
335
+ return "operations";
336
+ var separator = toolName.IndexOf('-');
337
+ var prefix = separator < 0 ? toolName : toolName.Substring(0, separator);
338
+ return s_prefixToCanonical.TryGetValue(prefix, out var canonical)
339
+ ? canonical
340
+ : prefix.ToLowerInvariant();
341
+ }
342
+
343
+ static Dictionary<string, string> BuildAliasIndex()
344
+ {
345
+ var result = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
346
+ foreach (var pair in s_aliasesByCanonical)
347
+ {
348
+ result[pair.Key] = pair.Key;
349
+ foreach (var alias in pair.Value) result[alias] = pair.Key;
350
+ }
351
+ return result;
352
+ }
353
+
354
+ static string ResolveAliasWithoutKnownCheck(string group)
355
+ {
356
+ if (string.IsNullOrWhiteSpace(group)) return string.Empty;
357
+ return s_aliasToCanonical.TryGetValue(group.Trim(), out var canonical)
358
+ ? canonical
359
+ : group.Trim().ToLowerInvariant();
360
+ }
361
+
362
+ /// <summary>
363
+ /// Reflect a method's custom attributes looking for one whose simple
364
+ /// type name is <c>UcoToolAttribute</c>, and pull out the tool
365
+ /// id via its <c>Name</c> property. We intentionally avoid a typed
366
+ /// reference to the external NuGet attribute so this file does not
367
+ /// require linking against <c>Uco.Framework.dll</c> at compile time
368
+ /// the asmdef already references it for the rest of the codebase,
369
+ /// but loose coupling here keeps the registry resilient to upstream
370
+ /// renames of the property surface.
371
+ /// </summary>
372
+ static string? ExtractToolName(MethodInfo method)
373
+ {
374
+ object[] attrs;
375
+ try { attrs = method.GetCustomAttributes(inherit: false); }
376
+ catch { return null; }
377
+
378
+ foreach (var attr in attrs)
379
+ {
380
+ if (attr == null) continue;
381
+ var typeName = attr.GetType().Name;
382
+ if (typeName != "UcoToolAttribute")
383
+ continue;
384
+
385
+ // Probe a small set of likely member names. The upstream
386
+ // attribute uses 'Name' (verified via TestProject), but we
387
+ // try a fallback 'Id' to stay forward-compatible.
388
+ var nameValue = ReadStringMember(attr, "Name")
389
+ ?? ReadStringMember(attr, "Id");
390
+ if (!string.IsNullOrWhiteSpace(nameValue))
391
+ return nameValue;
392
+ }
393
+ return null;
394
+ }
395
+
396
+ static string? ReadStringMember(object instance, string memberName)
397
+ {
398
+ var type = instance.GetType();
399
+ var prop = type.GetProperty(memberName,
400
+ BindingFlags.Public | BindingFlags.Instance);
401
+ if (prop != null)
402
+ {
403
+ try { return prop.GetValue(instance) as string; }
404
+ catch { return null; }
405
+ }
406
+ var field = type.GetField(memberName,
407
+ BindingFlags.Public | BindingFlags.Instance);
408
+ if (field != null)
409
+ {
410
+ try { return field.GetValue(instance) as string; }
411
+ catch { return null; }
412
+ }
413
+ return null;
414
+ }
415
+
416
+ // ---------------------------------------------------------------------
417
+ // Persistence.
418
+ // ---------------------------------------------------------------------
419
+
420
+ static void LoadEnabledFromPrefs()
421
+ {
422
+ var raw = EditorPrefs.GetString(EditorPrefsKey, string.Empty);
423
+ if (string.IsNullOrEmpty(raw))
424
+ return;
425
+
426
+ // Format: group=0|group=1|...
427
+ foreach (var entry in raw.Split('|'))
428
+ {
429
+ if (string.IsNullOrWhiteSpace(entry)) continue;
430
+ var sep = entry.IndexOf('=');
431
+ if (sep <= 0 || sep >= entry.Length - 1) continue;
432
+
433
+ var name = entry.Substring(0, sep);
434
+ var flag = entry.Substring(sep + 1);
435
+ if (!s_knownGroups.Contains(name)) continue; // ignore stale
436
+ if (flag == "1") s_enabled[name] = true;
437
+ else if (flag == "0") s_enabled[name] = false;
438
+ // anything else: ignore (use the attribute default)
439
+ }
440
+ }
441
+
442
+ static void SaveEnabledToPrefs()
443
+ {
444
+ if (s_enabled.Count == 0)
445
+ {
446
+ EditorPrefs.DeleteKey(EditorPrefsKey);
447
+ return;
448
+ }
449
+
450
+ var sb = new StringBuilder(s_enabled.Count * 12);
451
+ var first = true;
452
+ foreach (var kv in s_enabled.OrderBy(p => p.Key, StringComparer.Ordinal))
453
+ {
454
+ if (!first) sb.Append('|');
455
+ sb.Append(kv.Key).Append('=').Append(kv.Value ? '1' : '0');
456
+ first = false;
457
+ }
458
+ EditorPrefs.SetString(EditorPrefsKey, sb.ToString());
459
+ }
460
+ }
461
+ }