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