Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
69 commits
Select commit Hold shift + click to select a range
70b16db
Align plugin settings with capability parameters
matteomekhail Aug 14, 2026
485a9f3
Add safe directory operations to scoped filesystems
matteomekhail Aug 14, 2026
e3535b4
Resolve custom plugin defaults consistently
matteomekhail Aug 14, 2026
dea9ce8
Divide required and optional plugin settings
matteomekhail Aug 14, 2026
11b5294
Rework color picker dialog
matteomekhail Aug 14, 2026
feed807
Add declarative plugin-defined pages
matteomekhail Aug 14, 2026
6879e2b
Build plugins as standalone executable jars
matteomekhail Aug 14, 2026
168f648
Add a headless toolkit CLI
matteomekhail Aug 14, 2026
d343ba9
fix: complete setting metadata integration
matteomekhail Aug 14, 2026
848eb8d
fix: harden sandboxed file deletion
matteomekhail Aug 14, 2026
2eff259
fix: preserve unset plugin settings
matteomekhail Aug 14, 2026
badf10c
fix: test setting groups and target deep links
matteomekhail Aug 14, 2026
5062c9d
Merge branch 'codex/issue-3-plugin-setting-parameters' into codex/iss…
matteomekhail Aug 14, 2026
d75c541
Merge branch 'codex/issue-11-custom-setting-defaults' into codex/issu…
matteomekhail Aug 14, 2026
5cc8dcc
fix: preserve color picker values and alpha
matteomekhail Aug 14, 2026
a2cb1b3
fix: generate plugin UI pages without breaking ABI
matteomekhail Aug 14, 2026
1ac5dbd
fix: separate processor from standalone runtime
matteomekhail Aug 14, 2026
36ff162
Harden headless toolkit CLI
matteomekhail Aug 14, 2026
4db053c
Merge branch 'codex/issue-12-divide-plugin-settings' into codex/issue…
matteomekhail Aug 14, 2026
af30347
Merge branch 'codex/issue-4-plugin-defined-ui' into codex/issue-5-sta…
matteomekhail Aug 14, 2026
7e25940
Merge branch 'codex/issue-5-standalone-plugin-jar' into codex/issue-6…
matteomekhail Aug 14, 2026
b7015ea
fix: publish and harden standalone plugin tooling
matteomekhail Aug 14, 2026
dd751af
Merge branch 'codex/issue-5-standalone-plugin-jar' into codex/issue-6…
matteomekhail Aug 14, 2026
2b9a6f4
fix: make toolkit CLI reads safe and headless
matteomekhail Aug 14, 2026
9d7e9d5
fix: resolve generated settings safely at runtime
matteomekhail Aug 14, 2026
4a1158f
fix: preserve legacy color values and alpha
matteomekhail Aug 14, 2026
144d594
fix: preserve safe paths and isolate cache operations
matteomekhail Aug 14, 2026
206ae36
fix: retain data-class copy ABI for plugins
matteomekhail Aug 14, 2026
0eb3aaf
Merge branch 'codex/issue-3-plugin-setting-parameters' into codex/iss…
matteomekhail Aug 14, 2026
153c666
fix: preserve unchanged settings cache identity
matteomekhail Aug 14, 2026
c38bb10
Merge branch 'codex/issue-12-divide-plugin-settings' into codex/issue…
matteomekhail Aug 14, 2026
cb5b0b6
Merge branch 'codex/issue-4-plugin-defined-ui' into codex/issue-5-sta…
matteomekhail Aug 14, 2026
c39dd80
Merge branch 'codex/issue-5-standalone-plugin-jar' into codex/issue-6…
matteomekhail Aug 14, 2026
5768bef
fix: retain alpha for functional color values
matteomekhail Aug 14, 2026
a9f0142
fix: handle unavailable sandbox roots safely
matteomekhail Aug 14, 2026
f307171
fix: preserve desktop startup for launcher arguments
matteomekhail Aug 14, 2026
200c064
docs: flag the processor coordinate migration
matteomekhail Aug 14, 2026
10cd8a7
Merge branch 'codex/issue-5-standalone-plugin-jar' into codex/issue-6…
matteomekhail Aug 14, 2026
eb44cc9
fix: inspect standalone manifests without plugin startup
matteomekhail Aug 14, 2026
29c2c3f
Merge branch 'codex/issue-5-standalone-plugin-jar' into codex/issue-6…
matteomekhail Aug 14, 2026
894a2eb
fix: round-trip functional and short alpha colors
matteomekhail Aug 14, 2026
89dd5a1
fix: make release test gate cross-platform
matteomekhail Aug 14, 2026
46063ac
Merge branch 'codex/issue-5-standalone-plugin-jar' into codex/issue-6…
matteomekhail Aug 14, 2026
62dee07
fix: canonicalize native paths before access checks
matteomekhail Aug 14, 2026
c8e158a
Merge branch 'codex/issue-5-standalone-plugin-jar' into codex/issue-6…
matteomekhail Aug 14, 2026
2f15ae1
fix: apply manifest defaults to capability gates
matteomekhail Aug 14, 2026
c6cb1eb
Merge branch 'codex/issue-11-custom-setting-defaults' into codex/issu…
matteomekhail Aug 14, 2026
ffaf22e
Merge branch 'codex/issue-12-divide-plugin-settings' into codex/issue…
matteomekhail Aug 14, 2026
774280b
Merge branch 'codex/issue-13-color-picker-rework' into codex/issue-4-…
matteomekhail Aug 14, 2026
56ad0fa
Merge branch 'codex/issue-4-plugin-defined-ui' into codex/issue-5-sta…
matteomekhail Aug 14, 2026
d50b8cd
Merge branch 'codex/issue-5-standalone-plugin-jar' into codex/issue-6…
matteomekhail Aug 14, 2026
e203a5c
fix: keep cache filesystem operations isolated
matteomekhail Aug 15, 2026
ce2f1a8
Merge branch 'codex/issue-10-plugin-filesystem' into codex/issue-11-c…
matteomekhail Aug 15, 2026
db3dfd2
Merge branch 'codex/issue-11-custom-setting-defaults' into codex/issu…
matteomekhail Aug 15, 2026
092c09e
Merge branch 'codex/issue-12-divide-plugin-settings' into codex/issue…
matteomekhail Aug 15, 2026
f8a237d
Merge branch 'codex/issue-13-color-picker-rework' into codex/issue-4-…
matteomekhail Aug 15, 2026
fcd5149
Merge branch 'codex/issue-4-plugin-defined-ui' into codex/issue-5-sta…
matteomekhail Aug 15, 2026
0f42289
Merge branch 'codex/issue-5-standalone-plugin-jar' into codex/issue-6…
matteomekhail Aug 15, 2026
a2b4a8e
Revert "Merge branch 'codex/issue-13-color-picker-rework' into codex/…
matteomekhail Aug 15, 2026
a232e9e
Revert "Merge branch 'codex/issue-13-color-picker-rework' into codex/…
matteomekhail Aug 15, 2026
ca67411
Revert "Merge branch 'codex/issue-4-plugin-defined-ui' into codex/iss…
matteomekhail Aug 15, 2026
3b90c96
Revert "Merge branch 'codex/issue-5-standalone-plugin-jar' into codex…
matteomekhail Aug 15, 2026
7ff54dc
Revert "Merge branch 'codex/issue-4-plugin-defined-ui' into codex/iss…
matteomekhail Aug 15, 2026
38337fe
Revert "Merge branch 'codex/issue-5-standalone-plugin-jar' into codex…
matteomekhail Aug 15, 2026
dce0fa2
fix: enforce Windows UNC path policy
matteomekhail Aug 15, 2026
43ae896
Merge branch 'codex/issue-5-standalone-plugin-jar' into codex/issue-6…
matteomekhail Aug 15, 2026
7e8dd8d
Merge remote-tracking branch 'origin/dev' into codex/issue-4-plugin-d…
matteomekhail Aug 15, 2026
d17e86b
Merge branch 'codex/issue-4-plugin-defined-ui' into codex/issue-5-sta…
matteomekhail Aug 15, 2026
ed5a123
Merge branch 'codex/issue-5-standalone-plugin-jar' into codex/issue-6…
matteomekhail Aug 15, 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
6 changes: 3 additions & 3 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,14 +32,14 @@ jobs:
uses: gradle/actions/setup-gradle@v3

- name: Run Unit Tests
run: .\gradlew test
run: .\gradlew test :composeApp:jvmTest :plugin-api:jvmTest

- name: Build Release Distribution
run: .\gradlew packageDistributionForCurrentOS packageUberJarForCurrentOS packagePortableZip
# run: .\gradlew packageReleaseDistributionForCurrentOS packageReleaseUberJarForCurrentOS packagePortableZip

- name: Publish Plugin API
run: .\gradlew :plugin-api:publish
- name: Publish Plugin API and Processor
run: .\gradlew :plugin-api:publish :plugin-processor:publish
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

Expand Down
63 changes: 62 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,23 @@
This is a Kotlin Multiplatform project targeting Desktop (JVM).

## Plugin-defined pages

Plugins can organize capabilities into pages rendered by the host, without bundling Compose UI binaries:

```kotlin
@PluginUiPage(
id = "convert",
title = "Convert media",
description = "Choose an operation to begin.",
capabilityNames = ["Convert image", "Convert video"]
)
@PluginInfo(/* ... */)
class MediaPlugin
```

Unknown capability names are ignored. The declarative contract stays usable across host UI upgrades and
can also be interpreted by future web or command-line front ends.

* [/composeApp](./composeApp/src) is for code that will be shared across your Compose Multiplatform applications.
It contains several subfolders:
- [commonMain](./composeApp/src/commonMain/kotlin) is for code that’s common for all targets.
Expand All @@ -22,6 +40,19 @@ in your IDE’s toolbar or run it directly from the terminal:
.\gradlew.bat :composeApp:run
```

### Command-line interface

The desktop distribution can also run without opening a window:

```shell
plugintoolkit status
plugintoolkit plugins list
plugintoolkit flows list
plugintoolkit --version
```

Use `plugintoolkit --help` for the complete command summary.

---

## Execution Engine & Concurrency (PluginToolkit)
Expand All @@ -33,4 +64,34 @@ The internal job execution engine (`FlowEngine` and `JobWorker`) enforces strict
- **Recursion Depth Limits**: Deep subflow execution limits the stack frame depth to 50 iterations. Attempting to create an infinitely recursive subflow safely fails before hitting a JVM StackOverflow.
- **Configurable Capabilities Policies**: Transient network execution failures in plugins automatically back off and retry up to `maxRetries` (configurable in app settings). Executions are also bound by a strict `pluginTimeoutMs` to prevent hung plugins.

Learn more about [Kotlin Multiplatform](https://www.jetbrains.com/help/kotlin-multiplatform-dev/get-started.html)…
Learn more about [Kotlin Multiplatform](https://www.jetbrains.com/help/kotlin-multiplatform-dev/get-started.html)…

## Standalone plugin JARs

JVM plugin modules can build a self-contained executable artifact by applying the bundled script:

```kotlin
apply(from = rootProject.file("scripts/standalone-plugin.gradle.kts"))
```

Run `./gradlew :yourPlugin:standaloneJar`, then inspect the plugin without the desktop host:

```shell
java -jar yourPlugin/build/libs/yourPlugin-version-standalone.jar --info
```

The generated JAR contains runtime dependencies, merges `META-INF/services` providers, and leaves KSP/compiler
dependencies in the separate `plugin-processor` build-time artifact.

External plugin builds need both artifacts at the same toolkit version:

```kotlin
dependencies {
implementation("org.wip.plugintoolkit:plugin-api:<toolkit-version>")
ksp("org.wip.plugintoolkit:plugin-processor:<toolkit-version>")
}
```

> **2.0 migration:** replace any previous `ksp("org.wip.plugintoolkit:plugin-api:…")` dependency with
> `plugin-processor`. The old coordinate no longer contains a KSP provider, so leaving it unchanged can produce
> a successful build with no generated manifest or plugin entry point.
4 changes: 3 additions & 1 deletion completeExample/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -26,11 +26,13 @@ dependencies {
implementation(libs.koin.core)
implementation(libs.kotlinx.serialization.json)
implementation(project(":plugin-api"))
ksp(project(":plugin-api"))
ksp(project(":plugin-processor"))
testImplementation(libs.kotlin.test)
testImplementation(libs.kotlinx.coroutines.test)
}

tasks.withType<ProcessResources> {
duplicatesStrategy = DuplicatesStrategy.INCLUDE
}

apply(from = rootProject.file("scripts/standalone-plugin.gradle.kts"))
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import org.wip.plugintoolkit.api.annotations.CapabilityParam
import org.wip.plugintoolkit.api.annotations.CapabilityResult
import org.wip.plugintoolkit.api.annotations.PluginAction
import org.wip.plugintoolkit.api.annotations.PluginInfo
import org.wip.plugintoolkit.api.annotations.PluginUiPage
import org.wip.plugintoolkit.api.annotations.PluginLoad
import org.wip.plugintoolkit.api.annotations.PluginSetting
import org.wip.plugintoolkit.api.annotations.PluginSetup
Expand All @@ -39,7 +40,9 @@ import java.io.File
data class CompleteExampleSettings(
@PluginSetting(
description = "Public configuration value example",
defaultValue = "default_api_key"
defaultValue = "default_api_key",
minLength = 8,
semanticTypes = ["text/plain"]
) val apiKey: String? = "default_api_key",

@PluginSetting(
Expand Down Expand Up @@ -117,6 +120,17 @@ enum class FeatureMode {
description = "Complete showcase of plugin API features including settings, validation, signals, storage, file system, lifecycle hooks, and flow contexts.",
supportedOs = [OS.WINDOWS, OS.LINUX, OS.MACOS]
)
@PluginUiPage(
id = "essentials",
title = "Essential capabilities",
description = "Common storage and file operations.",
capabilityNames = ["capabilityWithFileAccess", "capabilityWithDataStorage"]
)
@PluginUiPage(
id = "advanced",
title = "Advanced capabilities",
capabilityNames = ["capabilityWithPauseResume", "capabilityWithComplexObjectsAndSemanticTypes"]
)
class CompleteExamplePlugin(val settings: CompleteExampleSettings) {

@PluginLoad
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
<?xml version='1.0' encoding='utf-8'?>
<resources>
<string name="plugin_settings_required">Obbligatorie</string>
<string name="plugin_settings_optional">Facoltative</string>
<string name="app_name">PluginToolkit</string>
<string name="section_runner">Runner</string>
<string name="nav_main">Dashboard</string>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,8 @@
<string name="plugin_settings_actions">Actions</string>
<string name="plugin_settings_custom">Custom Settings</string>
<string name="plugin_settings_global_defaults">Global Parameter Defaults</string>
<string name="plugin_settings_required">Required</string>
<string name="plugin_settings_optional">Optional</string>
<string name="plugin_settings_capability">Capability: %1$s</string>
<string name="settings_enum_unlock_message">Configure required settings to unlock options</string>
<string name="settings_locked_capability">Locked capability: %1$s</string>
Expand Down Expand Up @@ -474,4 +476,4 @@
<string name="repo_filter_label">Filter:</string>
<string name="repo_sort_label">Sort:</string>
<string name="repo_sync_all">Sync All</string>
</resources>
</resources>
Original file line number Diff line number Diff line change
Expand Up @@ -46,92 +46,99 @@ class FlowRepository(
}

private fun getFlowPath(appDataDir: String, flowName: String): Path {
val safeName = flowName.replace(Regex("[\\\\/:*?\"<>|]"), "_")
return Path("$appDataDir/flows/$safeName.json")
return storedFlowPath(appDataDir, flowName)
}

fun reloadFlows() {
scope.launch(Dispatchers.IO) {
try {
val appDataDir = settingsPersistence.getSettingsDir()
val flowsDir = Path("$appDataDir/flows")

if (!SystemFileSystem.exists(flowsDir)) {
SystemFileSystem.createDirectories(flowsDir)
}

// Check for legacy migration first
val legacyFile = Path("$appDataDir/${appConfig.FLOWS_FILE_NAME}")
if (SystemFileSystem.exists(legacyFile)) {
try {
val legacyContent = SystemFileSystem.source(legacyFile).buffered().use { it.readString() }
if (legacyContent.isNotBlank()) {
val loadedFlows = json.decodeFromString<List<Flow>>(legacyContent)
loadedFlows.forEach { flow ->
val targetFile = getFlowPath(appDataDir, flow.name)
if (!SystemFileSystem.exists(targetFile)) {
val flowContent = json.encodeToString(Flow.serializer(), flow)
SystemFileSystem.sink(targetFile).buffered().use { it.writeString(flowContent) }
}
}
}
val backupFile = Path("$appDataDir/${appConfig.FLOWS_FILE_NAME}.bak")
if (SystemFileSystem.exists(backupFile)) {
SystemFileSystem.delete(backupFile)
}
SystemFileSystem.source(legacyFile).buffered().use { source ->
SystemFileSystem.sink(backupFile).buffered().use { sink ->
val data = source.readString()
sink.writeString(data)
val storedFlows = loadStoredFlows(settingsPersistence, appConfig)
val manifests = pluginManager.installedPlugins.value.filter { it.isEnabled }
.associate { it.pkg to pluginManager.getManifest(it.pkg) }
.filterValues { it != null }.mapValues { it.value!! }
_flows.value = storedFlows.map { flow ->
val updatedNodes = flow.nodes.map { node ->
if (node is Node.CapabilityNode) {
val currentManifest = manifests[node.pluginInfo.id]
val actualCapability = currentManifest?.capabilities?.find { it.name == node.capability.name }
if (currentManifest == null || actualCapability == null) {
node.copy(isBroken = true)
} else {
node.copy(
isBroken = false,
capability = actualCapability,
pluginInfo = currentManifest.plugin
)
}
} else {
node
}
SystemFileSystem.delete(legacyFile)
Logger.i { "Legacy flows.json successfully migrated and backed up" }
} catch (e: Exception) {
Logger.e(e) { "Migration failed" }
}
flow.copy(nodes = updatedNodes)
}
} catch (e: Exception) {
Logger.e(e) { "Failed to reload flows" }
}
}
}

val loadedFlows = mutableListOf<Flow>()
val manifests = pluginManager.installedPlugins.value.filter { it.isEnabled }
.associate { it.pkg to pluginManager.getManifest(it.pkg) }
.filterValues { it != null }.mapValues { it.value!! }
companion object {
private val storageJson = Json {
prettyPrint = true
ignoreUnknownKeys = true
encodeDefaults = true
}

private fun storedFlowPath(appDataDir: String, flowName: String): Path {
val safeName = flowName.replace(Regex("[\\\\/:*?\"<>|]"), "_")
return Path("$appDataDir/flows/$safeName.json")
}

SystemFileSystem.list(flowsDir).forEach { file ->
if (file.name.endsWith(".json")) {
try {
val content = SystemFileSystem.source(file).buffered().use { it.readString() }
val flow = json.decodeFromString<Flow>(content)

val updatedNodes = flow.nodes.map { node ->
if (node is Node.CapabilityNode) {
val currentManifest = manifests[node.pluginInfo.id]
val actualCapability =
currentManifest?.capabilities?.find { it.name == node.capability.name }
if (currentManifest == null || actualCapability == null) {
node.copy(isBroken = true)
} else {
node.copy(
isBroken = false,
capability = actualCapability,
pluginInfo = currentManifest.plugin
)
}
} else {
node
suspend fun loadStoredFlows(
settingsPersistence: SettingsPersistence,
appConfig: SystemConfig
): List<Flow> = kotlinx.coroutines.withContext(Dispatchers.IO) {
val appDataDir = settingsPersistence.getSettingsDir()
val flowsDir = Path("$appDataDir/flows")
if (!SystemFileSystem.exists(flowsDir)) SystemFileSystem.createDirectories(flowsDir)

val legacyFile = Path("$appDataDir/${appConfig.FLOWS_FILE_NAME}")
if (SystemFileSystem.exists(legacyFile)) {
try {
val legacyContent = SystemFileSystem.source(legacyFile).buffered().use { it.readString() }
if (legacyContent.isNotBlank()) {
storageJson.decodeFromString<List<Flow>>(legacyContent).forEach { flow ->
val targetFile = storedFlowPath(appDataDir, flow.name)
if (!SystemFileSystem.exists(targetFile)) {
SystemFileSystem.sink(targetFile).buffered().use {
it.writeString(storageJson.encodeToString(Flow.serializer(), flow))
}
}
loadedFlows.add(flow.copy(nodes = updatedNodes))
} catch (e: Exception) {
Logger.e(e) { "Failed to parse flow file: ${file.name}" }
}
}
val backupFile = Path("$appDataDir/${appConfig.FLOWS_FILE_NAME}.bak")
if (SystemFileSystem.exists(backupFile)) SystemFileSystem.delete(backupFile)
SystemFileSystem.source(legacyFile).buffered().use { source ->
SystemFileSystem.sink(backupFile).buffered().use { sink -> sink.writeString(source.readString()) }
}
SystemFileSystem.delete(legacyFile)
Logger.i { "Legacy flows.json successfully migrated and backed up" }
} catch (error: Exception) {
Logger.e(error) { "Migration failed" }
}

_flows.value = loadedFlows
} catch (e: Exception) {
Logger.e(e) { "Failed to reload flows" }
}

SystemFileSystem.list(flowsDir)
.filter { it.name.endsWith(".json") }
.mapNotNull { file ->
try {
val content = SystemFileSystem.source(file).buffered().use { it.readString() }
storageJson.decodeFromString<Flow>(content)
} catch (error: Exception) {
Logger.e(error) { "Failed to parse flow file: ${file.name}" }
null
}
}
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
package org.wip.plugintoolkit.features.flows.logic

import kotlinx.io.files.Path

/**
* Utility class to evaluate and resolve autogenerated path patterns for node outputs.
*
Expand Down Expand Up @@ -79,30 +77,28 @@ object PathPatternResolver {
// If it's empty, we can't extract modifiers like name or ext effectively
if (pathString.isBlank()) return ""

val path = Path(pathString)
val isWindows = pathString.contains(":\\") || pathString.contains(":/")
// kotlinx.io Path follows the host OS. Parse separators as data so a Windows path can be
// resolved correctly by Linux/macOS CI (and vice versa).
val lastSeparator = maxOf(pathString.lastIndexOf('/'), pathString.lastIndexOf('\\'))
val name = pathString.substring(lastSeparator + 1)

return when (modifier) {
"dir" -> {
val parent = path.parent?.toString() ?: ""
val separator = if (pathString.contains("\\")) "\\" else "/"
val normalizedParent = parent.replace("\\", separator).replace("/", separator)
// Edge case handling for windows roots
if (isWindows && parent.isEmpty()) {
pathString.substringBefore("\\").substringBefore("/") + "\\"
} else {
normalizedParent
when {
lastSeparator < 0 -> ""
lastSeparator == 0 -> pathString.substring(0, 1)
lastSeparator == 2 && pathString.getOrNull(1) == ':' ->
pathString.substring(0, lastSeparator + 1)
else -> pathString.substring(0, lastSeparator)
}
}

"name" -> path.name
"name" -> name
"nameWithoutExtension" -> {
val name = path.name
if (name.contains(".")) name.substringBeforeLast(".") else name
}

"ext" -> {
val name = path.name
if (name.contains(".")) name.substringAfterLast(".") else ""
}

Expand Down
Loading