diff --git a/.code-linter.json b/.code-linter.json index 2d325db..a16cfd4 100644 --- a/.code-linter.json +++ b/.code-linter.json @@ -1,10 +1,10 @@ { - "max_file_lines": 750, - "max_function_lines": 150, - "max_nesting_depth": 8, - "max_parameters": 8, - "max_comment_lines": 15, - "max_types_per_file": 6, + "max_file_lines": 300, + "max_function_lines": 50, + "max_nesting_depth": 4, + "max_parameters": 5, + "max_comment_lines": 5, + "max_types_per_file": 2, "include_extensions": [ ".cs", ".py" diff --git a/.githooks/post-commit b/.githooks/post-commit new file mode 100755 index 0000000..419f1cc --- /dev/null +++ b/.githooks/post-commit @@ -0,0 +1,8 @@ +#!/usr/bin/env sh +set -eu + +REPO_ROOT=$(git rev-parse --show-toplevel) + +if [ -d "$REPO_ROOT/.projectmem" ] && command -v pjm >/dev/null 2>&1; then + (cd "$REPO_ROOT" && pjm _auto-capture commit >/dev/null 2>&1 &) +fi diff --git a/.githooks/post-merge b/.githooks/post-merge new file mode 100755 index 0000000..768f500 --- /dev/null +++ b/.githooks/post-merge @@ -0,0 +1,8 @@ +#!/usr/bin/env sh +set -eu + +REPO_ROOT=$(git rev-parse --show-toplevel) + +if [ -d "$REPO_ROOT/.projectmem" ] && command -v pjm >/dev/null 2>&1; then + (cd "$REPO_ROOT" && pjm _auto-capture merge >/dev/null 2>&1 &) +fi diff --git a/.githooks/pre-commit b/.githooks/pre-commit new file mode 100755 index 0000000..e0f454f --- /dev/null +++ b/.githooks/pre-commit @@ -0,0 +1,8 @@ +#!/usr/bin/env sh +set -eu + +REPO_ROOT=$(git rev-parse --show-toplevel) + +if [ -d "$REPO_ROOT/.projectmem" ] && command -v pjm >/dev/null 2>&1; then + (cd "$REPO_ROOT" && pjm precheck --level warn) || true +fi diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..b6496c5 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,10 @@ +* @daliys + +# Quality Gate & CI Policy files strictly owned by @daliys +/.code-linter.json @daliys +/.ruff.toml @daliys +/.unity-quality-gate.json @daliys +/.swift-quality-gate.json @daliys +/.slop-review.json @daliys +/.github/workflows/ @daliys +/.github/CODEOWNERS @daliys diff --git a/.github/workflows/approve-external-pr.yml b/.github/workflows/approve-external-pr.yml index 3525ea6..c04f771 100644 --- a/.github/workflows/approve-external-pr.yml +++ b/.github/workflows/approve-external-pr.yml @@ -29,6 +29,11 @@ on: description: "External PR number to replay (e.g. 30)" required: true type: string + acknowledge_critical_files: + description: "Set true only after reviewing the diff, to proceed even though the PR touches .github/workflows, .githooks, or scripts" + required: false + type: boolean + default: false permissions: contents: write # push the trusted/pr-N branch @@ -79,6 +84,70 @@ jobs: exit 1 fi + # ── 0.1. Authorize maintainer actor ───────────────────────────────────── + - name: Verify maintainer authorization + env: + ACTOR: ${{ github.actor }} + REPOSITORY: ${{ github.repository }} + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + + echo "Verifying authorization for actor @$ACTOR..." + + # Explicit maintainer bypass + if [ "$ACTOR" = "Daliys" ]; then + echo "Actor @$ACTOR is authorized maintainer." + exit 0 + fi + + # Check collaborator permission via GitHub API + PERM=$(gh api "repos/$REPOSITORY/collaborators/$ACTOR/permission" --jq '.permission' 2>/dev/null || echo "none") + case "$PERM" in + admin|write) + echo "Actor @$ACTOR authorized with permission: $PERM" + ;; + *) + echo "::error::Actor @$ACTOR does not have maintainer authorization (permission: $PERM)." + exit 1 + ;; + esac + + # ── 0.2. Content & Security Pre-scan ──────────────────────────────────── + - name: Run security pre-scan on PR files + env: + REPOSITORY: ${{ github.repository }} + PR_NUMBER: ${{ inputs.pr_number }} + ACKNOWLEDGE: ${{ inputs.acknowledge_critical_files }} + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + + echo "Scanning changed files in PR #$PR_NUMBER..." + FILES=$(gh api "repos/$REPOSITORY/pulls/$PR_NUMBER/files" --paginate --jq '.[].filename') + + SUSPICIOUS_COUNT=0 + while IFS= read -r file; do + [ -z "$file" ] && continue + case "$file" in + .github/workflows/*|.githooks/*|scripts/*) + echo "::warning::PR #$PR_NUMBER modifies critical CI/script file: $file" + SUSPICIOUS_COUNT=$((SUSPICIOUS_COUNT + 1)) + ;; + esac + done <<< "$FILES" + + if [ "$SUSPICIOUS_COUNT" -gt 0 ]; then + echo "::notice::PR #$PR_NUMBER modifies $SUSPICIOUS_COUNT critical file(s)." + if [ "$ACKNOWLEDGE" != "true" ]; then + echo "::error::Refusing to replay PR #$PR_NUMBER: it modifies critical CI/script file(s) (see warnings above). Review the diff, then re-run this workflow with 'acknowledge_critical_files' set to true if it's safe to proceed." + exit 1 + fi + echo "Critical file changes acknowledged by @${{ github.actor }} — proceeding." + else + echo "Content pre-scan passed cleanly: no critical CI/workflow files modified." + fi + # ── 1. Create trusted branch with the fork's commits ─────────────────── - name: Create trusted/pr-${{ inputs.pr_number }} branch env: diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index ff7228b..deafd67 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -128,17 +128,21 @@ jobs: path: candidate - name: Run required Ollama documentation and checklist review + env: + IS_FORK_PR: ${{ github.event.pull_request && github.event.pull_request.head.repo.fork || false }} run: | set -euo pipefail dotnet --version QUALITY_GATE_PROJECT="trusted/tools~/NexusQualityGate/NexusQualityGate.csproj" - if [ ! -f "$QUALITY_GATE_PROJECT" ]; then - echo "::notice::Trusted branch does not have NexusQualityGate yet; using candidate tool for bootstrap validation." - QUALITY_GATE_PROJECT="candidate/tools~/NexusQualityGate/NexusQualityGate.csproj" - elif ! grep -q -- "--checklist-ai" trusted/tools~/NexusQualityGate/QualityGateOptions.cs; then - echo "::notice::Trusted NexusQualityGate does not support checklist AI yet; using candidate tool for this bootstrap validation." - QUALITY_GATE_PROJECT="candidate/tools~/NexusQualityGate/NexusQualityGate.csproj" + if [ ! -f "$QUALITY_GATE_PROJECT" ] || ! grep -q -- "--checklist-ai" trusted/tools~/NexusQualityGate/QualityGateOptions.cs; then + if [ "${IS_FORK_PR:-false}" = "true" ]; then + echo "::warning::Trusted branch does not support the required NexusQualityGate version, and candidate tool cannot be executed for fork PRs due to security restrictions. Skipping AI documentation check." + exit 0 + else + echo "::notice::Trusted branch does not have the required NexusQualityGate version; using candidate tool for bootstrap validation." + QUALITY_GATE_PROJECT="candidate/tools~/NexusQualityGate/NexusQualityGate.csproj" + fi fi dotnet run --project "$QUALITY_GATE_PROJECT" -- \ @@ -239,6 +243,9 @@ jobs: dependencies = manifest.setdefault("dependencies", {}) dependencies["com.forkhorizon.nexus.unity"] = f"file:{os.environ['GITHUB_WORKSPACE']}" dependencies["com.unity.test-framework"] = "1.5.1" + testables = manifest.setdefault("testables", []) + if "com.forkhorizon.nexus.unity" not in testables: + testables.append("com.forkhorizon.nexus.unity") manifest_path.write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8") PY @@ -361,7 +368,7 @@ jobs: -projectPath "$SMOKE_ROOT" \ -runTests \ -testPlatform editmode \ - -assemblyNames NexusUnity.PackageSmoke.EditorTests \ + -assemblyNames "NexusUnity.PackageSmoke.EditorTests;UnityMCP.Editor.Tests" \ -testResults "$SMOKE_TEST_RESULTS" \ -logFile "$SMOKE_TEST_LOG"; then echo "::error::Unity package Editor tests failed." diff --git a/AGENTS.md b/AGENTS.md index fa5b301..f84b7bb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -43,3 +43,14 @@ PYTHONDONTWRITEBYTECODE=1 scripts/prepush-validate.sh --static-only For Unity-facing behavior, also use the running test harness through MCP tools such as `unity_wait`, `unity_lint_project`, and `unity_editor_controller`. Clean generated Python caches before validation if needed; `__pycache__` or `*.pyc` files are not part of package changes. + + +## Memory Tools + +Default mode: light. Do not spend tokens on memory tools for small, obvious, single-file tasks. + +- projectmem: use for bugs, regressions, multi-step changes, repeated attempts, or architecture decisions. For small self-contained edits, skip full memory startup and use targeted history checks only when useful. +- `.projectmem/config.toml`, `PROJECT_MAP.md`, and `plan.md` are shared project configuration. The append-only event log, issue files, generated summary/structure, watcher state, and generated `CLAUDE.md` bridge are local and ignored. +- This package uses the tracked `.githooks` directory. When `pjm` is installed, its `pre-commit`, `post-commit`, and `post-merge` wrappers provide warnings and local auto-capture; do not run `pjm hooks install`, which assumes `.git/hooks` exists. +- Verify that the projectmem MCP server is bound to this package before using write tools. If it is bound elsewhere, use the local `pjm` CLI from this package root instead. + diff --git a/API_REFERENCE.MD b/API_REFERENCE.MD index 33f3971..5e015dc 100644 --- a/API_REFERENCE.MD +++ b/API_REFERENCE.MD @@ -1,6 +1,6 @@ # Nexus Unity API Reference -Version: `1.5.0` +Version: `1.6.0` Nexus Unity exposes two supported public API surfaces: @@ -187,12 +187,12 @@ Actions: `search`, `explore`, `create_material`, `import`, `refresh`, `instantia ### `unity_editor_controller` Actions: `undo`, `redo`, `play`, `pause`, `step`, `menu`, `read_logs`, `clear_logs`, `get_state`, `get_server_status`, `refresh_assets`, `run_tests`, `get_test_results`, `run_tests_wait`, `get_tool_usage_stats`, `reset_tool_usage_stats`. -`run_tests_wait` triggers `run_tests` and polls raw `get_test_results` from the Python bridge so Unity's main thread is not blocked while the agent waits. +`get_tool_usage_stats` returns in-memory call counts, average/total durations, `last_error_type`, and sanitized `last_error` with sensitive paths redacted; `reset_tool_usage_stats` resets the counters. Raw `run_tests` returns `status: "Submitted"` after Unity accepts the asynchronous request; it does not claim that execution has begun or completed. `run_tests_wait` triggers `run_tests` and polls raw `get_test_results` from the Python bridge so Unity's main thread is not blocked while the agent waits. If submission is rejected, `run_tests_wait` returns that result without polling. ### `unity_ui_automation` Actions: `list_windows`, `get_hierarchy`, `query`, `get_window_rect`, `set_window_rect`, `capture_window_snapshot`, `click`, `input`. -`get_hierarchy` accepts `deep`. `query` accepts `name`, `text`, and `class_name`. Window rect and snapshot actions are intended for resize/layout QA; window snapshots include rect and hierarchy, with best-effort PNG image capture on macOS. +`get_hierarchy` accepts `deep`, `max_depth`, and `max_elements` (with `children_truncated` and root `truncated` markers when capped). `query` accepts `name`, `text`, `class_name`, `max_depth`, and `max_results`. Window rect and snapshot actions are intended for resize/layout QA; window snapshots include rect and hierarchy (supporting `max_depth` and `max_elements`), with best-effort PNG image capture on macOS. ### `unity_wait` Conditions: `compilation`, `play_mode`, `import`, `editor_idle`. @@ -209,6 +209,9 @@ This is the public MCP bridge macro for code edits. Pass `confirm: true` when wr Raw `attach_script`, `write_file`, and `write_files_batch` calls also require `confirm: true` before writing `.cs` files. +### `delete_asset` +Deletes a file or folder in the project. Requires `path` and `confirm: true`. Deletions use `AssetDatabase.MoveAssetToTrash` to send items to the OS Trash. Deletion of `ProjectSettings/` or `Packages/` paths is forbidden. + ### `unity_invoke_method` Invokes a C# method on a component through reflection. diff --git a/CHANGELOG.md b/CHANGELOG.md index 9f80c51..0888d9a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,32 @@ All notable public changes to Nexus Unity are documented here. ## [Unreleased] +## [1.6.0] - 2026-08-23 + +### Security +- Sanitize and redact sensitive exception messages in tool usage stats (`get_tool_usage_stats` and `RecordToolUsage`), replacing absolute filesystem paths, user directories, and multiline frames with generic placeholders and safe truncated summaries, and exposing `last_error_type` (#142). +- Require explicit confirmation (`confirm: true`) for `delete_asset`, enforce `AssetDatabase.MoveAssetToTrash` for OS trash recovery, and block creation, modification, moving, or deletion of `ProjectSettings/` and `Packages/` paths across all asset tools (#140). +- Hardened external PR replay workflow (`approve-external-pr.yml`) with an in-workflow actor authorization gate and a PR content security pre-scan to prevent unauthorized execution and flag critical CI modifications on self-hosted runners (#144). +- The content pre-scan now blocks the replay when a PR touches `.github/workflows/*`, `.githooks/*`, or `scripts/*`, requiring the maintainer to re-run with `acknowledge_critical_files: true` after reviewing the diff, instead of only logging a warning (#144). + +### Changed +- Consolidated duplicate internal Ollama-review and serialized-property write helpers. + +### Fixed +- Add traversal depth and element/result bounds to UI Toolkit hierarchy serialization and element queries (`ui_get_hierarchy`, `ui_query_elements`, and `ui_capture_window_snapshot`), preventing editor stalls and payload bloat on complex UI windows, with `children_truncated` and `truncated` markers on partial hierarchy responses (#126). +- Dispatch WebSocket request handling asynchronously in `Task.Run`, ensuring `ServerLoop` remains non-blocking for concurrent HTTP JSON-RPC requests and new connections (#129). +- `EditorApplication.update` subscription for `HandlePostCompileFocusReturn` is now idempotent (unsubscribes before subscribing), preventing stacked duplicate frame handlers on repeated initialization or domain reloads (#138). +- `ListPlayerPrefs` now uses `ProcessStartInfo.ArgumentList` and timeout guards for macOS `defaults read` execution, preventing process hangs, alongside regex unhashing for Windows registry keys, Linux XML prefs support, and double-default type verification (#143). +- Persist authentication tokens across Unity Editor process restarts in Library token file, preventing persistent HTTP 401 Unauthorized errors for external MCP CLI integrations after restarting Unity (#166). +- `get_scene_dependencies` now uses `enterChildren = false` after the initial property iteration step, preventing deep recursive traversal into child properties and eliminating duplicated dependency references (#71). +- `find_objects` now safely constructs name search regexes with a 100ms match timeout and falls back gracefully to literal substring search on invalid regex patterns or match timeouts (#119). +- Restrict type resolution (`FindType`) to user project assemblies and standard public `UnityEngine` assemblies, disallow internal system/editor assemblies and namespaces, and enforce strict type allowlist constraints for component and ScriptableObject creation/inspection tools (#139). +- Resolve symlinks and directory junctions to their real filesystem targets in `ValidatePath` before checking project boundaries to prevent path traversal. +- Reject abstract ScriptableObject types before calling `CreateInstance` in `create_scriptable_object_asset` and `list_fields_for_type`. +- `TriggerSafeAssetRefresh` callback is now tracked to prevent callback leaks, duplicate refresh hooks, and memory leaks across assembly reload/shutdown boundaries, with a fail-safe 15-second timeout guard. +- `run_tests` now reports `Submitted` rather than `Success` after Unity accepts its asynchronous request, and `run_tests_wait` now returns rejected submissions immediately instead of polling until timeout. +- Auto Setup now reports a clear refresh-and-retry message if a client is unavailable after the integration list is regenerated, instead of throwing an exception. + ## [1.5.0] - 2026-07-12 ### Changed diff --git a/DOCUMENTATION.MD b/DOCUMENTATION.MD index f7ad418..2f1340a 100644 --- a/DOCUMENTATION.MD +++ b/DOCUMENTATION.MD @@ -1,6 +1,6 @@ # Nexus Unity Technical Documentation -Version: `1.5.0` +Version: `1.6.0` Nexus Unity is a Unity Editor automation package with two public interfaces: @@ -62,6 +62,7 @@ Nexus Unity is a local developer tool and should be used only with trusted local - HTTP and WebSocket requests must target loopback hosts. - HTTP and WebSocket requests require the per-session `X-Nexus-Unity-Token` header; generated MCP configs pass it to the Python bridge as `NEXUS_UNITY_AUTH_TOKEN`. +- WebSocket and HTTP requests are processed asynchronously on background worker threads, ensuring the server accept loop remains non-blocking for concurrent clients. - Browser origins are validated to reduce CSRF and DNS rebinding exposure; non-loopback origins are rejected. - File operations resolve paths and enforce the Unity project root boundary. - C# script writes require `confirm: true` before creating or overwriting `.cs` files and triggering Unity compilation. @@ -114,9 +115,9 @@ MCP bridge: - Manager tools accept common raw-action aliases where agents naturally try them, for example `unity_scene_manager action=list_scenes` and `unity_hierarchy_manager action=create_gameobject`. - Invalid manager actions include the valid action names in the error message. - `unity_hierarchy_manager` can create primitives with name, parent, transform, and material path; it can also rename, set transforms, and pass through `create_hierarchy`. -- `unity_editor_controller` includes `run_tests_wait`, which waits in the Python bridge by polling raw `get_test_results` instead of blocking the Unity main thread. +- Raw `run_tests` returns `Submitted` when Unity accepts the asynchronous request; it does not confirm that execution has begun or completed. `unity_editor_controller` `run_tests_wait` waits in the Python bridge by polling raw `get_test_results` instead of blocking the Unity main thread. - `unity_editor_controller` also exposes `get_tool_usage_stats` and `reset_tool_usage_stats` so diagnostics are not split between raw and manager workflows. -- `unity_ui_automation` passes through `deep` for UI hierarchy reads, `class_name` for queries, and window rect/snapshot actions for layout checks. +- `unity_ui_automation` passes through `deep`, `max_depth`, and `max_elements` for UI hierarchy reads, `class_name`, `max_depth`, and `max_results` for queries, and window rect/snapshot actions for layout checks. ## Current Compatibility Decisions @@ -206,7 +207,7 @@ PlayerPrefs cleanup should use specific disposable keys. Bulk cleanup requires ` - Public repo: `https://github.com/ForkHorizon/NexusUnity.git`. - Package id: `com.forkhorizon.nexus.unity`. -- Public release version: `1.5.0`. +- Public release version: `1.6.0`. - License: `MIT`. - Required release docs: `SECURITY.md`, `CONTRIBUTING.md`, and `RELEASE.md`. - Repository funding metadata lives in `.github/FUNDING.yml` and configures the GitHub Sponsor button for `Daliys`. @@ -218,12 +219,12 @@ PlayerPrefs cleanup should use specific disposable keys. Bulk cleanup requires ` Nexus Unity follows semantic versioning for public releases, but the development branch should not bump the package version for every merged fix. Keep `package.json` and visible docs at the latest shipped public version until a release is being prepared. -Unity Package Manager requires `MAJOR.MINOR.PATCH` values in `package.json`, and GitHub release tags and titles use the same semantic version. Use forms like `1.5.0` for the package version, `v1.5.0` for tags, and `1.5.0` for release titles. +Unity Package Manager requires `MAJOR.MINOR.PATCH` values in `package.json`, and GitHub release tags and titles use the same semantic version. Use forms like `1.6.0` for the package version, `v1.6.0` for tags, and `1.6.0` for release titles. During normal development: - Add all user-visible API, behavior, docs, and validation changes to `[Unreleased]` in `CHANGELOG.md`. -- Do not change `package.json` from `1.5.0` unless the change is part of a release-preparation commit. +- Do not change `package.json` from `1.6.0` unless the change is part of a release-preparation commit. - Prefer compatibility fixes over breaking changes; if a breaking change is unavoidable, document the migration path before release. During release preparation: @@ -231,4 +232,4 @@ During release preparation: - Choose the next semantic version based on accumulated changes. - Move `[Unreleased]` entries into the new dated release section. - Update `package.json`, README badges/install examples, `DOCUMENTATION.MD`, and `API_REFERENCE.MD`. -- Tag the release with the matching semantic GitHub version, for example `v1.5.0` for package version `1.5.0`. +- Tag the release with the matching semantic GitHub version, for example `v1.6.0` for package version `1.6.0`. diff --git a/Editor/MCPCliInstaller.ClaudeCode.cs b/Editor/MCPCliInstaller.ClaudeCode.cs index e7734f0..619901c 100644 --- a/Editor/MCPCliInstaller.ClaudeCode.cs +++ b/Editor/MCPCliInstaller.ClaudeCode.cs @@ -1,8 +1,8 @@ -using UnityEditor; -using UnityEngine; -using System.IO; using System; +using System.IO; using Newtonsoft.Json.Linq; +using UnityEditor; +using UnityEngine; namespace UnityMCP.Editor { @@ -15,9 +15,7 @@ public static partial class MCPCliInstaller /// /// Prefers the official claude CLI (claude mcp add --scope project, which itself writes /// .mcp.json). When the CLI is unavailable or fails, it falls back to writing .mcp.json directly. - /// Either path produces the same project-root .mcp.json that the Integrations tab tracks. This is distinct - /// from the Claude Desktop app config, which is handled by the generic JSON-config codepath in - /// instead of a dedicated CLI installer. + /// Either path produces the same project-root .mcp.json that the Integrations tab tracks. /// public static void LinkToClaudeCode() { @@ -31,45 +29,43 @@ public static void LinkToClaudeCode() private static void ExecuteClaudeCodeLinkSequence(string scriptPath, string pythonPath) { string claudePath = ResolveExecutablePath("claude"); - - // Preferred path: the official claude CLI writes the project-scoped .mcp.json for us. if (!string.IsNullOrEmpty(claudePath) && claudePath != "claude") { - // 1. Remove any stale registration so re-running is idempotent. - // A missing registration is expected on first setup and is safe to add over. - bool removedStaleRegistration = RunInstallerProcess(CreateProcessStartInfo(claudePath, "mcp", "remove", "--scope", "project", "nexus-unity"), claudePath, false, "Claude Code", out string removeError, false); - bool registrationWasAbsent = !removedStaleRegistration && IsClaudeCodeRegistrationAbsent(removeError); - - // 2. Add the server at project scope only when the prior state is known to be clear. - if ((removedStaleRegistration || registrationWasAbsent) && RunInstallerProcess(CreateProcessStartInfo(claudePath, "mcp", "add", "--transport", "stdio", "--scope", "project", "--env", MCPServer.AuthTokenEnvironmentVariable + "=" + MCPServer.AuthToken, "nexus-unity", "--", pythonPath, scriptPath), claudePath, false, "Claude Code")) + if (TryLinkViaClaudeCli(claudePath, scriptPath, pythonPath)) { - NexusEditorLog.Log(NexusLogCategory.Integrations, "[MCP] Successfully linked Nexus Unity to Claude Code via '" + claudePath + "'.", true); - EditorUtility.DisplayDialog("MCP Success", "Successfully linked Nexus Unity to Claude Code.\n\nRun /mcp inside Claude Code (or restart it) to load the server.", "OK"); return; } + } + + WriteClaudeCodeDirectJson(scriptPath, pythonPath); + } - NexusEditorLog.Warning(NexusLogCategory.Integrations, (removedStaleRegistration || registrationWasAbsent) - ? "[MCP] Claude Code CLI add command failed at '" + claudePath + "'. Falling back to direct .mcp.json edit." - : "[MCP] Claude Code CLI could not remove the existing registration at '" + claudePath + "': " + removeError + ". Skipping CLI add and falling back to direct .mcp.json edit."); + private static bool TryLinkViaClaudeCli(string claudePath, string scriptPath, string pythonPath) + { + bool removedStale = RunInstallerProcess(CreateProcessStartInfo(claudePath, "mcp", "remove", "--scope", "project", "nexus-unity"), false, "Claude Code", out string removeError, false); + bool registrationAbsent = !removedStale && IsClaudeCodeRegistrationAbsent(removeError); + + if ((removedStale || registrationAbsent) && RunInstallerProcess(CreateProcessStartInfo(claudePath, "mcp", "add", "--transport", "stdio", "--scope", "project", "--env", MCPServer.AuthTokenEnvironmentVariable + "=" + MCPServer.AuthToken, "nexus-unity", "--", pythonPath, scriptPath), claudePath, false, "Claude Code")) + { + NexusEditorLog.Log(NexusLogCategory.Integrations, "[MCP] Successfully linked Nexus Unity to Claude Code via '" + claudePath + "'.", true); + EditorUtility.DisplayDialog("MCP Success", "Successfully linked Nexus Unity to Claude Code.\n\nRun /mcp inside Claude Code (or restart it) to load the server.", "OK"); + return true; } - // Fallback: write the project-root .mcp.json directly. + NexusEditorLog.Warning(NexusLogCategory.Integrations, (removedStale || registrationAbsent) + ? "[MCP] Claude Code CLI add command failed at '" + claudePath + "'. Falling back to direct .mcp.json edit." + : "[MCP] Claude Code CLI could not remove the existing registration at '" + claudePath + "': " + removeError + ". Skipping CLI add and falling back to direct .mcp.json edit."); + return false; + } + + private static void WriteClaudeCodeDirectJson(string scriptPath, string pythonPath) + { try { string projectRoot = Path.GetDirectoryName(Application.dataPath); string configPath = Path.Combine(projectRoot, ".mcp.json"); - JObject config; - if (File.Exists(configPath)) - { - try { config = JObject.Parse(File.ReadAllText(configPath)); } - catch { config = new JObject(); } - } - else - { - config = new JObject(); - } - + JObject config = LoadOrCreateJsonObject(configPath); if (config["mcpServers"] == null) config["mcpServers"] = new JObject(); JObject servers = (JObject)config["mcpServers"]; @@ -93,6 +89,16 @@ private static void ExecuteClaudeCodeLinkSequence(string scriptPath, string pyth } } + private static JObject LoadOrCreateJsonObject(string path) + { + if (File.Exists(path)) + { + try { return JObject.Parse(File.ReadAllText(path)); } + catch { return new JObject(); } + } + return new JObject(); + } + internal static bool IsClaudeCodeRegistrationAbsent(string error) { return !string.IsNullOrEmpty(error) diff --git a/Editor/MCPCliInstaller.Deploy.cs b/Editor/MCPCliInstaller.Deploy.cs new file mode 100644 index 0000000..e3666bc --- /dev/null +++ b/Editor/MCPCliInstaller.Deploy.cs @@ -0,0 +1,164 @@ +using System; +using System.IO; +using UnityEditor; +using UnityEngine; + +namespace UnityMCP.Editor +{ + public static partial class MCPCliInstaller + { + private static bool DeployBridgeScript(out string destinationPath) + { + destinationPath = null; + string sourcePath = FindBridgeScript(); + + if (string.IsNullOrEmpty(sourcePath)) + { + NexusEditorLog.Error(NexusLogCategory.Integrations, "[MCP] Could not find 'nexus_unity_bridge.py' in the project."); + EditorUtility.DisplayDialog("MCP Error", "Could not find 'nexus_unity_bridge.py'.\n\nEnsure the library is correctly imported.", "OK"); + return false; + } + + string projectRoot = Path.GetDirectoryName(Application.dataPath); + destinationPath = Path.Combine(projectRoot, "nexus_unity_bridge.py"); + + try + { + File.Copy(sourcePath, destinationPath, true); + DeployBridgeModule(projectRoot, sourcePath); + NexusEditorLog.Log(NexusLogCategory.Integrations, "[MCP] Bridge script deployed to stable location: " + destinationPath, true); + DeployDocumentationPointer(projectRoot, sourcePath); + return true; + } + catch (Exception e) + { + NexusEditorLog.Error(NexusLogCategory.Integrations, "[MCP] Failed to deploy bridge or docs: " + e.Message); + EditorUtility.DisplayDialog("MCP Error", "Failed to deploy integration files to project root.\n\n" + e.Message, "OK"); + return false; + } + } + + private static void DeployBridgeModule(string projectRoot, string sourcePath) + { + string sourceDir = Path.GetDirectoryName(sourcePath); + string sourceModuleDir = Path.Combine(sourceDir, "nexus_bridge"); + if (!Directory.Exists(sourceModuleDir)) + { + return; + } + + string destinationModuleDir = Path.Combine(projectRoot, "nexus_bridge"); + CopyDirectory(sourceModuleDir, destinationModuleDir); + NexusEditorLog.Log(NexusLogCategory.Integrations, "[MCP] Bridge module deployed to stable location: " + destinationModuleDir); + } + + private static void CopyDirectory(string sourceDir, string destinationDir) + { + Directory.CreateDirectory(destinationDir); + + foreach (string file in Directory.GetFiles(sourceDir)) + { + string fileName = Path.GetFileName(file); + if (fileName.EndsWith(".meta", StringComparison.OrdinalIgnoreCase) || + fileName.EndsWith(".pyc", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + File.Copy(file, Path.Combine(destinationDir, fileName), true); + } + + foreach (string dir in Directory.GetDirectories(sourceDir)) + { + string dirName = Path.GetFileName(dir); + if (dirName == "__pycache__") + { + continue; + } + + CopyDirectory(dir, Path.Combine(destinationDir, dirName)); + } + } + + private static string FindLibraryRoot(string sourcePath) + { + string dir = Path.GetDirectoryName(sourcePath); + while (!string.IsNullOrEmpty(dir)) + { + if (File.Exists(Path.Combine(dir, "package.json"))) + { + return dir; + } + dir = Path.GetDirectoryName(dir); + } + return null; + } + + private static void DeployDocumentationPointer(string projectRoot, string sourcePath) + { + string libraryRoot = FindLibraryRoot(sourcePath); + if (string.IsNullOrEmpty(libraryRoot)) + { + return; + } + + string docSource = Path.Combine(libraryRoot, "DOCUMENTATION.MD"); + if (!File.Exists(docSource)) + { + return; + } + + string relativeDocPath = GetRelativePath(projectRoot, docSource).Replace("\\", "/"); + string pointerPath = Path.Combine(projectRoot, "NEXUS_UNITY_DOCUMENTATION.md"); + string pointerContent = "# Nexus Unity Documentation\n\n" + + "The canonical Nexus Unity documentation is maintained in the package root:\n\n" + + "- [DOCUMENTATION.MD](" + relativeDocPath + ")\n\n" + + "Keep all edits in the package copy so changes stay with version control.\n"; + + File.WriteAllText(pointerPath, pointerContent); + } + + private static string GetRelativePath(string fromPath, string toPath) + { + Uri fromUri = new Uri(AppendDirectorySeparator(Path.GetFullPath(fromPath))); + Uri toUri = new Uri(Path.GetFullPath(toPath)); + Uri relativeUri = fromUri.MakeRelativeUri(toUri); + return Uri.UnescapeDataString(relativeUri.ToString()); + } + + private static string AppendDirectorySeparator(string path) + { + if (path.EndsWith(Path.DirectorySeparatorChar.ToString()) || + path.EndsWith(Path.AltDirectorySeparatorChar.ToString())) + { + return path; + } + return path + Path.DirectorySeparatorChar; + } + + private static string FindBridgeScript() + { + string[] guids = AssetDatabase.FindAssets("nexus_unity_bridge"); + foreach (string guid in guids) + { + string path = AssetDatabase.GUIDToAssetPath(guid); + if (path.EndsWith("nexus_unity_bridge.py")) + { + return Path.GetFullPath(path); + } + } + + string manualSearch = Path.Combine(Application.dataPath, "nexus_unity_bridge.py"); + if (File.Exists(manualSearch)) + { + return manualSearch; + } + + foreach (string path in Directory.GetFiles(Application.dataPath, "*.py", SearchOption.AllDirectories)) + { + if (path.EndsWith("nexus_unity_bridge.py")) return Path.GetFullPath(path); + } + return null; + } + } +} diff --git a/Editor/MCPCliInstaller.Deploy.cs.meta b/Editor/MCPCliInstaller.Deploy.cs.meta new file mode 100644 index 0000000..a7c88f2 --- /dev/null +++ b/Editor/MCPCliInstaller.Deploy.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: be5b21cfbca94822a22c7c66930cd272 diff --git a/Editor/MCPCliInstaller.cs b/Editor/MCPCliInstaller.cs index 47c3e07..24c396a 100644 --- a/Editor/MCPCliInstaller.cs +++ b/Editor/MCPCliInstaller.cs @@ -1,9 +1,9 @@ -using UnityEditor; -using UnityEngine; -using System.IO; -using System.Diagnostics; using System; +using System.Diagnostics; +using System.IO; using System.Text; +using UnityEditor; +using UnityEngine; namespace UnityMCP.Editor { @@ -16,226 +16,77 @@ namespace UnityMCP.Editor /// public static partial class MCPCliInstaller { - - - private static bool DeployBridgeScript(out string destinationPath) - { - destinationPath = null; - string sourcePath = FindBridgeScript(); - - if (string.IsNullOrEmpty(sourcePath)) - { - NexusEditorLog.Error(NexusLogCategory.Integrations, "[MCP] Could not find 'nexus_unity_bridge.py' in the project."); - EditorUtility.DisplayDialog("MCP Error", "Could not find 'nexus_unity_bridge.py'.\n\nEnsure the library is correctly imported.", "OK"); - return false; - } - - string projectRoot = Path.GetDirectoryName(Application.dataPath); - destinationPath = Path.Combine(projectRoot, "nexus_unity_bridge.py"); - - try - { - File.Copy(sourcePath, destinationPath, true); - DeployBridgeModule(projectRoot, sourcePath); - NexusEditorLog.Log(NexusLogCategory.Integrations, "[MCP] Bridge script deployed to stable location: " + destinationPath, true); - DeployDocumentationPointer(projectRoot, sourcePath); - return true; - } - catch (Exception e) - { - NexusEditorLog.Error(NexusLogCategory.Integrations, "[MCP] Failed to deploy bridge or docs: " + e.Message); - EditorUtility.DisplayDialog("MCP Error", "Failed to deploy integration files to project root.\n\n" + e.Message, "OK"); - return false; - } - } - - private static void DeployBridgeModule(string projectRoot, string sourcePath) - { - string sourceDir = Path.GetDirectoryName(sourcePath); - string sourceModuleDir = Path.Combine(sourceDir, "nexus_bridge"); - if (!Directory.Exists(sourceModuleDir)) - { - return; - } - - string destinationModuleDir = Path.Combine(projectRoot, "nexus_bridge"); - CopyDirectory(sourceModuleDir, destinationModuleDir); - NexusEditorLog.Log(NexusLogCategory.Integrations, "[MCP] Bridge module deployed to stable location: " + destinationModuleDir); - } - - private static void CopyDirectory(string sourceDir, string destinationDir) + private static string ResolveExecutablePath(string name) { - Directory.CreateDirectory(destinationDir); - - foreach (string file in Directory.GetFiles(sourceDir)) + if (Application.platform == RuntimePlatform.WindowsEditor) { - string fileName = Path.GetFileName(file); - if (fileName.EndsWith(".meta", StringComparison.OrdinalIgnoreCase) || - fileName.EndsWith(".pyc", StringComparison.OrdinalIgnoreCase)) - { - continue; - } - - File.Copy(file, Path.Combine(destinationDir, fileName), true); + string fromWhere = GetPathFromWhere(name); + return string.IsNullOrEmpty(fromWhere) ? name : fromWhere; } - foreach (string dir in Directory.GetDirectories(sourceDir)) + if (name == "codex") { - string dirName = Path.GetFileName(dir); - if (dirName == "__pycache__") - { - continue; - } - - CopyDirectory(dir, Path.Combine(destinationDir, dirName)); + string nvmCodex = ResolveNvmCodex(); + if (!string.IsNullOrEmpty(nvmCodex)) return nvmCodex; } - } - private static string FindLibraryRoot(string sourcePath) - { - string dir = Path.GetDirectoryName(sourcePath); - while (!string.IsNullOrEmpty(dir)) - { - if (File.Exists(Path.Combine(dir, "package.json"))) - { - return dir; - } - dir = Path.GetDirectoryName(dir); - } - return Path.GetDirectoryName(sourcePath); - } - - private static void DeployDocumentationPointer(string projectRoot, string sourcePath) - { - string libraryRoot = FindLibraryRoot(sourcePath); - string[] docFiles = { "API_REFERENCE.MD", "DOCUMENTATION.MD" }; - - foreach (string file in docFiles) + string pathFromWhich = GetPathFromWhich(name); + if (!string.IsNullOrEmpty(pathFromWhich) && pathFromWhich != name && File.Exists(pathFromWhich)) { - string src = Path.Combine(libraryRoot, file); - if (File.Exists(src)) + if (name != "codex" || !pathFromWhich.Contains("homebrew")) { - try - { - string dst = Path.Combine(projectRoot, file); - File.Copy(src, dst, true); - NexusEditorLog.Log(NexusLogCategory.Integrations, "[MCP] Copied documentation to root: " + dst); - } - catch (Exception e) - { - NexusEditorLog.Warning(NexusLogCategory.Integrations, "[MCP] Failed to copy " + file + " to root: " + e.Message); - } + return pathFromWhich; } } - string docPointerPath = Path.Combine(projectRoot, "NEXUS_UNITY_DOCS.md"); - string docContent = "# Nexus Unity - AI Context\n\n" + - "This project uses **Nexus Unity** for AI Editor automation.\n\n" + - "## 📚 Documentation Access\n" + - "- **Full Tool Reference**: [API_REFERENCE.MD](API_REFERENCE.MD)\n" + - "- **Technical Guide**: [DOCUMENTATION.MD](DOCUMENTATION.MD)\n\n" + - "## 🤖 AI Instructions\n" + - "Before performing any Unity tasks, ALWAYS read `API_REFERENCE.MD` to understand the available tools, their parameters, and the surgical editing patterns required for this project."; - - File.WriteAllText(docPointerPath, docContent); - NexusEditorLog.Log(NexusLogCategory.Integrations, "[MCP] Documentation pointer deployed: " + docPointerPath); + return ResolveSystemExecutable(name); } - - - private static string FindBridgeScript() + private static string ResolveNvmCodex() { - string[] guids = AssetDatabase.FindAssets("MCPCliInstaller t:Script"); - foreach (var guid in guids) - { - string path = AssetDatabase.GUIDToAssetPath(guid); - if (!path.Contains("NexusUnity")) continue; - - string dir = Path.GetDirectoryName(path); - string potentialBridge = Path.Combine(dir, "nexus_unity_bridge.py"); - if (File.Exists(Path.GetFullPath(potentialBridge))) return Path.GetFullPath(potentialBridge); - } + string home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + string nvmBase = Path.Combine(home, ".nvm/versions/node"); + if (!Directory.Exists(nvmBase)) return null; - foreach (var path in AssetDatabase.GetAllAssetPaths()) + foreach (var versionDir in Directory.GetDirectories(nvmBase)) { - if (path.EndsWith("nexus_unity_bridge.py")) return Path.GetFullPath(path); + string potential = Path.Combine(versionDir, "bin/codex"); + if (File.Exists(potential)) return potential; } return null; } - private static string ResolveExecutablePath(string name) + private static string ResolveSystemExecutable(string name) { - if (Application.platform == RuntimePlatform.WindowsEditor) - { - // 'which' does not exist on Windows; use 'where' to resolve against PATH (and PATHEXT, so - // .cmd/.exe shims like gemini.cmd are found). Falls back to the bare name when unresolved. - string fromWhere = GetPathFromWhere(name); - return string.IsNullOrEmpty(fromWhere) ? name : fromWhere; - } - - // 1. Explicit check for NVM/Node paths (High priority for Codex) - if (name == "codex") - { - string home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); - string nvmBase = Path.Combine(home, ".nvm/versions/node"); - if (Directory.Exists(nvmBase)) - { - foreach (var versionDir in Directory.GetDirectories(nvmBase)) - { - string potential = Path.Combine(versionDir, "bin/codex"); - if (File.Exists(potential)) return potential; - } - } - } - - // 2. Try 'which' (respects shell PATH) - string pathFromWhich = GetPathFromWhich(name); - if (!string.IsNullOrEmpty(pathFromWhich) && pathFromWhich != name && File.Exists(pathFromWhich)) - { - // Only return if it's not the older homebrew version of codex (if we can tell) - if (name == "codex" && pathFromWhich.Contains("homebrew")) { - // Continue to fallback if we prefer NVM - } else { - return pathFromWhich; - } - } - - // 3. Common system paths - string[] searchPaths = { - "/usr/local/bin/" + name, - "/opt/homebrew/bin/" + name, - "/usr/bin/" + name, - "/bin/" + name + string[] searchPaths = { + "/usr/local/bin/" + name, + "/opt/homebrew/bin/" + name, + "/usr/bin/" + name, + "/bin/" + name }; foreach (string path in searchPaths) { if (File.Exists(path)) return path; } - return name; } private static string GetPathFromWhich(string name) { - ProcessStartInfo psi = new ProcessStartInfo - { - FileName = "/usr/bin/which", - Arguments = name, - RedirectStandardOutput = true, - UseShellExecute = false, - CreateNoWindow = true + ProcessStartInfo psi = new ProcessStartInfo + { + FileName = "/usr/bin/which", + Arguments = name, + RedirectStandardOutput = true, + UseShellExecute = false, + CreateNoWindow = true }; if (Application.platform != RuntimePlatform.WindowsEditor) { string pathEnv = ""; try { pathEnv = Environment.GetEnvironmentVariable("PATH"); } catch(Exception) {} - // Unity launched from Finder/Hub (not a terminal) inherits a stripped PATH - // (typically just /usr/bin:/bin:/usr/sbin:/sbin) with no /usr/local/bin or /opt/homebrew/bin. - // 'which' would then resolve python3 to the old Xcode-bundled interpreter at /usr/bin/python3 - // instead of a modern one. Prepend common interpreter locations so they're checked first, - // matching the priority order ResolveExecutablePath's own fallback search list already uses. psi.EnvironmentVariables["PATH"] = "/usr/local/bin:/opt/homebrew/bin:" + pathEnv; } @@ -269,14 +120,13 @@ private static string GetPathFromWhere(string name) { string output = p.StandardOutput.ReadToEnd(); p.WaitForExit(); - if (p.ExitCode == 0 && !string.IsNullOrWhiteSpace(output)) + if (p.ExitCode != 0 || string.IsNullOrWhiteSpace(output)) return null; + + string[] lines = output.Split('\n'); + for (int i = 0; i < lines.Length; i++) { - // 'where' can list multiple matches, one per line. Take the first that exists on disk. - foreach (string line in output.Split('\n')) - { - string candidate = line.Trim(); - if (!string.IsNullOrEmpty(candidate) && File.Exists(candidate)) return candidate; - } + string candidate = lines[i].Trim(); + if (!string.IsNullOrEmpty(candidate) && File.Exists(candidate)) return candidate; } } } @@ -284,14 +134,6 @@ private static string GetPathFromWhere(string name) return null; } - /// - /// Resolves the Python interpreter to embed in generated MCP configs, preferring a concrete path. - /// Tries python3, then python, then the Windows py launcher (which defaults to Python 3). - /// - /// - /// Generated configs previously hardcoded python3, which is frequently absent from PATH on Windows - /// (where the interpreter is usually python or the py launcher), so the bridge failed to launch. - /// private static string ResolvePythonPath() { string python3 = ResolveExecutablePath("python3"); @@ -300,7 +142,6 @@ private static string ResolvePythonPath() string python = ResolveExecutablePath("python"); if (!string.IsNullOrEmpty(python) && python != "python") return python; - // Windows commonly ships the 'py' launcher instead of a 'python3' alias; 'py