Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
e3a2e46
Add script-based plugin system with composed SKILL.md
Niaobu Mar 13, 2026
d1cb2a7
Add executable plugin support (git/kubectl convention)
Niaobu Mar 15, 2026
d6ccb38
Add unityctl-plugins skill for plugin authoring guidance
Niaobu Mar 15, 2026
77b5c6b
Simplify plugin system: reduce duplication and improve efficiency
claude Mar 15, 2026
c9c9a5b
Add path traversal guard and built-in command collision check for plu…
Niaobu Mar 15, 2026
aeefdbe
Fix plugin system review issues: security, validation, and performance
Niaobu Mar 15, 2026
7b38b22
Fix lazy PATH fallback for .bat/.cmd scripts on Windows
Niaobu Mar 15, 2026
3a7783c
Update plugin skill docs: name validation, remove --force, lazy PATH
Niaobu Mar 15, 2026
c6ea1be
Add plugin system tests and sample plugins
Niaobu Mar 15, 2026
276e21d
Move base skill to Resources/, make .claude/skills/ the composed output
Niaobu Mar 15, 2026
749f714
Document skill editing workflow in CLAUDE.md
Niaobu Mar 15, 2026
8bfc40b
Remove --force flag and confirmation prompt from plugin remove
Niaobu Mar 15, 2026
3858afc
Update plugin skill docs: remove --force flag from plugin remove
Niaobu Mar 15, 2026
dea401a
Refactor plugin system: fix global options bypass, extract shared hel…
Niaobu Mar 15, 2026
1a019a7
Fix plugin system review issues: UX, timeout forwarding, handler vali…
Niaobu Mar 15, 2026
b3087a2
Fix path traversal test failing on Linux
Niaobu Mar 15, 2026
cc667cd
Fix plugin system review issues: exit codes, validation, dead code, t…
Niaobu Mar 15, 2026
bdb83e9
Simplify plugin system: dedup helpers, fix tests, clean up templates
Niaobu Mar 15, 2026
6dbd5ff
Rebuild composed SKILL.md to include sample-exec plugin section
Niaobu Mar 15, 2026
892715a
Update README with plugin system docs and skill install note
Niaobu Mar 16, 2026
df74ec2
Fix executable plugins on Windows: route shebang scripts through Git …
Niaobu Mar 16, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .claude/skills/unity-editor/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -174,3 +174,19 @@ Run `unityctl status` first to diagnose issues.
| Command timed out | A native dialog may be blocking Unity: `unityctl dialog list` |
| Progress bar stuck | Check with `unityctl dialog list`, wait or dismiss |
| Editor not found | Use `--unity-path` to specify Unity executable |

## Plugin Commands

### Plugin: sample-script

Sample script plugin demonstrating the plugin system

- `unityctl sample-script hello [name] [--loud]`
Say hello from Unity

### Plugin: sample-exec (executable)

A sample executable plugin that prints connection info. Demonstrates the `unityctl-{name}` naming convention.

- `unityctl sample-exec [args...]`
Prints greeting and bridge connection details.
153 changes: 153 additions & 0 deletions .claude/skills/unityctl-plugins/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
---
name: unityctl-plugins
description: Create unityctl plugins. Use when the user wants to create, scaffold, or write a unityctl plugin (script or executable).
---

# Creating unityctl Plugins

There are two plugin types: **script** (C# running inside Unity) and **executable** (any program on disk).

## Script Plugins

Run C# inside the Unity Editor via the `script.execute` RPC. Best for commands that need Unity APIs.

### Quick start

```bash
unityctl plugin create my-tool # scaffolds .unityctl/plugins/my-tool/
```

### Structure

```
.unityctl/plugins/my-tool/
plugin.json # manifest
hello.cs # handler script
```

### plugin.json

```json
{
"name": "my-tool",
"version": "1.0.0",
"description": "My custom tool",
"commands": [
{
"name": "stats",
"description": "Show scene stats",
"arguments": [
{ "name": "scene", "description": "Scene name", "required": false }
],
"options": [
{ "name": "verbose", "type": "bool", "description": "Show details" },
{ "name": "format", "type": "string", "description": "Output format" }
],
"handler": { "type": "script", "file": "stats.cs" }
}
],
"skill": { "file": "SKILL.md" }
}
```

### Handler script

Must define `public class Script` with `public static object Main(string[] args)`. Arguments and options are passed as the `args` array.

```csharp
using UnityEngine;
using UnityEditor;

public class Script
{
public static object Main(string[] args)
{
var count = Object.FindObjectsByType<GameObject>(FindObjectsSortMode.None).Length;
return $"Scene has {count} GameObjects";
}
}
```

The return value is sent back as the command output. The script runs on Unity's main thread and has access to all Unity and UnityEditor APIs.

### Custom skill documentation

Add a `"skill": { "file": "SKILL.md" }` entry to plugin.json and create the markdown file. It will be included in the composed SKILL.md when running `unityctl skill rebuild`. Without it, documentation is auto-generated from the manifest.

### Location

| Level | Directory | Precedence |
|-------|-----------|------------|
| Project | `.unityctl/plugins/` | Higher |
| User | `~/.unityctl/plugins/` | Lower |

Use `--global` / `-g` with `plugin create` to scaffold at user level.

Plugin names must be lowercase alphanumeric with hyphens (e.g. `my-tool`, `scene-stats`). Must start and end with a letter or digit.

## Executable Plugins

Any executable named `unityctl-<name>` becomes available as `unityctl <name>`. Best for workflows outside Unity: build pipelines, CI scripts, multi-step orchestration.

### How it works

Place an executable (shell script, Python, Go binary, .bat/.cmd/.ps1 on Windows) named `unityctl-<name>` in `.unityctl/plugins/` or on PATH. All arguments after the command name are passed through.

Executables in plugin directories are registered at startup (appear in `--help`). Executables on PATH are resolved lazily when invoked — like `git` resolving `git foo` → `git-foo`.

```bash
unityctl smoke 30 # finds and runs unityctl-smoke with arg "30"
unityctl deploy --staging # finds and runs unityctl-deploy with arg "--staging"
```

### Environment variables

The CLI sets these before launching the executable:

| Variable | Description |
|----------|-------------|
| `UNITYCTL_PROJECT_PATH` | Resolved Unity project root |
| `UNITYCTL_BRIDGE_PORT` | Bridge HTTP port |
| `UNITYCTL_BRIDGE_URL` | Full bridge URL (e.g. `http://localhost:62908`) |
| `UNITYCTL_AGENT_ID` | Agent ID if `--agent-id` was passed |
| `UNITYCTL_JSON` | `"1"` if `--json` was passed |

### Example

```bash
#!/usr/bin/env bash
# Save as: unityctl-smoke (chmod +x)
unityctl logs clear
unityctl play enter
sleep "${1:-10}"
unityctl screenshot capture --json > /tmp/smoke.json
ERRORS=$(unityctl logs -n 1000 --json | jq '[.entries[] | select(.level == "Error")] | length')
unityctl play exit
[ "$ERRORS" -eq 0 ] && echo "PASS" || { echo "FAIL: $ERRORS error(s)"; exit 1; }
```

### Companion skill file

Place `unityctl-<name>.skill.md` next to the executable to provide custom documentation for SKILL.md composition. Without it, a minimal section is auto-generated.

### Platform notes

- **Windows**: matches `.exe`, `.cmd`, `.bat`, `.ps1` extensions
- **Unix**: matches any file with execute permission; extensions are stripped from the command name (`unityctl-foo.sh` becomes `unityctl foo`)

## Precedence

built-in command > script plugin > executable plugin. A plugin cannot shadow a built-in command.

## After changes

Run `unityctl skill rebuild` to update the composed SKILL.md with plugin documentation.

## Management commands

```bash
unityctl plugin list # list all plugins (script + executable)
unityctl plugin create <name> # scaffold a script plugin
unityctl plugin create <name> -g # scaffold at user level (~/.unityctl/plugins/)
unityctl plugin remove <name> # remove a script plugin
```
14 changes: 14 additions & 0 deletions .unityctl/plugins/sample-script/hello.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
using UnityEngine;

public class Script
{
public static object Main(string[] args)
{
var name = args.Length > 0 ? args[0] : "World";
var loud = System.Array.Exists(args, a => a == "--loud");
var greeting = $"Hello, {name}!";
if (loud) greeting = greeting.ToUpper();
Debug.Log(greeting);
return greeting;
}
}
18 changes: 18 additions & 0 deletions .unityctl/plugins/sample-script/plugin.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
{
"name": "sample-script",
"version": "1.0.0",
"description": "Sample script plugin demonstrating the plugin system",
"commands": [
{
"name": "hello",
"description": "Say hello from Unity",
"arguments": [
{ "name": "name", "description": "Name to greet", "required": false }
],
"options": [
{ "name": "loud", "type": "bool", "description": "Shout the greeting" }
],
"handler": { "type": "script", "file": "hello.cs" }
}
]
}
18 changes: 18 additions & 0 deletions .unityctl/plugins/unityctl-sample-exec
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
#!/usr/bin/env bash
# Sample executable plugin demonstrating the unityctl-{name} convention.
# When discovered, this becomes: unityctl sample-exec [args...]
#
# Environment variables injected by unityctl:
# UNITYCTL_PROJECT_PATH — Unity project root
# UNITYCTL_BRIDGE_PORT — Bridge HTTP port
# UNITYCTL_BRIDGE_URL — Bridge base URL
# UNITYCTL_AGENT_ID — Agent identifier (if set)
# UNITYCTL_JSON — "1" when --json flag is active

if [ "$UNITYCTL_JSON" = "1" ]; then
echo "{\"message\":\"Hello from sample-exec plugin\",\"project\":\"${UNITYCTL_PROJECT_PATH}\"}"
else
echo "Hello from sample-exec plugin!"
[ -n "$UNITYCTL_PROJECT_PATH" ] && echo "Project: $UNITYCTL_PROJECT_PATH"
[ -n "$UNITYCTL_BRIDGE_URL" ] && echo "Bridge: $UNITYCTL_BRIDGE_URL"
fi
6 changes: 6 additions & 0 deletions .unityctl/plugins/unityctl-sample-exec.skill.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
### Plugin: sample-exec (executable)

A sample executable plugin that prints connection info. Demonstrates the `unityctl-{name}` naming convention.

- `unityctl sample-exec [args...]`
Prints greeting and bridge connection details.
13 changes: 12 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,18 @@ dotnet test

## Skill

Claude Code skill for AI integration: `.claude/skills/unity-editor/SKILL.md`
The Claude Code skill has two layers:

- **Base skill**: `UnityCtl.Cli/Resources/SKILL.md` — the source of truth, embedded into the CLI assembly. Edit this file when adding or changing CLI commands.
- **Composed skill**: `.claude/skills/unity-editor/SKILL.md` — generated output (base + plugin docs + user extra). This is what Claude Code loads. Committed so it works on clone.

After editing the base skill or changing plugins, regenerate the composed output:

```bash
./uc skill add --force # or: ./uc skill rebuild
```

**Do not edit `.claude/skills/unity-editor/SKILL.md` directly** — it will be overwritten by the next rebuild.

## Notes

Expand Down
21 changes: 20 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,9 +114,27 @@ This installs the Unity package and Claude Code skill. If run outside a Unity pr
| `unityctl config set/get/list` | Manage configuration |
| `unityctl package add/remove/status` | Manage Unity package |
| `unityctl skill add/remove/status` | Manage Claude Code skill |
| `unityctl skill rebuild` | Rebuild skill with plugin docs included |
| `unityctl plugin list` | List discovered plugins |
| `unityctl plugin create <name>` | Scaffold a new script plugin |
| `unityctl plugin remove <name>` | Remove a script plugin |

Run `unityctl --help` for the full command list.

## Plugins

Extend unityctl with custom commands — no changes to the bridge or Unity plugin required.

- **Script plugins** — C# scripts executed inside Unity via the existing `script.execute` RPC. Best for commands that need Unity APIs.
- **Executable plugins** — any `unityctl-<name>` binary on PATH or in `.unityctl/plugins/`, following the git/kubectl convention. Best for orchestration and CI workflows.

```bash
unityctl plugin create scene-stats # scaffold a script plugin
unityctl scene-stats stats # use it like a built-in command
```

Plugins are discovered from `.unityctl/plugins/` (project-level) and `~/.unityctl/plugins/` (user-level). Their docs are automatically included in the Claude Code skill when you run `unityctl setup` or `unityctl skill rebuild`.

## Architecture

```
Expand All @@ -142,7 +160,8 @@ The API is kept simple, and leans mostly on the script execution to get work don
- [ARCHITECTURE.md](ARCHITECTURE.md) - Technical details
- [CONTRIBUTING.md](CONTRIBUTING.md) - Development setup
- [TROUBLESHOOTING.md](TROUBLESHOOTING.md) - Common issues
- [.claude/skills/unity-editor/SKILL.md](.claude/skills/unity-editor/SKILL.md) - AI assistant skill file

A Claude Code skill is installed as part of `unityctl setup` and kept up to date by `unityctl update`. It teaches Claude how to use the CLI without consuming context window on every task.

## License

Expand Down
38 changes: 13 additions & 25 deletions UnityCtl.Cli/ContextHelper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,36 +11,24 @@ namespace UnityCtl.Cli;

internal static class ContextHelper
{
public static string? GetProjectPath(InvocationContext context)
{
var parseResult = context.ParseResult;
var rootCommand = parseResult.RootCommandResult.Command;
var option = rootCommand.Options.FirstOrDefault(o => o.Name == "project") as Option<string>;
return option != null ? parseResult.GetValueForOption(option) : null;
}
public static string? GetProjectPath(InvocationContext context) =>
GetGlobalOption<string>(context, "project");

public static string? GetAgentId(InvocationContext context)
{
var parseResult = context.ParseResult;
var rootCommand = parseResult.RootCommandResult.Command;
var option = rootCommand.Options.FirstOrDefault(o => o.Name == "agent-id") as Option<string>;
return option != null ? parseResult.GetValueForOption(option) : null;
}
public static string? GetAgentId(InvocationContext context) =>
GetGlobalOption<string>(context, "agent-id");

public static bool GetJson(InvocationContext context)
{
var parseResult = context.ParseResult;
var rootCommand = parseResult.RootCommandResult.Command;
var option = rootCommand.Options.FirstOrDefault(o => o.Name == "json") as Option<bool>;
return option != null && parseResult.GetValueForOption(option);
}
public static bool GetJson(InvocationContext context) =>
GetGlobalOption<bool>(context, "json");

public static int? GetTimeout(InvocationContext context) =>
GetGlobalOption<int?>(context, "timeout");

public static int? GetTimeout(InvocationContext context)
private static T? GetGlobalOption<T>(InvocationContext context, string name)
{
var parseResult = context.ParseResult;
var rootCommand = parseResult.RootCommandResult.Command;
var option = rootCommand.Options.FirstOrDefault(o => o.Name == "timeout") as Option<int?>;
return option != null ? parseResult.GetValueForOption(option) : null;
var option = parseResult.RootCommandResult.Command.Options
.FirstOrDefault(o => o.Name == name) as Option<T>;
return option != null ? parseResult.GetValueForOption(option) : default;
}

public static bool? GetResultBool(ResponseMessage response, string key)
Expand Down
Loading
Loading