Conversation
Plugins are directories in .unityctl/plugins/ (project) or ~/.unityctl/plugins/ (user) containing a plugin.json manifest and .cs scripts. The CLI dynamically creates System.CommandLine commands from manifests and executes handlers via script.execute. - Plugin discovery with project-level precedence over user-level - plugin list/create/remove management commands - Dynamic CLI command generation from plugin.json manifests - Composed SKILL.md: base + plugin sections + .unityctl/skill-extra.md - skill rebuild command to regenerate composed skill
This comment was marked as outdated.
This comment was marked as outdated.
Executables named `unityctl-<name>` on PATH or in .unityctl/plugins/ become top-level CLI commands. The CLI sets environment variables (UNITYCTL_PROJECT_PATH, UNITYCTL_BRIDGE_PORT, UNITYCTL_BRIDGE_URL, UNITYCTL_AGENT_ID, UNITYCTL_JSON) so plugins can discover the bridge. Precedence: built-in > script plugin > executable plugin. Also fix skill rebuild snowballing plugin sections in dev mode by stripping prior composed sections before recomposing.
New Claude Code skill describing how to create both script and executable plugins. Installed and updated alongside the main unity-editor skill via skill add/rebuild/remove/status commands.
Niaobu
pushed a commit
that referenced
this pull request
Mar 15, 2026
- Extract shared FindDotUnityctlDirectory() to deduplicate 3 independent
directory-walking implementations across PluginLoader and SkillCommands
- Reuse ScriptCommands.DisplayScriptResult() in PluginLoader instead of
duplicating the script result display/error handling logic
- Derive BuiltInCommandNames dynamically from rootCommand.Children instead
of a manually-maintained hardcoded string array that can drift out of sync
- Use Directory.EnumerateFiles("unityctl-*") wildcard to filter at OS level
instead of enumerating all files in every PATH directory
- Hoist WindowsExecutableExtensions to a static field to avoid per-file
array allocation in the scan loop
- Fix FindSkillExtraFile() bug where it returned a non-existent path when
.unityctl/ dir exists but skill-extra.md doesn't
https://claude.ai/code/session_01KMW2mqjmQPUKogZDatcTKX
…gins Validate that plugin handler and skill file paths resolve within the plugin directory to prevent directory traversal via malicious plugin.json. Skip script plugins whose names conflict with built-in commands instead of crashing at startup.
- Fix path traversal guard case-sensitivity bypass on Windows (use OrdinalIgnoreCase for StartsWith checks in both script execution and skill section generation) - Add confirmation prompt to plugin remove (with --force/-f to skip) - Validate plugin names in plugin create (lowercase alphanumeric + hyphens) - Lazy PATH resolution for executable plugins (git-style: try to exec unityctl-<name> on demand instead of scanning all PATH dirs at startup; plugin list still does full scan explicitly) - Fix missing exit code in skill add when embedded resource not found - Fix IsUnixExecutable returning true on error (now returns false)
Use where.exe to resolve PATHEXT-aware matches, then cmd.exe /c to execute batch scripts. The direct Process.Start only finds .exe files with UseShellExecute=false, so scripts need the two-step approach. Unknown commands now fall through cleanly to System.CommandLine help.
Niaobu
pushed a commit
that referenced
this pull request
Mar 15, 2026
59 new tests covering manifest deserialization, name validation, path traversal guards, skill section generation, executable plugin name extraction, and base SKILL.md integrity (ensuring the embedded resource never contains composed plugin sections). Sample plugins in .unityctl/plugins/ demonstrate both plugin types and serve as living test fixtures for skill composition.
The base SKILL.md now lives in UnityCtl.Cli/Resources/ where it is embedded into the assembly and never overwritten by skill add/rebuild. The files in .claude/skills/ are the composed output (base + plugin sections + user extra) and are committed so the project is ready to use on clone. This eliminates the marker-stripping workaround since compose always starts from the clean embedded base.
Clarify that the base skill lives in Resources/ and the composed output in .claude/skills/ is generated — edit the base, then run skill add --force to regenerate.
Agent-only command — callers always mean it, so the interactive confirmation just causes hangs in non-TTY contexts.
…pers - Fix PATH-resolved plugins not receiving --json/--project/--agent-id by parsing global options via rootCommand.Parse() before interception - Extract PluginLoader.DiscoverWithExclusions() (was duplicated 3x) - Extract PluginLoader.IsPathWithin() path-traversal guard (was duplicated 2x) - Extract RunAndStreamAsync/CreateStartInfo/SetPluginEnvironment helpers in ExecutablePluginLoader (was duplicated across TryExecuteByName and ExecutePluginAsync) - Unify executable plugin scanning across platforms (extensionless works everywhere, skip .skill.md companions) - Remove dead null check on non-nullable GetUserPluginsDirectory()
…dation - plugin remove: detect executable plugins and tell user to remove manually - plugin create: reject names that conflict with built-in commands - Forward --timeout to executable plugins via UNITYCTL_TIMEOUT env var - Skip commands with unsupported handler types instead of silently treating them as scripts - Fix misplaced XML doc comment on BuiltInCommandNames
Backslash path separators are Windows-only — on Linux `..\..\` is a literal filename, not traversal. Move the backslash test case to a Windows-only SkippableFact.
…ests - Fix skill remove exiting 0 on not-found (now returns exit code 1) - Remove unused folderName parameter from GetEmbeddedResourceContent - Make BuiltInCommandNames setter private with explicit Initialize method - Add name validation to plugin remove (matching plugin create) - Rewrite tests to call actual ScanDirectoryForExecutables/ValidPluginName instead of re-implementing the logic inline (caught dotIndex > 0 bug) - Fix executable name extraction for leading-dot names (dotIndex >= 0) - Drain stderr in ResolveViaWhere to prevent potential deadlock
- Extract generic GetGlobalOption<T> in ContextHelper (DRY) - Add PluginLoader.GetPluginDirectories() shared by both loaders - Rewrite ExecutablePluginScanTests to call actual ScanDirectoryForExecutables - Use raw string literal for scaffold template in PluginCommands - Guard development-only resource fallback with #if DEBUG - Extract WriteComposedSkillAsync shared by skill add and rebuild - Add InternalsVisibleTo for Cli project
…Bash On Windows, Process.Start can't execute extensionless shebang scripts directly, and the bare "bash" name may resolve to WSL's bash.exe which can't see Windows paths. Now reads the shebang line, resolves to Git for Windows' bash.exe, and passes the script as an argument.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds a plugin system that lets users extend
unityctlwith custom commands — no changes to the bridge or Unity plugin required.Two plugin types:
script.executeRPC. Best for commands that need Unity APIs (scene queries, asset manipulation, etc.).unityctl-<name>binary/script on disk, following the git/kubectl naming convention. Best for orchestration, CI pipelines, and workflows outside Unity.How it works
Script plugins
A
plugin.jsonmanifest declares commands with typed arguments, options, and a C# handler script. The handler runs on Unity's main thread with full access to UnityEngine/UnityEditor APIs.Executable plugins
Place any executable named
unityctl-<name>in.unityctl/plugins/or on PATH. All arguments are passed through; bridge connection info is injected via environment variables (UNITYCTL_BRIDGE_URL,UNITYCTL_PROJECT_PATH, etc.).Executables in plugin directories appear in
--helpat startup. PATH executables are resolved lazily at invocation time (git-style fallback).Plugin locations
.unityctl/plugins/~/.unityctl/plugins/Skill composition
Plugins can provide AI documentation via custom
SKILL-SECTION.mdfiles or auto-generated docs from the manifest. The newskill rebuildcommand composes the final SKILL.md from:UnityCtl.Cli/Resources/SKILL.md).unityctl/skill-extra.mdDesign decisions
script.executeRPC; executable plugins are spawned as child processesskill-extra.md, behavior is identical to beforeManagement commands
New files
PluginManifest.csplugin.jsonschema)PluginLoader.csPluginCommands.csplugin list/create/removemanagement commandsExecutablePluginLoader.csContextHelper.csInvocationContextResources/SKILL.mdResources/SKILL.plugins.md.unityctl/plugins/sample-*Test plan
dotnet buildsucceedsplugin createscaffolds valid plugin with manifest + example scriptplugin listdiscovers and displays plugins from both sources (script + executable)--helpand accept declared args/optionsplugin removecleans up plugin directoryskill rebuildproduces composed SKILL.md with plugin sections appended