mcpbeat Sign in

Foss Vs Proprietary Module Pairs Agent Skill

Ship one codebase in two forms — a full build carrying a proprietary or credentialed integration and an open build carrying a no-op stub — using twin modules with an identical public API selected by a Gradle property rather than product flavors, which do not exist for non-Android multiplatform targets. Reach for it when a tracking, casting, or paid-service dependency must be absent from an open-source build, when call sites are littered with build-flavor branches, or when the "clean" build still pulls the proprietary artifact through a transitive path.

2k tokens
context cost
the whole folder, loaded on every use
1
files
instructions only
0
copies elsewhere
how many repositories repackaged it
100
stars on the repo
on the repository, not the skill itself

Install

one command, takes just this skill from the repository
npx skills add https://github.com/maxrave-dev/kotlin-footguns --skill foss-vs-proprietary-module-pairs

The instruction itself

4 sections, as written by the author

Twin modules: <feature> and <feature>-empty

Two modules, same package, same declarations. One wraps the real integration; the other returns

values that make every caller take the "not available" path without knowing there is one.

// adapted — <feature>-empty/src/…/<Feature>.kt
package com.example.app.<feature>

// STUB build: <feature> is not available. Every function is a safe no-op so callers
// never branch on the build.

fun init<Feature>(context: Context): Boolean {
    Logger.d(TAG, UNAVAILABLE)
    return false
}

fun is<Feature>Available(): Boolean = false

fun wrapPlayer(context: Context, localPlayer: Player): Player = localPlayer

fun currentDeviceName(): String? = null

@Composable
fun <Feature>IconButton(modifier: Modifier = Modifier, tint: Color = Color.White) {
    // no-op
}

Selection is a Gradle property, read the same way in every consuming module:

// adapted — top of each consuming build script
val isFullBuild: Boolean = try { extra["isFullBuild"] == "true" } catch (e: Exception) { false }

// …in that module's dependency block
if (isFullBuild) implementation(projects.<feature>) else implementation(projects.<feature>Empty)

The property has a default in gradle.properties; continuous integration appends a line to that same

file before invoking Gradle, one value per job — run: echo "isFullBuild=false" >> ./gradle.properties.

Why a property, not product flavors

Product flavors are an Android Gradle Plugin concept. A Kotlin Multiplatform module's jvm(),

ios*() and common source sets have no flavor dimension, so a flavor cannot select a dependency for

them — and here the twins are consumed from *both* an Android-only module and the commonMain

dependency block of a multiplatform module. One property covers every module and every target; a

flavor covers a subset and silently leaves the rest on whichever twin was hardcoded.

Traps

Every consuming module must read the same property, or the graph mixes. The gate is per-module,

so one module that hardcodes the real twin drags the proprietary code into the open build through a

transitive path — and it links fine, so nothing complains. Enumerate both sides and compare:

grep -rn 'isFullBuild' --include='*.kts' --include='*.properties' --include='*.yml' . | grep -v '/build/'
grep -rn '<feature>Empty\|<feature>-empty' --include='*.kts' . | grep -v '/build/'

Every module in the second list must also appear in the first. Here the pairs are gated from build

scripts across the whole tree, including one deep in the data layer that nothing about the user

interface would have led you to.

Both twins declare the same package *and* the same Android namespace — that is what makes the

swap invisible: imports do not change, so no call site knows which module it linked against.

The stub returns identity, never null, wherever the real one transforms. wrapPlayer handing

back localPlayer unchanged is the shape to copy. A stub returning null there forces every caller

into a branch — the thing this pattern exists to delete.

The availability check is the entire hiding mechanism. The stub's is<Feature>Available()

returns a hardcoded false; the real one derives it from whether the credential was injected:

fun is<Feature>Available(): Boolean = apiKey.isNotEmpty() && sharedSecret.isNotEmpty()

That second form is what makes a full build *without* credentials behave exactly like the stub build

instead of offering a login that can never succeed. Pair it with buildkonfig-secrets-flavors, which

supplies the empty strings on the no-secrets branch. Then confirm the check is consulted —

grep -rn 'is<Feature>Available()' --include='*.kt' . | grep -v '/build/' should hit at least the

settings screen (so the whole block hides) and the service entry point (so background work returns

early).

The stub needs enough to compile the same signatures, and nothing from the vendor. The stub here

carries the neutral type artifact for the player interface, the Compose runtime for the composable,

and the project's own shared modules — but no proprietary artifact. Read the difference directly

rather than assuming it, and match the declarations wherever they sit: a multiplatform twin keeps

them indented inside sourceSets { … { dependencies { … } } }, where a /^dependencies {/ capture

matches zero lines and the diff passes silently.

deps() { grep -hoE '^[[:space:]]*(implementation|api|compileOnly|runtimeOnly)\(.*' "$1" \
           | sed 's/^ *//' | sort; }
diff <(deps <feature>/build.gradle.kts) <(deps <feature>-empty/build.gradle.kts)

Lines only on the proprietary side should be the vendor's artifacts. Lines only on the *stub* side

are not automatically drift — a stub often needs a shared or logging module the real twin gets

transitively — so read each one rather than deleting it.

Missing declarations only fail in the branch you are not building. The two files are kept in

lockstep by hand, so a function added to the real twin and forgotten in the stub compiles locally and

fails elsewhere. Data classes, sealed hierarchies and constants count: the stub here re-declares the

full result hierarchy including its error codes, because callers pattern match on them.

Reading the property must not fail when it is absent. The try/catch defaulting to false is

deliberate — a module with no property in scope gets the stub, the branch that always builds. The

checked-in default may still be the *full* build, as it is here, but that is only safe because the

credential reader catches a missing local.properties and writes empty fields, which the

availability check above turns back into stub behaviour. Without that catch, defaulting to full

makes a contributor's first build fail on a missing credential.

A stub is not a place for TODOs. These functions are called from playback callbacks and from

composition, so every one must be complete and safe on a hot path. One log line per call is fine;

throwing, or returning a partially initialised object, is not.

Verifying it

# the two public surfaces must declare the same names.
# The final `sort` is load-bearing: `uniq -u` only collapses ADJACENT duplicates, so two
# separately sorted lists concatenated make it report every declaration as one-sided.
for m in <feature> <feature>-empty; do
  grep -rhoE '^(fun|suspend fun|data class|sealed interface|@Composable)[^({]*' "$m/src" | sort
done | sort | uniq -u    # must print nothing — anything listed exists on one side only

# both twins stay in the project list unconditionally; selection happens per dependency
grep -n '<feature>' settings.gradle.kts

That second point matters. Conditionally include()-ing a project makes the whole configuration

depend on the property, which breaks tooling that resolves the project list without one.

How to use it

Copy the folder

Take maxrave-dev/foss-vs-proprietary-module-pairs from the repository into ~/.claude/skills for personal use, or into .claude/skills inside a project.

Check the name does not clash

The agent identifies a skill by the name field in its header. Two skills with the same name cannot sit side by side — one of them will be ignored.