diff --git a/.run/devReload.run.xml b/.run/devReload.run.xml
new file mode 100644
index 0000000..39c2bb0
--- /dev/null
+++ b/.run/devReload.run.xml
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/README.md b/README.md
index e0f5ef9..f1c92d1 100644
--- a/README.md
+++ b/README.md
@@ -11,6 +11,9 @@ Edit-run loop:
1. Run `./gradlew installPlugin` to build the plugin and install it directly to Toolbox.
2. Restart the Toolbox to apply the installed plugin.
+Alternatively, run `./gradlew :plugin:devReload` to build, install, and restart the Toolbox in a single step.
+Add `-PskipToolboxRestart=true` to skip the restart, or `-PtoolboxExecutable=/path/to/jetbrains-toolbox` to point at a custom Toolbox executable.
+
To check the logs:
1. Go to the `About` page.
2. Click `Show log files`.
diff --git a/build-logic/build.gradle.kts b/build-logic/build.gradle.kts
index 28e1f16..a83c7e1 100644
--- a/build-logic/build.gradle.kts
+++ b/build-logic/build.gradle.kts
@@ -33,6 +33,12 @@ gradlePlugin {
displayName = "Install Toolbox Plugin"
description = "Installs the plugin into the local Toolbox directory"
}
+ create("toolboxDev") {
+ id = "com.redhat.devtools.toolbox.dev"
+ implementationClass = "com.redhat.devtools.toolbox.buildlogic.DevToolboxPlugin"
+ displayName = "Dev Reload for Toolbox Plugin"
+ description = "Registers quit, launch, and dev-reload tasks to restart the local JetBrains Toolbox"
+ }
create("toolboxPublish") {
id = "com.redhat.devtools.toolbox.publish"
implementationClass = "com.redhat.devtools.toolbox.buildlogic.PublishToolboxPlugin"
diff --git a/build-logic/src/main/kotlin/toolbox/buildlogic/DevToolboxPlugin.kt b/build-logic/src/main/kotlin/toolbox/buildlogic/DevToolboxPlugin.kt
new file mode 100644
index 0000000..b2f6cb8
--- /dev/null
+++ b/build-logic/src/main/kotlin/toolbox/buildlogic/DevToolboxPlugin.kt
@@ -0,0 +1,112 @@
+/*
+ * Copyright (c) 2026 Red Hat, Inc.
+ * This program and the accompanying materials are made
+ * available under the terms of the Eclipse Public License 2.0
+ * which is available at https://www.eclipse.org/legal/epl-2.0/
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ *
+ * Contributors:
+ * Red Hat, Inc. - initial API and implementation
+ */
+package com.redhat.devtools.toolbox.buildlogic
+
+import java.nio.file.Path
+import org.gradle.api.DefaultTask
+import org.gradle.api.GradleException
+import org.gradle.api.Plugin
+import org.gradle.api.Project
+import org.gradle.api.provider.Property
+import org.gradle.api.tasks.Internal
+import org.gradle.api.tasks.TaskAction
+import org.gradle.work.DisableCachingByDefault
+
+class DevToolboxPlugin : Plugin {
+
+ override fun apply(target: Project) {
+ val skipRestart = readFlag(target, "skipToolboxRestart")
+
+ val installTask = target.tasks.named("installPlugin")
+
+ val quitTask = target.tasks.register("quitToolbox", QuitToolboxTask::class.java) {
+ group = "toolbox"
+ description = "Requests the running JetBrains Toolbox application to quit and waits for it to exit"
+ restartDelayMs.set(readDelay(target))
+ skip.set(skipRestart)
+ onlyIf { !skip.get() }
+ mustRunAfter("installPlugin")
+ }
+
+ val launchTask = target.tasks.register("launchToolbox", LaunchToolboxTask::class.java) {
+ group = "toolbox"
+ description = "Launches the JetBrains Toolbox application"
+ skip.set(skipRestart)
+ if (!skipRestart) {
+ executable.set(ToolboxApp.resolveExecutable(target).toString())
+ }
+ onlyIf { !skip.get() }
+ mustRunAfter(quitTask)
+ }
+
+ target.tasks.register("devReload", DevReloadTask::class.java) {
+ group = "toolbox"
+ description = "Builds and installs the plugin, restarts JetBrains Toolbox, and applies the change"
+ dependsOn(installTask, quitTask, launchTask)
+ }
+ }
+
+ private fun readFlag(target: Project, name: String): Boolean =
+ (target.findProperty(name) as? String)?.toBoolean() == true
+
+ private fun readDelay(target: Project): Long =
+ (target.findProperty("toolboxRestartDelayMs") as? String)?.toLongOrNull() ?: 2000L
+
+ @DisableCachingByDefault(because = "Controls the running Toolbox application")
+ abstract class QuitToolboxTask : DefaultTask() {
+ @get:Internal
+ abstract val restartDelayMs: Property
+ @get:Internal
+ abstract val skip: Property
+
+ @TaskAction
+ fun quit() {
+ println("Requesting JetBrains Toolbox to quit...")
+ ToolboxApp.quit()
+ println("Waiting ${restartDelayMs.get()}ms for JetBrains Toolbox to exit...")
+ val exited = ToolboxApp.waitForExit(restartDelayMs.get())
+ if (exited) {
+ println("JetBrains Toolbox quit.")
+ } else {
+ throw GradleException(
+ "JetBrains Toolbox did not exit within ${restartDelayMs.get()}ms of the quit request. " +
+ "The quit request did not finish; launch was not performed. " +
+ "Increase toolboxRestartDelayMs or quit Toolbox manually and re-run."
+ )
+ }
+ }
+ }
+
+ @DisableCachingByDefault(because = "Launches the Toolbox application")
+ abstract class LaunchToolboxTask : DefaultTask() {
+ @get:Internal
+ abstract val skip: Property
+ @get:Internal
+ abstract val executable: Property
+
+ @TaskAction
+ fun launch() {
+ val executable = this.executable.get()
+ println("Launching JetBrains Toolbox: $executable")
+ ToolboxApp.launch(Path.of(executable))
+ println("JetBrains Toolbox launched.")
+ }
+ }
+
+ @DisableCachingByDefault(because = "Orchestrates a Toolbox restart")
+ abstract class DevReloadTask : DefaultTask() {
+ @TaskAction
+ fun devReload() {
+ println("Dev reload complete: plugin installed and JetBrains Toolbox restarted.")
+ }
+ }
+}
diff --git a/build-logic/src/main/kotlin/toolbox/buildlogic/InstallToolboxPlugin.kt b/build-logic/src/main/kotlin/toolbox/buildlogic/InstallToolboxPlugin.kt
index a30d963..62d5115 100644
--- a/build-logic/src/main/kotlin/toolbox/buildlogic/InstallToolboxPlugin.kt
+++ b/build-logic/src/main/kotlin/toolbox/buildlogic/InstallToolboxPlugin.kt
@@ -22,13 +22,13 @@ import org.gradle.api.tasks.Input
import org.gradle.api.tasks.InputFile
import org.gradle.api.tasks.TaskAction
import org.gradle.work.DisableCachingByDefault
-import java.nio.file.Path
-import java.util.*
import kotlin.io.path.div
class InstallToolboxPlugin : Plugin {
override fun apply(target: Project) {
val installTask = target.tasks.register("installPlugin", InstallTask::class.java) {
+ group = "toolbox"
+ description = "Builds and installs the plugin into the local JetBrains Toolbox plugins directory"
extensionId.set(target.group.toString())
extensionJsonFile.set(target.layout.buildDirectory.file("generated/extension.json"))
pluginRuntimeDependencies.from(target.configurations.named("runtimeClasspath"))
@@ -51,22 +51,7 @@ class InstallToolboxPlugin : Plugin {
fun install() {
println("Installing Toolbox plugin...")
project.sync {
- val userHome = System.getProperty("user.home").let { Path.of(it) }
- val os = System.getProperty("os.name").lowercase(Locale.getDefault())
- val toolboxCachesDir = when {
- os.contains("win") -> System.getenv("LOCALAPPDATA")?.let { Path.of(it) } ?: (userHome / "AppData" / "Local")
- os.contains("linux") -> System.getenv("XDG_CACHE_HOME")?.let { Path.of(it) } ?: (userHome / ".cache")
- os.contains("mac") -> userHome / "Library" / "Caches"
- else -> error("Unknown os: $os")
- } / "JetBrains" / "Toolbox"
-
- val pluginsDir = when {
- os.contains("win") -> toolboxCachesDir / "cache"
- os.contains("linux") || os.contains("mac") -> toolboxCachesDir
- else -> error("Unknown os: $os")
- } / "plugins"
-
- val targetDir = pluginsDir / extensionId.get()
+ val targetDir = ToolboxApp.pluginsDir() / extensionId.get()
// Copy jar task output and the generated JSON
from(project.tasks.getByName("jar"))
diff --git a/build-logic/src/main/kotlin/toolbox/buildlogic/ToolboxApp.kt b/build-logic/src/main/kotlin/toolbox/buildlogic/ToolboxApp.kt
new file mode 100644
index 0000000..74b0d98
--- /dev/null
+++ b/build-logic/src/main/kotlin/toolbox/buildlogic/ToolboxApp.kt
@@ -0,0 +1,291 @@
+/*
+ * Copyright (c) 2026 Red Hat, Inc.
+ * This program and the accompanying materials are made
+ * available under the terms of the Eclipse Public License 2.0
+ * which is available at https://www.eclipse.org/legal/epl-2.0/
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ *
+ * Contributors:
+ * Red Hat, Inc. - initial API and implementation
+ */
+package com.redhat.devtools.toolbox.buildlogic
+
+import org.gradle.api.Project
+import java.nio.file.Files
+import java.nio.file.Path
+import kotlin.io.path.div
+import kotlin.io.path.exists
+
+/**
+ * Cross-platform helpers for locating and controlling a local JetBrains Toolbox installation.
+ */
+object ToolboxApp {
+
+ /**
+ * Detects the operating system the current process is running on,
+ * based on the `os.name` system property.
+ */
+ private class OperatingSystem(osName: String = System.getProperty("os.name").orEmpty()) {
+
+ private enum class Type { WINDOWS, LINUX, MAC, UNKNOWN }
+
+ private val type: Type = when {
+ osName.contains("win", ignoreCase = true) -> Type.WINDOWS
+ osName.contains("nix", ignoreCase = true) ||
+ osName.contains("nux", ignoreCase = true) ||
+ osName.contains("aix", ignoreCase = true) -> Type.LINUX
+ osName.contains("mac", ignoreCase = true) ||
+ osName.contains("darwin", ignoreCase = true) -> Type.MAC
+ else -> Type.UNKNOWN
+ }
+
+ val isWindows: Boolean get() = type == Type.WINDOWS
+ val isLinux: Boolean get() = type == Type.LINUX
+ val isMac: Boolean get() = type == Type.MAC
+ }
+
+ private val os = OperatingSystem()
+
+ private fun userHome(): Path = Path.of(System.getProperty("user.home"))
+
+ /**
+ * Directory where Toolbox stores installed plugins:
+ * - Windows: %LOCALAPPDATA%/JetBrains/Toolbox/cache/plugins
+ * - Linux: $XDG_CACHE_HOME (or ~/.cache)/JetBrains/Toolbox/plugins
+ * - macOS: ~/Library/Caches/JetBrains/Toolbox/plugins
+ */
+ fun pluginsDir(): Path {
+ val toolboxDir = when {
+ os.isWindows -> (System.getenv("LOCALAPPDATA")?.let { Path.of(it) } ?: (userHome() / "AppData" / "Local")) / "JetBrains" / "Toolbox"
+ os.isLinux -> (System.getenv("XDG_CACHE_HOME")?.let { Path.of(it) } ?: (userHome() / ".cache")) / "JetBrains" / "Toolbox"
+ os.isMac -> userHome() / "Library" / "Caches" / "JetBrains" / "Toolbox"
+ else -> error("Unknown os: ${System.getProperty("os.name")}")
+ }
+ return when {
+ os.isWindows -> toolboxDir / "cache"
+ else -> toolboxDir
+ } / "plugins"
+ }
+
+ /**
+ * Resolves the path to the Toolbox executable using the following fallback chain:
+ * 1. `-PtoolboxExecutable` Gradle property
+ * 2. `which`/`where` on PATH
+ * 3. Well-known default installation locations
+ */
+ fun resolveExecutable(project: Project): Path {
+ val fromProperty = project.properties["toolboxExecutable"] as? String
+ if (!fromProperty.isNullOrBlank()) {
+ val executable = Path.of(fromProperty)
+ if (!(executable.exists() && Files.isExecutable(executable))) {
+ error(
+ "-PtoolboxExecutable points to '$fromProperty', which does not exist or is not executable. " +
+ "Provide a valid path via -PtoolboxExecutable=/path/to/jetbrains-toolbox"
+ )
+ }
+ return executable
+ }
+
+ val pathCandidates = when {
+ os.isWindows -> listOf("JetBrains Toolbox.exe", "jetbrains-toolbox")
+ else -> listOf("jetbrains-toolbox")
+ }
+ for (candidate in pathCandidates) {
+ which(candidate)?.let { return it }
+ }
+
+ val localAppData = System.getenv("LOCALAPPDATA")?.let { Path.of(it) } ?: (userHome() / "AppData" / "Local")
+ val defaults = when {
+ os.isMac -> listOf(
+ Path.of("/Applications/JetBrains Toolbox.app/Contents/MacOS/jetbrains-toolbox")
+ )
+ os.isWindows -> listOf(
+ localAppData / "Programs" / "JetBrains Toolbox" / "JetBrains Toolbox.exe",
+ localAppData / "JetBrains" / "Toolbox" / "bin" / "jetbrains-toolbox.exe"
+ )
+ os.isLinux -> listOf(
+ System.getenv("TOOLBOX_HOME")?.let { Path.of(it) / "bin" / "jetbrains-toolbox" },
+ userHome() / ".local" / "share" / "JetBrains" / "Toolbox" / "bin" / "jetbrains-toolbox"
+ )
+ else -> emptyList()
+ }.filterNotNull()
+
+ defaults.firstOrNull { it.exists() }?.let { return it }
+
+ error(
+ "Could not find JetBrains Toolbox executable. " +
+ "Please install Toolbox or provide its path via -PtoolboxExecutable=/path/to/jetbrains-toolbox"
+ )
+ }
+
+ /**
+ * Requests the running Toolbox application to quit, if any.
+ */
+ fun quit() {
+ if (os.isWindows) {
+ for (imageName in taskListImageNames) {
+ try {
+ ProcessBuilder("taskkill", "/IM", imageName).start().waitFor()
+ } catch (_: Exception) {
+ // Ignore
+ }
+ }
+ Thread.sleep(750)
+ for (imageName in taskListImageNames) {
+ try {
+ ProcessBuilder("taskkill", "/F", "/IM", imageName).start().waitFor()
+ } catch (_: Exception) {
+ // Ignore
+ }
+ }
+ return
+ }
+ val command = when {
+ os.isMac -> listOf("osascript", "-e", "quit app \"JetBrains Toolbox\"")
+ os.isLinux -> listOf("pkill", "-f", "[/]jetbrains-toolbox")
+ else -> return
+ }
+ try {
+ ProcessBuilder(command).start().waitFor()
+ } catch (_: Exception) {
+ // Not running or no permission – ignore
+ }
+ if (os.isLinux) {
+ try {
+ ProcessBuilder("pkill", "-x", "jetbrains-toolb").start().waitFor()
+ } catch (_: Exception) {
+ // Ignore
+ }
+ }
+ }
+
+ /**
+ * Starts the Toolbox application using the given executable.
+ *
+ * Requires that no Toolbox is running before launch: if [isRunning] reports
+ * a running instance, a previous quit did not finish and launching is
+ * refused with an [IllegalStateException].
+ *
+ * Some launchers (especially on macOS) spawn the real application and
+ * exit immediately. The launch is therefore only treated as a failure
+ * when the started process died AND no Toolbox process appears
+ * within a short poll window afterwards (e.g. bad executable path or an
+ * early crash).
+ */
+ fun launch(executable: Path) {
+ if (isRunning()) {
+ throw IllegalStateException(
+ "JetBrains Toolbox is already running; quit did not finish. " +
+ "Close or force-quit the running instance before launching $executable."
+ )
+ }
+ val process = ProcessBuilder(executable.toString()).start()
+ Thread.sleep(400)
+ if (process.isAlive) {
+ return
+ }
+ if (waitForHandoff()) {
+ // Launcher handed off to the real Toolbox process – treat as success
+ return
+ }
+ val exitValue = try {
+ process.waitFor()
+ } catch (_: InterruptedException) {
+ -1
+ }
+ throw IllegalStateException(
+ "JetBrains Toolbox process exited immediately (exit code $exitValue) after launch: $executable. " +
+ "Check that the executable path is valid and can start on this system."
+ )
+ }
+
+ /**
+ * Waits for the Toolbox application to fully exit after a quit request.
+ *
+ * Polls until the process is no longer running or [delayMs] elapses.
+ * Returns `true` when the process is gone (or was never running),
+ * `false` when it is still running after the timeout.
+ */
+ fun waitForExit(delayMs: Long = 2000): Boolean {
+ val deadline = System.currentTimeMillis() + delayMs
+ while (System.currentTimeMillis() < deadline) {
+ if (!isRunning()) return true
+ Thread.sleep(pollIntervalMs)
+ }
+ return !isRunning()
+ }
+
+ private val pollIntervalMs = 250L
+
+ private val taskListImageNames = listOf("JetBrains Toolbox.exe", "jetbrains-toolbox.exe")
+
+ /**
+ * Used after [launch] has started a process that already exited.
+ *
+ * Because [launch] guarantees no Toolbox was running before the start,
+ * this polls [isRunning] for up to [timeoutMs] at [pollIntervalMs]
+ * intervals, returning `true` when a *new* Toolbox instance appears
+ * (i.e. the launcher handed off to the real application).
+ */
+ private fun waitForHandoff(timeoutMs: Long = 2000): Boolean {
+ val deadline = System.currentTimeMillis() + timeoutMs
+ while (System.currentTimeMillis() < deadline) {
+ if (isRunning()) return true
+ Thread.sleep(pollIntervalMs)
+ }
+ return isRunning()
+ }
+
+ /**
+ * Whether a Toolbox process is currently running on this machine.
+ */
+ private fun isRunning(): Boolean = when {
+ os.isWindows -> taskListImageNames.any { taskListShows(it) }
+ os.isMac -> exitsSuccessfully("pgrep", "-x", "jetbrains-toolbox")
+ os.isLinux -> exitsSuccessfully("pgrep", "-f", "[/]jetbrains-toolbox") ||
+ exitsSuccessfully("pgrep", "-x", "jetbrains-toolb")
+ else -> exitsSuccessfully("pgrep", "-x", "jetbrains-toolbox")
+ }
+
+ /**
+ * Runs `tasklist /FI "IMAGENAME eq "` and reports whether
+ * the image name appears in the output (tasklist always exits 0).
+ */
+ private fun taskListShows(imageName: String): Boolean {
+ val output = runCatching {
+ val process = ProcessBuilder("tasklist", "/FI", "IMAGENAME eq $imageName").start()
+ val text = process.inputStream.bufferedReader().use { it.readText() }
+ process.waitFor()
+ text
+ }.getOrNull() ?: return false
+ return output.contains(imageName, ignoreCase = true)
+ }
+
+ /**
+ * Runs the given command and returns true when it exits with status 0
+ * (e.g. `pgrep` finds a matching process).
+ */
+ private fun exitsSuccessfully(vararg command: String): Boolean = try {
+ val process = ProcessBuilder(*command).start()
+ process.inputStream.bufferedReader().use { it.readText() }
+ process.waitFor() == 0
+ } catch (_: Exception) {
+ false
+ }
+
+ private fun which(name: String): Path? {
+ val lookup = when {
+ os.isWindows -> "where"
+ else -> "which"
+ }
+ return try {
+ val process = ProcessBuilder(lookup, name).start()
+ val output = process.inputStream.bufferedReader().readText().trim()
+ process.waitFor()
+ output.lineSequence().firstOrNull()?.takeIf { it.isNotBlank() }?.let { Path.of(it) }?.takeIf { it.exists() && Files.isExecutable(it) }
+ } catch (_: Exception) {
+ null
+ }
+ }
+}
diff --git a/plugin/build.gradle.kts b/plugin/build.gradle.kts
index b3eb1a3..a62cfa8 100644
--- a/plugin/build.gradle.kts
+++ b/plugin/build.gradle.kts
@@ -3,6 +3,7 @@ plugins {
// `kotlin-dsl`
id("com.redhat.devtools.toolbox.packaging")
id("com.redhat.devtools.toolbox.install")
+ id("com.redhat.devtools.toolbox.dev")
id("com.redhat.devtools.toolbox.publish")
`java-library`
}