Skip to content
Open
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
6 changes: 3 additions & 3 deletions .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "jetwhale",
"description": "Claude Code skills for developing plugins for JetWhale, the Compose Desktop debugging tool.",
"description": "Claude Code skills for JetWhale, the Compose Desktop debugging tool — integrating it into your app, and developing plugins for it.",
"owner": {
"name": "kitakkun",
"url": "https://github.com/kitakkun"
Expand All @@ -9,8 +9,8 @@
{
"name": "jetwhale",
"source": "./plugins/jetwhale",
"description": "Skills for developing JetWhale host plugins — currently a QA workflow that drives a plugin's real UI through the debug tool's MCP server.",
"version": "0.1.0",
"description": "Skills for JetWhale, the Compose Desktop debugging tool — integrating it into an app you want to debug, and QA-ing host plugins through the debug tool's MCP server.",
"version": "0.2.0",
"author": {
"name": "kitakkun",
"url": "https://github.com/kitakkun"
Expand Down
4 changes: 4 additions & 0 deletions docs-site/.vitepress/config.mts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,10 @@ export default defineConfig({
text: 'Guide',
items: [
{ text: 'Network Inspector', link: '/guide/network-inspector' },
{
text: 'Excluding from Release Builds',
link: '/guide/excluding-from-release-builds',
},
{ text: 'MCP Server', link: '/guide/mcp-server' },
{ text: 'Host Settings', link: '/guide/host-settings' },
{ text: 'ADB Auto Port Mapping', link: '/guide/adb-auto-port-mapping' },
Expand Down
283 changes: 283 additions & 0 deletions docs/guide/excluding-from-release-builds.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,283 @@
# Excluding JetWhale from Release Builds

JetWhale is a debugging tool: it opens a WebSocket to your machine, records HTTP traffic, and lets
an external process drive your app. None of that belongs in a build you ship.

Keeping the *artifact* out is easy — `debugImplementation` on Android, or a build-flavor-specific
dependency elsewhere. Keeping the *call sites* out is the hard part: as soon as your shared code
says `startJetWhale { }` or `networkAgent.ktorClientPlugin()`, the release compilation needs those
symbols too, and the dependency comes right back.

This page describes one way out for apps that use a DI container with contribution merging. The
examples use [Metro](https://zacsweers.github.io/metro/), whose `@ContributesBinding(replaces = …)`
expresses the idea directly, but the shape applies to any DI framework that can swap an
implementation per build variant.

## The problem, concretely

| Approach | Why it falls short |
|----------|--------------------|
| `debugImplementation` alone | The release variant fails to compile the moment shared code references JetWhale. |
| Android `src/debug/kotlin` | Variant source sets are an Android Gradle Plugin feature. A KMP `commonMain` — where your HTTP client and app startup usually live — has no equivalent. |
| `if (BuildConfig.DEBUG) { … }` | Compiles, but the dependency and every JetWhale class still ship in the release binary. The check is a runtime guard, not an exclusion. |

What all three lack is a **seam**: a boundary that production code can compile against without
knowing JetWhale exists.

## The shape of the fix

Own the abstraction yourself. Production code depends on a small interface of your own; JetWhale
lives behind an implementation of it that only exists on the debug classpath.

```
:app @DependencyGraph(AppScope::class)
│ implementation(:core:debug)
│ debugImplementation(:debug-jetwhale)
├── :core:debug ← always compiled
│ interface DebugToolingInitializer
│ NoOpInitializer @ContributesBinding(AppScope::class)
└── :debug-jetwhale ← debug classpath only; the only module that imports JetWhale
JetWhaleInitializer @ContributesBinding(
AppScope::class,
replaces = [NoOpInitializer::class],
)
```

Both modules contribute a binding for `DebugToolingInitializer`. On the debug classpath the
JetWhale one `replaces` the no-op, so exactly one survives the merge. On the release classpath the
JetWhale module is simply absent and the no-op stands unopposed — no flags, no `expect`/`actual`,
no source set gymnastics.

::: tip Where the switch happens
Metro merges contributions where the `@DependencyGraph` is declared, by scanning that compilation's
classpath. So the variant-specific dependency belongs on the module holding the graph — usually
your app module.
:::

## 1. Declare the seam in production code

```kotlin
// :core:debug — on every classpath, release included
package com.example.debug

interface DebugToolingInitializer {
fun initialize()
}

@ContributesBinding(AppScope::class)
@Inject
class NoOpInitializer : DebugToolingInitializer {
override fun initialize() = Unit
}
```

That is the whole production-side surface. Nothing here knows what a debugger is — name the seam
after the role it plays in your app, not after the tool behind it.

## 2. Contribute the JetWhale-backed implementation

Everything below lives in `:debug-jetwhale`, the one module that may import
`com.kitakkun.jetwhale.*`.

The Network Inspector requires the *same* `JetWhaleNetworkAgentPlugin` instance in two places — the
HTTP client and `startJetWhale { }` (see [Network Inspector](/guide/network-inspector#setup)). A
scoped binding is exactly the tool for that, so give the agents a single owner:

```kotlin
// :debug-jetwhale
package com.example.debug.jetwhale

@SingleIn(AppScope::class)
@Inject
class JetWhaleAgents {
val network: JetWhaleNetworkAgentPlugin = JetWhaleNetworkAgentPlugin()
}
```

Then implement the seam:

```kotlin
@ContributesBinding(AppScope::class, replaces = [NoOpInitializer::class])
@Inject
class JetWhaleInitializer(
private val agents: JetWhaleAgents,
) : DebugToolingInitializer {
override fun initialize() {
startJetWhale {
connection {
host = "localhost"
port = 5080
}
plugins {
register(agents.network)
}
}
}
}
```

::: warning `replaces` names the contributing class
`replaces = [NoOpInitializer::class]` — the *class that contributes* the binding, not the bound type
`DebugToolingInitializer`. Both contributions must also target the same scope, or the merge leaves
two bindings in place.
:::

## 3. Start it

Your app calls the seam, never JetWhale:

```kotlin
// :app — Application.onCreate(), or the first line of main()
appGraph.debugToolingInitializer.initialize()
```

In a release build this is a call to an empty method, which R8 removes outright.

## 4. Attach the Network Inspector to your HTTP client

Startup is a single binding, so `replaces` fits. Client customization is different: there may be
zero of them, or several. That is a multibinding, and an absent contribution just means an absent
element — no `replaces` needed at all.

Declare the seam and the empty case in production code:

```kotlin
// :core:debug
fun interface HttpClientDecorator {
fun decorate(client: HttpClient)
}

@ContributesTo(AppScope::class)
interface HttpClientDecoratorDeclarations {
// Release builds contribute nothing, and an empty multibinding is an error by default.
@Multibinds(allowEmpty = true)
fun httpClientDecorators(): Set<HttpClientDecorator>
}
```

Apply them wherever you build the client — still production code, still JetWhale-free:

```kotlin
// :core:network
@ContributesTo(AppScope::class)
interface NetworkBindings {
@Provides
@SingleIn(AppScope::class)
fun provideHttpClient(decorators: Set<HttpClientDecorator>): HttpClient =
HttpClient().also { client -> decorators.forEach { it.decorate(client) } }
}
```

And contribute the JetWhale decorator from the debug module:

```kotlin
// :debug-jetwhale
@ContributesIntoSet(AppScope::class)
@Inject
class JetWhaleHttpClientDecorator(
private val agents: JetWhaleAgents,
) : HttpClientDecorator {
override fun decorate(client: HttpClient) {
client.plugin(HttpSend).intercept(agents.network.ktorSendInterceptor(client))
}
}
```

This uses the `HttpSend` interceptor rather than `install(...)` because the client comes from the
graph already built. Register it once per client — `HttpSend` accepts duplicate interceptors
silently and would record every transaction twice.

OkHttp works the same way: contribute an `Interceptor` with `@ContributesIntoSet` from the debug
module, and have the production `OkHttpClient` provider add every element of the (possibly empty)
set.

## 5. Wire the variants in Gradle

### Android

```kotlin
// :app/build.gradle.kts
dependencies {
implementation(projects.core.debug)
debugImplementation(projects.debugJetwhale)
}
```

### Kotlin Multiplatform

KMP has no build variants, so gate the dependency on a Gradle property:

```kotlin
// :app/build.gradle.kts
val jetwhaleEnabled = providers.gradleProperty("jetwhale.enabled").orNull.toBoolean()

kotlin {
sourceSets {
commonMain.dependencies {
implementation(projects.core.debug)
if (jetwhaleEnabled) implementation(projects.debugJetwhale)
}
}
}
```

Run your debug builds with `-Pjetwhale.enabled=true` (or set it in a local, un-committed
`gradle.properties`); release CI omits it and gets the no-op. Flipping the property changes the
compile classpath, so the graph module recompiles — expected, and cheap enough for a switch you
throw once per session.

If you would rather not thread a property through the build, the alternative is two thin entry-point
modules — `:app-debug` and `:app-release` — each declaring its own graph and depending on the
appropriate set of modules.

## Verify it

The point of all this is a release binary with no trace of JetWhale, so check the classpath rather
than trusting the wiring:

```shell
# Android
./gradlew :app:dependencies --configuration releaseRuntimeClasspath | grep jetwhale
```

No output means no JetWhale — no classes for R8 to process, no keep rules to write, and no way for
a stray `startJetWhale` call to reach production.

## Pitfalls

- **The no-op must live in an always-present module.** If it sits next to the JetWhale
implementation, release builds lose both and the graph fails to resolve
`DebugToolingInitializer`.
- **Forgetting `replaces` fails loudly.** The debug build stops at compile time with
`Metro/DuplicateBinding`, naming both contributions. That is the safety net working — it cannot
silently pick one.
- **Keep the seam interface out of the debug module.** Production code has to compile against it.
- **Don't let JetWhale types leak into the seam.** The moment `DebugToolingInitializer` mentions
`JetWhaleSession` or `JetWhaleNetworkAgentPlugin` in its signature, production code needs the
dependency again.
- **One agent instance, shared.** `@SingleIn(AppScope::class)` on `JetWhaleAgents` is what
guarantees the plugin registered with `startJetWhale { }` is the one installed into the HTTP
client. Two instances means the host shows no traffic.

## Other DI frameworks

The seam is the same everywhere; only the mechanism for making the debug side win differs. Each of
these was checked by building and running it, not inferred from the annotations:

- **Anvil / kotlin-inject-anvil** — `@ContributesBinding(replaces = [...])` carries the same
meaning and the module layout transfers unchanged. Two caveats: kotlin-inject has no equivalent of
`@Multibinds(allowEmpty = true)`, so model the HTTP decorator as an ordinary binding with a no-op
default rather than a set; and Square Anvil generates through the Kotlin compiler plugin, so
Dagger has to run on kapt — under KSP nothing is generated at all.
- **Hilt** — `@InstallIn` modules are discovered from the classpath, so a `@BindsOptionalOf`
declaration in `src/main` plus a module in `src/debug` is enough. No release-side counterpart is
needed; the generated component simply binds `Optional.empty()`.
- **Plain Dagger** — `@Component(modules = [...])` names its modules, and `src/main` cannot name a
class that only exists in `src/debug`. Here you do need the same fully-qualified module in both
source sets, empty in release. Note that Dagger's `@Multibinds` allows an empty set by default —
there is no `allowEmpty` to pass.
- **Koin / manual DI** — resolution is at runtime, so nothing fails the release build for you.
Declare the JetWhale definitions only in the debug source set and reach for them with
`getOrNull` / `getAll`, which return absent and empty respectively when nothing is registered.
5 changes: 4 additions & 1 deletion docs/guide/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,10 @@ and may break (especially around `inline` functions) with future releases. Prefe

::: tip
Only add JetWhale to debug builds (e.g. `debugImplementation` on Android, or your own build-flavor
wiring) — it is a debugging tool and should not ship in release builds.
wiring) — it is a debugging tool and should not ship in release builds. Once shared code calls
`startJetWhale { }`, a debug-only dependency stops being enough on its own; see
[Excluding from Release Builds](/guide/excluding-from-release-builds) for a DI-based seam that keeps
release builds free of JetWhale entirely.
:::

## 3. Start JetWhale in your app
Expand Down
4 changes: 2 additions & 2 deletions plugins/jetwhale/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "jetwhale",
"description": "Skills for developing JetWhale host plugins — currently a QA workflow that drives a plugin's real UI through the debug tool's MCP server.",
"version": "0.1.0",
"description": "Skills for JetWhale, the Compose Desktop debugging tool — integrating it into an app you want to debug, and QA-ing host plugins through the debug tool's MCP server.",
"version": "0.2.0",
"author": {
"name": "kitakkun",
"url": "https://github.com/kitakkun"
Expand Down
25 changes: 15 additions & 10 deletions plugins/jetwhale/README.md
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
# JetWhale plugin for Claude Code

Skills for developing [JetWhale](https://github.com/kitakkun/JetWhale) host plugins.
Skills for using and extending [JetWhale](https://github.com/kitakkun/JetWhale).

| Skill | What it covers |
|---|---|
| `/jetwhale:integrate` | Adding JetWhale to an app you want to debug — surveying the build, HTTP client and DI framework, then wiring startup and traffic capture behind a seam so no JetWhale symbol reaches release builds |
| `/jetwhale:plugin-qa` | Driving a host plugin's real UI through the debug tool's MCP server — screenshots, gestures, persisted state, restart restore, and a headless debuggee to drive it against |

## Install
Expand All @@ -23,16 +24,20 @@ claude plugin marketplace add kitakkun/JetWhale --sparse .claude-plugin plugins

Either way, installing copies just this directory into `~/.claude/plugins/cache`.

## Why the skill lives in the JetWhale repository
## Why the skills live in the JetWhale repository

A QA skill is only useful while it is true, and what it describes — MCP tool names, the QA agent's
control API, which ports the launch tasks accept — moves with the code. Keeping it here means a
change to the host and the change to its documented workflow land in the same commit, reviewed
together. A separate repository would let the two drift, and a QA guide that quietly lies is worse
than none.
A skill is only useful while it is true, and what these describe — MCP tool names, the QA agent's
control API, which ports the launch tasks accept, the published artifact coordinates — moves with
the code. Keeping them here means a change to the host and the change to its documented workflow
land in the same commit, reviewed together. A separate repository would let the two drift, and a
guide that quietly lies is worse than none.

## Requirements

The skill assumes your plugin module applies the `com.kitakkun.jetwhale.host` Gradle plugin and sets
`jetwhalePlugin.hostVersion`; that is what provides the `runJetWhale` and `runJetWhaleQaAgent` tasks
it drives. See the [plugin development guide](https://github.com/kitakkun/JetWhale/tree/main/docs).
`/jetwhale:integrate` runs against the app you want to debug and needs nothing installed beyond
that project; it will tell you if the project's Kotlin version is too old.

`/jetwhale:plugin-qa` assumes your plugin module applies the `com.kitakkun.jetwhale.host` Gradle
plugin and sets `jetwhalePlugin.hostVersion`; that is what provides the `runJetWhale` and
`runJetWhaleQaAgent` tasks it drives. See the
[plugin development guide](https://github.com/kitakkun/JetWhale/tree/main/docs).
Loading
Loading