Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
27 changes: 27 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# AGENTS.md

## Build/Lint/Test Commands

This is a Python script project with no complex build process.

- Lint: `ruff check .` or `pylint open-unity.py`
- Test: No automated tests currently exist; manual testing via `python open-unity.py` is recommended. The `tests/projects-v1.json` file is a copy from `Unity Hub` (v`3.15.3`) with anonymized data, preserving the structure for testing.

## Code Style Guidelines

- Python code follows PEP8 style guidelines
- Use 4 spaces for indentation
- Import statements should be grouped (standard library, third-party, local)
- Function and variable names use snake_case
- Class names use PascalCase
- Error handling with try/except blocks where appropriate
- Docstrings for all functions
- Type hints should be included for function parameters and return values

## Special Notes

- This is a simple Python command-line utility for macOS
- The script is designed to be easily customizable
- Uses pathlib for path operations
- ANSI color codes for error messages in terminal output
- Supports command-line arguments passed to Unity editor
95 changes: 58 additions & 37 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,60 +1,81 @@
# Unity Command Line Launcher

<img width="1502" alt="image" src="https://github.com/chrisyarbrough/UnityCommandLineLauncher/assets/17833862/f07c7396-b0da-4ce4-bfef-e9103de8c976">

A lightweight command-line script designed to speedup opening Unity projects. With a single command, this script launches a Unity project directly from within the project's directory tree, bypassing the Unity Hub for enhanced speed and convenience. It's tailored for developers who prefer the efficiency of a terminal-based approach.

Recommended workflow:
1) Open your Unity project in your IDE from the recent projects list
2) Use a keyboard shortcut to open the command line window in the open Unity project working directory
3) Use the launcher command to open the Unity project
The script now also supports opening recent projects from Unity Hub, with a fuzzy finder for quick access.

This approach is much quicker than:
1) Waiting for the slow Unity Hub to start
2) Selecting the Unity project in the list and waiting for it to open
3) Double-clicking a script or selecting _Assets > Open C# Project_ in Unity to open the IDE
<img width="1502" alt="image" src="https://user-images.githubusercontent.com/17833862/233826202-82216b5f-5323-424b-82b2-725b63f68c3e.png">

# Support
The script currently only supports macOS and was tested on Ventura 13.4.
## Support

# Setup
- Install Python 3
- Place the launcher script anywhere on your machine
- Create a globally available command alias that points to the script. For example, for the ZSH shell, add `alias unity="~/bin/open-unity.py"` to the `.zshrc` file.
The script currently only supports macOS.

```bash
echo 'alias unity="~/bin/open-unity.py"' >> ~/.zshrc
```
## Setup

1. **Install Python 3:** The script is written in Python.
2. **Install `fzf` (Recommended):** For a better project selection UI, install `fzf`.

```bash
brew install fzf
```

`fzf` is a powerful command-line fuzzy finder. Learn more at [github.com/junegunn/fzf](https://github.com/junegunn/fzf).

3. **Place the script:** Put the `open-unity.py` script in a memorable location, like `~/bin/`.

4. **Create an alias:** Make the script easily accessible from your terminal by adding an alias to your shell's configuration file (e.g., `.zshrc` or `.bash_profile`).

```zsh
# Add this line to your ~/.zshrc
alias unity="~/bin/open-unity.py"
```

Alternatively, you can add it by running this command:

```zsh
echo 'alias unity="~/bin/open-unity.py"' >> ~/.zshrc
```

Another option is to rename the script to `unity` and move it to a directory in your `PATH`.

## Customization

The script is intended to be used in source form so that it can be easily customized. For example, you can update the directories searched for the Unity project or change the default command-line arguments passed to Unity.

Alternatively, rename the script to _unity_ and place it in a directory that is part of the PATH shell variable.
## Design

# Usage
Open your IDE (e.g. Rider or Visual Studio) with the desired Unity project or any command line session in one of the following directories:
- The Unity project root directory (which contains Assets, Library and ProjectSettings)
- Any direct child directory (e.g. Assets, Library, ProjectSettings)
- A directory which contains the directory "frontend" which is the Unity project root
For more details on the design of the command-line interface, see the [CLI Design Document](docs/cli-design.md).

Invoke the `unity` command alias to start Unity and open the current project:
## Usage

```bash
The script has two main modes of operation:

### 1. Open Recent Projects (Default)

When run without any arguments, the script displays a list of your recent Unity projects, fetched from the Unity Hub's data.

```zsh
unity
```

Pass additional arguments via the command:
This will open an interactive selector. If you have `fzf` (a command-line fuzzy finder) installed, you'll get a powerful search interface. Otherwise, it will present a simple numbered list of the 10 most recent projects.

```bash
unity -force-metal
```
You can also explicitly trigger this mode with the `--recent` flag:

Find the available Unity editor command line arguments in the [official documentation](https://docs.unity3d.com/Manual/EditorCommandLineArguments.html).
```zsh
unity --recent
```

# Customization
The script is intended to be used in source form so that it can be easily customized, e.g. update the directories that are searched for the Unity project or change the command line arguments passed to Unity by default.
### 2. Open Project from Current Directory

## Add an alias to .zshrc
To open a Unity project located in the current directory (or a parent directory), pass any argument to the script. This maintains the original behavior of the tool.

You can add an `alias` to `unity` - just add this to your `.zshrc`
A common use case is to pass arguments directly to the Unity Editor:

```zsh
alias unity="~/bin/open-unity.py
unity -force-metal
```

This is useful when you are already in a project's directory and want to open it with specific command-line flags.

Find the available Unity editor command line arguments in the [official documentation](https://docs.unity3d.com/Manual/EditorCommandLineArguments.html).
52 changes: 52 additions & 0 deletions docs/cli-design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# Proposal: New CLI Behavior for `open-unity`

This document outlines the proposed changes to the command-line interface for the `open-unity` script to incorporate the new feature of opening recent projects.

## Guiding Principle

The primary goal is to make the most common workflow—opening a recent project—as fast and intuitive as possible, while retaining the original functionality for opening a project from the current directory.

## Proposed CLI Design

The script will operate in one of two modes based on the arguments provided.

### 1. Default Mode: Open Recent Project (No Arguments)

When the script is executed without any arguments, it will be the primary and default behavior.

```bash
open-unity
```

**Action:**

1. The script will read the `projects-v1.json` file from Unity Hub's application support directory.
2. It will parse the list of projects, sort them by the `lastModified` date (most recent first).
3. It will display an interactive selector to the user:
- **If `fzf` is installed:** Use `fzf` to show a fuzzy-findable list of recent projects. This provides the best user experience.
- **If `fzf` is not installed:** Fall back to a simple, numbered list of the top 10 recent projects, prompting the user to enter a number.
4. Once a project is selected, the script will launch the corresponding Unity Editor version with the selected project path.

### 2. Legacy Mode: Open Project from Current Directory (With Arguments)

When the script is executed with a valid directory path as its first argument, it will trigger the legacy behavior. This path is mandatory to enter this mode. Any additional arguments will be forwarded to the Unity Editor.

```bash
# Open project in the current directory
open-unity .

# Open project in a specific directory
open-unity /path/to/my/project

# Open project in the current directory with extra arguments for Unity
open-unity . -force-metal -batchmode
```

**Action:**

1. The script will check if the first argument is a valid directory path (e.g., `.` or `/path/to/project`).
2. If it is a valid path, it will look for a `ProjectSettings/ProjectVersion.txt` file directly within that path. The script will no longer search parent or common subdirectories.
3. If a project is found, it will launch the corresponding Unity Editor.
4. All subsequent arguments (`-force-metal`, `-batchmode`, etc.) are forwarded directly to the Unity Editor executable.
5. If no arguments are provided, or if the first argument is not a valid directory path (e.g., it's a flag like `-h`), the script will default to showing the recent projects list.
This design makes opening recent projects the default, most accessible feature, while a simple argument like `.` or any Unity flag cleanly switches to the context-sensitive local project opening.
135 changes: 109 additions & 26 deletions open-unity.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,60 +4,145 @@
import sys
import re
import subprocess
import json
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
from typing import Optional

@dataclass
class UnityProject:
"""Represents a Unity project entry from Unity Hub's data."""
path: Path
title: str
version: str
last_modified: Optional[datetime]

@staticmethod
def from_json(json_data: dict):
last_modified_timestamp = json_data.get("lastModified")
last_modified = None
if last_modified_timestamp:
# The lastModified timestamp is in milliseconds, so we convert it to seconds.
last_modified = datetime.fromtimestamp(last_modified_timestamp / 1000)

return UnityProject(
path=Path(json_data["path"]),
title=json_data["title"],
version=json_data["version"],
last_modified=last_modified,
)

def get_recent_projects():
"""Load and return recent Unity projects from Unity Hub."""
unity_hub_projects_file = Path.home() / "Library/Application Support/UnityHub/projects-v1.json"
if not unity_hub_projects_file.is_file():
return []

with open(unity_hub_projects_file, "r") as file:
projects_data = json.load(file)

projects = [
UnityProject.from_json(data)
for data in projects_data.get("data", {}).values()
if is_valid_project_data(data)
]

# Sort projects by last modified date, most recent first.
projects.sort(key=lambda p: p.last_modified, reverse=True)
return projects

def main():
# Starting from the current working directory, search in multiple locations for the Unity ProjectVersion file.
current_directory = Path.cwd()
project_settings_file = Path('ProjectSettings') / 'ProjectVersion.txt'
project_settings_paths = [
current_directory / project_settings_file,
current_directory / 'frontend' / project_settings_file,
current_directory / '..' / project_settings_file
]
def is_valid_project_data(data: dict) -> bool:
"""Check if project data contains all required fields."""
required_fields = ["path", "title", "version", "lastModified"]
return all(field in data and data.get(field) for field in required_fields)

project_settings_path = None
def main():
# If a path is provided as the first argument, open that project.
# Otherwise, show the recent projects list.
if len(sys.argv) > 1 and (sys.argv[1] == '.' or os.path.isdir(sys.argv[1])):
project_path = Path(sys.argv[1]).resolve()
extra_args = sys.argv[2:]
open_project_from_path(project_path, extra_args)
else:
open_recent_project()

def open_recent_project():
recent_projects = get_recent_projects()
if not recent_projects:
exit_with_error("Couldn't find any recent Unity projects.", 1)

project = show_project_selection(recent_projects)
if project:
launch_unity(project.path, project.version)

def show_project_selection(projects):
try:
# Use fzf for a better selection UI, if available.
project_strings = [f"{p.title} ({p.path.parent.name}) - {p.version}" for p in projects]
fzf_process = subprocess.run(
['fzf', '--height', '40%', '--reverse', '--prompt', 'Select a Unity Project> '],
input='\n'.join(project_strings),
capture_output=True,
text=True,
check=True
)
selected_string = fzf_process.stdout.strip()
if selected_string:
selected_index = project_strings.index(selected_string)
return projects[selected_index]
except (FileNotFoundError, subprocess.CalledProcessError):
# Fallback to a simple numbered list if fzf is not installed or fails.
print("Recent projects:")
for i, project in enumerate(projects[:10]):
print(f" {i + 1}: {project.title} ({project.version})")
try:
selection = int(input("Select a project (1-10): "))
if 1 <= selection <= len(projects[:10]):
return projects[selection - 1]
except (ValueError, IndexError):
exit_with_error("Invalid selection.", 1)
return None

for path in project_settings_paths:
if path.is_file():
project_settings_path = path.resolve()
break
def open_project_from_path(project_path: Path, args: list):
# Check for the Unity ProjectVersion file in the specified path.
project_settings_file = project_path / 'ProjectSettings' / 'ProjectVersion.txt'

if not project_settings_path:
paths_string = '\n'.join(str(path.resolve()) for path in project_settings_paths)
exit_with_error(f"Couldn't find ProjectSettings file in:\n{paths_string}", 1)
if not project_settings_file.is_file():
exit_with_error(f"Couldn't find ProjectSettings/ProjectVersion.txt in:\n{project_path}", 1)

unity_version = find_version(project_settings_path)
unity_version = find_version(project_settings_file)

if unity_version is None:
exit_with_error("Couldn't find Unity version in ProjectSettings file", 2)

launch_unity(project_path, unity_version, args)

def launch_unity(project_path: Path, unity_version: str, extra_args: list = None):
if extra_args is None:
extra_args = []

unity_editor_path = f"/Applications/Unity/Hub/Editor/{unity_version}/Unity.app/Contents/MacOS/Unity"

if not os.path.isfile(unity_editor_path):
exit_with_error(f"Couldn't find Unity Editor installation at {unity_editor_path}", 3)

# The project path is the one which contains the Assets, Library and ProjectSettings folders.
project_path = str(project_settings_path.parent.parent)

# Invoke the Unity Editor with the found project path and some global arguments.
command = [
unity_editor_path,
'-projectPath', project_path,
'-projectPath', str(project_path),
'-cacheServerEnableDownload', 'false',
'-cacheServerEnableUpload', 'false',
]

# Add any custom arguments passed to this script.
command.extend(sys.argv[1:])
command.extend(extra_args)

print(f"Starting Unity {unity_version} with arguments: {' '.join(command[1:])}")

# Start a new process which continues to live even after the shell is closed.
subprocess.Popen(command)


def find_version(project_settings_path):
# Extract the Unity version from the ProjectSettings file.
version_pattern = re.compile(r'm_EditorVersion: (.*)')
Expand All @@ -68,7 +153,6 @@ def find_version(project_settings_path):
return match.group(1)
return None


def exit_with_error(message: str, code: int):
if sys.stderr.isatty():
# Use ANSI colors in terminal output, but not when piping to a file.
Expand All @@ -78,6 +162,5 @@ def exit_with_error(message: str, code: int):
sys.stderr.write(message + '\n')
sys.exit(code)


if __name__ == '__main__':
main()
Loading