Skip to content

Add plugin system (script + executable)#37

Merged
Niaobu merged 21 commits intomainfrom
plugin-system
Mar 16, 2026
Merged

Add plugin system (script + executable)#37
Niaobu merged 21 commits intomainfrom
plugin-system

Conversation

@Niaobu
Copy link
Copy Markdown
Contributor

@Niaobu Niaobu commented Mar 13, 2026

Summary

Adds a plugin system that lets users extend unityctl with custom commands — no changes to the bridge or Unity plugin required.

Two plugin types:

  • Script plugins — C# scripts executed inside Unity via the existing script.execute RPC. Best for commands that need Unity APIs (scene queries, asset manipulation, etc.).
  • Executable plugins — any 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.json manifest 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.

.unityctl/plugins/scene-stats/
  plugin.json       # manifest: commands, args, options, handler mapping
  stats.cs          # C# script executed in Unity
  SKILL-SECTION.md  # optional custom AI documentation
# Scaffold a new plugin
unityctl plugin create scene-stats

# Use it (dispatches to Unity via bridge)
unityctl scene-stats stats --verbose
unityctl scene-stats stats MainScene --format json

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.).

# A bash script saved as .unityctl/plugins/unityctl-smoke
unityctl smoke 30          # runs unityctl-smoke with arg "30"
unityctl deploy --staging  # runs unityctl-deploy with arg "--staging"

Executables in plugin directories appear in --help at startup. PATH executables are resolved lazily at invocation time (git-style fallback).

Plugin locations

Level Directory Precedence
Project .unityctl/plugins/ Higher (shareable via git)
User ~/.unityctl/plugins/ Lower (personal tools)

Skill composition

Plugins can provide AI documentation via custom SKILL-SECTION.md files or auto-generated docs from the manifest. The new skill rebuild command composes the final SKILL.md from:

  1. Base embedded skill (source of truth in UnityCtl.Cli/Resources/SKILL.md)
  2. Auto-generated plugin command sections
  3. Optional user-provided .unityctl/skill-extra.md

Design decisions

  • Zero bridge/Unity changes — script plugins reuse script.execute RPC; executable plugins are spawned as child processes
  • Precedence: built-in > script plugin > executable plugin; project-level > user-level > PATH
  • Security: handler file paths validated against path traversal; plugins that conflict with built-in command names are skipped with a warning
  • Idempotent: with no plugins and no skill-extra.md, behavior is identical to before

Management commands

unityctl plugin list              # list all discovered plugins (script + executable)
unityctl plugin create <name>     # scaffold a script plugin (project-level)
unityctl plugin create <name> -g  # scaffold at user level (~/.unityctl/plugins/)
unityctl plugin remove <name>     # remove a script plugin
unityctl skill rebuild            # recompose SKILL.md with plugin docs

New files

File Purpose
PluginManifest.cs Plugin manifest model (plugin.json schema)
PluginLoader.cs Discovers script plugins, creates System.CommandLine commands, generates skill sections
PluginCommands.cs plugin list/create/remove management commands
ExecutablePluginLoader.cs Discovers executable plugins, PATH fallback, process spawning with env vars
ContextHelper.cs Shared helpers for extracting global options from InvocationContext
Resources/SKILL.md Base skill (embedded resource, source of truth)
Resources/SKILL.plugins.md Claude Code skill for plugin authoring guidance
.unityctl/plugins/sample-* Sample plugins demonstrating both types

Test plan

  • dotnet build succeeds
  • All 261 existing tests pass
  • plugin create scaffolds valid plugin with manifest + example script
  • plugin list discovers and displays plugins from both sources (script + executable)
  • Dynamic plugin commands appear in --help and accept declared args/options
  • plugin remove cleans up plugin directory
  • skill rebuild produces composed SKILL.md with plugin sections appended
  • JSON output mode works for all new commands
  • Plugin with name matching built-in command is skipped gracefully
  • End-to-end: plugin command executes script in Unity via bridge
  • End-to-end: executable plugin receives env vars and passes exit code through

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
@Niaobu

This comment was marked as outdated.

Niaobu added 2 commits March 15, 2026 11:18
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.
claude and others added 2 commits March 15, 2026 12:03
- 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.
@Niaobu Niaobu changed the title Add script-based plugin system Add plugin system (script + executable) Mar 15, 2026
Niaobu added 3 commits March 15, 2026 14:34
- 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 added 8 commits March 15, 2026 18:38
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.
@Niaobu Niaobu marked this pull request as ready for review March 15, 2026 19:13
Niaobu added 2 commits March 15, 2026 20:25
…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
@Niaobu Niaobu self-assigned this Mar 15, 2026
Niaobu added 3 commits March 15, 2026 20:44
…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.
@Niaobu Niaobu merged commit 2b08606 into main Mar 16, 2026
1 check passed
@Niaobu Niaobu deleted the plugin-system branch March 24, 2026 12:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants