mcpbeat Sign in

Buildkonfig Secrets Flavors Agent Skill

Wire build-time configuration into a Kotlin Multiplatform app with BuildKonfig — secrets read from an untracked local properties file and injected as generated constants, with the no-secrets branch getting empty strings so the feature disables itself instead of failing the build, plus the task-dependency wiring newer Gradle demands for generated sources; reach for it when common code needs a compile-time constant, when an open-source build must not carry credentials, or when a build fails on an implicit dependency between a generated-source task and a consumer.

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 buildkonfig-secrets-flavors

The instruction itself

4 sections, as written by the author

Build-time configuration across a multiplatform app

Android's generated config object does not exist in common code. BuildKonfig fills that gap: it

generates one object, visible from commonMain, from values the build script supplies.

buildkonfig {
    packageName = "com.example.app"
    exposeObjectWithName = "BuildKonfig"
    defaultConfigs {
        buildConfigField(STRING, "versionName", libs.versions.version.name.get())
        buildConfigField(INT, "versionCode", libs.versions.version.code.get())
        // …plus the secret-bearing fields below
    }
}

The result is a plain public object of public vals in the package you named, so every platform

and every source set reads it identically — no expect/actual, no platform accessor.

Secrets, and the two branches

Secrets come from an untracked properties file at the repo root (git-ignored; CI writes it from

repository secrets before the build). The build has two branches selected by a Gradle property —

one flavour that carries paid-integration credentials, one that carries none:

// adapted — field names genericised
val isFullBuild: Boolean = try { extra["isFullBuild"] == "true" } catch (e: Exception) { false }

if (isFullBuild) {
    try {
        val properties = Properties()
        properties.load(rootProject.file("local.properties").inputStream())
        buildConfigField(STRING, "integrationApiKey", properties.getProperty("INTEGRATION_API_KEY") ?: "")
        buildConfigField(STRING, "integrationSecret", properties.getProperty("INTEGRATION_SECRET") ?: "")
        buildConfigField(STRING, "crashReportingDsn", properties.getProperty("CRASH_DSN") ?: "")
    } catch (e: Exception) {
        println("Failed to load secrets from local.properties: ${e.message}")
        buildConfigField(STRING, "integrationApiKey", "")
        buildConfigField(STRING, "integrationSecret", "")
        buildConfigField(STRING, "crashReportingDsn", "")
    }
} else {
    // The no-secrets branch ships no credentials, so the availability check stays false
    // and the feature hides itself. The stub module is linked in this flavour anyway.
    buildConfigField(STRING, "integrationApiKey", "")
    buildConfigField(STRING, "integrationSecret", "")
    buildConfigField(STRING, "crashReportingDsn", "")
}

CI supplies the file, one line per secret, before invoking Gradle:

- run: |
    echo 'CRASH_DSN=${{ secrets.CRASH_DSN }}' > ./local.properties
    echo 'INTEGRATION_API_KEY=${{ secrets.INTEGRATION_API_KEY }}' >> ./local.properties

Traps

Empty strings, never a missing field. Both branches must declare the same field names. A

field defined only in one branch is a compile error in common code for the other, so the branch

without secrets would have to be a different source set — which is exactly the structure you are

trying to avoid. An empty value keeps one codebase.

Make blank mean absent at runtime, once, in a named check. Then the disable path is one

function every consumer already calls:

fun isIntegrationAvailable(): Boolean = apiKey.isNotEmpty() && sharedSecret.isNotEmpty()

Every entry point returns early on it, and the settings screen hides the whole block. The

paid-integration branch and the stub branch then behave identically for a build with no

credentials, so a contributor without secrets gets a working app rather than a broken feature.

Catch and fall through, do not let a missing file fail the build. A fresh clone has no

properties file. Loading it inside a try whose catch writes the same empty fields means a

contributor's first build succeeds — that is the difference between "you must be an employee to

compile this" and "you must be an employee to use this one feature".

Never read the generated file for its values. The generated source under the build directory

holds whatever the last local build injected, credentials included. Read the *shape* there if you

must; take the values from the build script.

Guard the consumer on the value, not on the flavour. Initialise optional services only when the

field is non-blank — if (BuildKonfig.crashReportingDsn.isNotEmpty()) { … }. A flavour check

compiles fine and then breaks the moment someone builds the full flavour without a secrets file.

Newer Gradle rejects the implicit dependency between the generation task and its consumers.

Under Gradle 9's strict task-dependency validation, a task that consumes generated sources without

declaring it fails the build rather than warning. If the plugin has not caught up, wire it yourself

by name:

afterEvaluate {
    tasks.matching { it.name.startsWith("prepare") && it.name.endsWith("ArtProfile") }
        .configureEach { dependsOn("generateBuildKonfig") }
}

The error names both tasks but *fails* the consuming one, so the message reads as being about a

subsystem you never touched. Match by name pattern rather than listing tasks: the set varies with

build type and flavour, so a hardcoded list breaks on the next variant added.

Version values belong in the catalog, not in this block. Reading them out of the version

catalog (libs.versions.version.name.get()) keeps one source of truth for a value that also

appears in packaging config and release workflows.

Verifying it

# the untracked file must be ignored AND actually untracked
git check-ignore -v local.properties
git ls-files --error-unmatch local.properties   # must report "did not match any file"

# what the current build injected — SHAPE ONLY, values elided so no secret hits the terminal
find <app-module>/build/generated/source/buildkonfig -name 'BuildKonfig.kt' \
  -exec sed 's/=.*/= <elided>/' {} \;

# nothing secret should ever appear in a tracked file
git grep -nI -e 'API_KEY' -e 'SECRET' -e 'DSN' -- '*.kts' '*.kt' '*.yml'

The last one should only ever hit *names* — the property keys and the secret references in CI. A

hit on a value means it has already been committed, and the fix is rotating the credential, not

deleting the line.

How to use it

Copy the folder

Take maxrave-dev/buildkonfig-secrets-flavors 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.