kotlin-script
Skill0 starsChanged 3 months agoMITView on GitHub
Write self-contained, portable, testable Kotlin command-line tools — a small Gradle project with pure-logic/side-effect separation (boundaries as interfaces) that JUnit 5 can drive with fakes, a Clikt CLI, and a compiler/test/ktlint gate. Use as soon as the user wants a Kotlin script, CLI utility, automation tool, or "glue" code (orchestrating git, gradle, curl, file manipulation, parsing) in Kotlin, even if they never say "Gradle". Also triggers on adding tests to an existing Kotlin tool, making one portable/testable, or porting a shell/Python script to Kotlin.
In this repository7 other files it gives its agents.
- go-scriptSkill
- python-scriptSkill
- rust-scriptSkill
- scripteratopsSkill
- shell-scriptSkill
- swift-scriptSkill
- typescript-scriptSkill
The file
We split this skill into the rules it gives an agent
4 rules. About 0 are common, 0 are distinctive, and 4 are very specific to this project.
≈0
Common · estimate
Many files like this have a rule of this kind.
≈0
Distinctive · estimate
This author's own choices. Worth a look.
4
Very specific
Tied to their project's names, files or tools. Probably skip.
- Common
- Distinctive
- Very specific
- Very specific: line 50
Keep dependencies few: Clikt (CLI) and kotlinx.serialization (JSON config) cover most tools.
- Very specific: line 177
Single native binary (no JVM): add the org.graalvm.buildtools.native Gradle plugin and a GraalVM native-image toolchain, then ./gradlew …
- Very specific: line 221
Every tool ships tests in core/src/test/kotlin, run by ./gradlew test.
- Very specific: line 228
Use kotlin.test assertions (assertEquals, assertTrue) so the test reads the same on any Kotlin target.
≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.
Files
Kotlin script
Standard for all Kotlin command-line tools: a small Gradle project whose logic
lives in a testable core module, driven by a thin Clikt app executable,
verified by the compiler + JUnit 5 + ktlint. The goal is a self-contained tool
(no external install beyond a JDK) whose logic is trivially testable — the Kotlin
counterpart of the python-script and swift-script skills.
Three decisions are already settled. Don't second-guess them, apply them:
- Distribution → a Gradle project,
core(library) +app(executable). Kotlin, like Swift, has no clean testable single-file story (.main.ktsand JBang exist but can't be unit-tested the way this architecture needs). A two-module project buys dependencies and testability. - Interface → Clikt (
com.github.ajalt.clikt). - Tests → JUnit 5 + kotlin.test (
@Test/assertEquals), made easy by an architecture that isolates side effects behind interfaces.
This file carries the decisions; the code implementing them is a complete,
gate-passing project in assets/template/. Copy and adapt that project — don't
retype it from here. Then delete what your tool doesn't earn.
1. Project layout
tool/
├── settings.gradle.kts # includes :core and :app
├── build.gradle.kts # plugins declared (apply false), from the catalog
├── gradle/libs.versions.toml # every dependency pinned to an exact version
├── .editorconfig # 2-space indent (ktlint reads it)
├── core/ # pure logic + boundaries → LIBRARY module (testable)
│ ├── build.gradle.kts
│ └── src/main/kotlin/tool/ # Errors, Logic, Boundaries, Config, Color, Log, Runner, Http, Work
│ └── src/test/kotlin/tool/ # JUnit 5 tests
└── app/ # Clikt CLI → thin EXECUTABLE module
├── build.gradle.kts # application plugin, mainClass = tool.MainKt
└── src/main/kotlin/tool/Main.ktThe split is the whole point: the app module only parses arguments and wires
things together, so everything worth testing lives in core, which the test
module (and any other consumer) can depend on directly.
- Pin every dependency to an exact version in
gradle/libs.versions.toml(a version catalog). A tool has no reason to float; bump deliberately. - Keep dependencies few: Clikt (CLI) and kotlinx.serialization (JSON
config) cover most tools. The JDK gives you subprocess (
ProcessBuilder), HTTP (java.net.http), files (java.nio), and env for free — reach for a dependency only for a real gain. - JSON is the "free" config format (kotlinx.serialization). Kotlin has no stdlib TOML; use JSON unless comments justify a TOML dependency.
- Logging needs no dependency: the template ships a tiny stderr
Log(level + color). Pull in SLF4J/Logback only if a library you use already needs it.
2. Structure
The structure is designed to make tests trivial: pure logic never touches the outside world directly. Everything that leaves the process (subprocess, network, filesystem, environment) sits behind an interface boundary, injected into the orchestration. Tests pass a fake implementation; production passes the real one.
- Boundaries are interfaces —
CommandRunner,HttpFetcher,EnvironmentReading. These are the single mock hooks. A fake is a 3-line class. - Pure logic (
transform) — input → output, no I/O, tested with plainassertEquals. - Config is a
@Serializable data class; precedence is one pure function,resolveConfig(cli, environment, file), tested without any fake. - Orchestration (
doWork) assembles boundary calls + pure logic: data positional, behaviour flags with defaults, returns a count. It takes the boundaries as interfaces, so a test drives it with no process and no socket. - The
appis thin: parse arguments, build the real boundaries, calldoWork, map errors to an exit code. Nothing worth testing lives here.
2.1 Blocking is fine (no coroutines needed)
Unlike Swift 6, there is no strict-concurrency pressure here: subprocess and HTTP are ordinary blocking calls on the main thread. Don't reach for coroutines unless the tool genuinely fans out concurrent work — a CLI that runs one thing then exits doesn't. The subprocess boundary drains its pipes on background threads (§2.2), which is an implementation detail, not a coroutine API.
2.2 Subprocess boundary
ProcessRunner routes all subprocess calls through ProcessBuilder:
- Never a shell. Pass an argument list (
ProcessBuilder(listOf("git", …))) — nosh -c, so no injection from a filename or argument. - Always a timeout.
process.waitFor(seconds, TimeUnit.SECONDS); onfalse,destroyForcibly()and throwToolError.Timeout(→ exit 124). Both pipes are drained on background threads so a child flooding stdout/stderr can't deadlock the wait. - On non-zero exit, throw
ToolError.Command(command, code, stderr).
2.3 Config and precedence
Resolve settings with an explicit, documented precedence — higher wins:
CLI flags > environment variables > config file > built-in defaultsresolveConfig is pure (takes already-loaded Config layers, ?:-chains them so
an absent value never clobbers a lower layer). Each source is read by one boundary
— readConfigFile (JSON via kotlinx.serialization), configFromEnvironment
(through the EnvironmentReading hook). Secrets get no CLI flag (argv is
world-readable via ps); a token comes from the environment or config file.
Never log a raw Config — route it through redacted(…), which masks declared
secret fields.
2.4 HTTP requests
JdkHttpFetcher wraps java.net.http.HttpClient in one boundary:
- Set an explicit request timeout — an unbounded call hangs forever.
- Raise on non-2xx yourself. The client does not throw on 4xx/5xx, so check
statusCode()and throwToolError.Http; a 404 page would otherwise sail through as a "successful" body. - Inject the client (or fake the
HttpFetcherinterface) so tests run offline.
2.5 Colorized logging
Logging uses the template's own Log, constructed once in app and writing
to stderr (never stdout — stdout is for program data). Color:
shouldColor(mode, isTty, environment)is a pure decision — precedence--color=always/never>NO_COLOR/FORCE_COLOR> TTY (and notTERM=dumb).Logcolorizes the level name when enabled. TTY detection inappprefersConsole.isTerminal()(JDK 22+), because on JDK 22+System.console()is non-null even when piped; it resolves the method reflectively so it still compiles against the JDK 21 floor, falling back toSystem.console() != nullon JDK 21 (where a non-null console already implies a terminal).
2.6 Robustness & signals (JVM specifics)
- Bound every subprocess and HTTP call with a timeout (§2.2, §2.4).
- Signals are the JVM's job, not yours. SIGINT (Ctrl-C) → the JVM exits 130,
SIGTERM → 143, after running shutdown hooks; SIGPIPE is already ignored by
the JVM (a closed downstream pipe surfaces as an
IOExceptionon write, not a kill). So there is no C-style signal handler to install — add aRuntime.getRuntime().addShutdownHook { … }only if you have real cleanup. - Preflight external tools for glue scripts (a
which-style check up front), throwingToolError.Configlisting every missing tool.
2.7 Subcommands (if needed)
Clikt handles this natively: a root CliktCommand with
subcommands(A(), B()), each its own CliktCommand. No manual dispatch.
2.8 Man page (optional)
Clikt has no built-in man-page generator (that was the trade-off vs picocli).
Generate one from the CLI's own --help with help2man:
help2man --no-info --name "tool" "app/build/install/tool/bin/tool" > tool.1
sudo install -m 644 tool.1 /usr/local/share/man/man1/tool.1Then man tool works. (If native man generation matters more than Clikt's
ergonomics, switch the CLI to picocli, whose picocli-codegen emits man
pages and which also has first-class GraalVM native-image support.)
2.9 Build, install & README (optional, for tools you distribute)
The README carries the environment set-up, not just the gates: a
Prerequisites section with per-platform install of a JDK 21+
(macOS/Linux/Windows; the committed wrapper handles Gradle) and the dependency
list, pointing to gradle/libs.versions.toml as the pinned source. For a
distributed binary, an optional --dependencies flag printing the pinned versions
is a nice touch — skip it for source-run tools.
- App image (JVM):
./gradlew :app:installDistproducesapp/build/install/tool/(abin/toollauncher +lib/*.jar). Copy the tree and symlink the launcher onto PATH (see the templateREADME.md). Needs a JRE at runtime. - Single native binary (no JVM): add the
org.graalvm.buildtools.nativeGradle plugin and a GraalVMnative-imagetoolchain, then./gradlew :app:nativeCompile→ one standalone binary (the Swift-like story). - Limits: the app image needs a JRE; the native binary is per-OS — build on each platform.
3. CLI contract
Clikt gives most of the baseline for free:
-h/--helpis automatic;--versionviaversionOption("…")ininit.- Verbosity:
option("-v","--verbose").counted()counts occurrences (-vv= 2); same forquiet. Map the net count to a level withlogLevelForVerbosity(verbose - quiet). --coloris achoice("auto" to …, "always" to …, "never" to …)over aColorModeenum.--dry-runshort-circuits before output, announced at warning level.- Group the help once flags pile up (the same rule as the other skills): put
the tool's own flags in
OptionGroup(name = …)classes (Config sources,Behaviour, …); Clikt renders each as its own--helpheading, with the baseline flags in the default section. - Stream discipline: program data → stdout (
println); logs, progress, diagnostics → stderr (theLog).
Exit codes
Map typed errors to a code in app's catch. Keep the convention:
| Code | Meaning |
0 | Success (also --help/--version) |
1 | Runtime error (a ToolError caught in run()); Clikt also uses 1 for usage errors |
124 | A subprocess exceeded its timeout (ToolError.Timeout) |
130 | Interrupted — SIGINT / Ctrl-C (JVM default) |
143 | Terminated — SIGTERM / kill (JVM default) |
ToolError.exitCode keeps the code with the failure category; run() throws
ProgramResult(error.exitCode). Note the one divergence from python/swift: Clikt
maps usage errors to 1, not 2 (override CliktError.statusCode if you
insist on 2). Broken-pipe 141 is not automatic on the JVM — a closed
downstream pipe throws IOException; handle it only if your tool streams to
another process.
4. Tests with JUnit 5 + kotlin.test
Every tool ships tests in core/src/test/kotlin, run by ./gradlew test.
- Never test subprocess or network for real — inject a fake implementing the boundary interface. That's what the interface is for.
- Fakes are tiny classes (
class FakeRunner(val output: String) : CommandRunner), not a mocking framework. - Pure logic and precedence are tested by direct call — no fakes at all.
- Use
kotlin.testassertions (assertEquals,assertTrue) so the test reads the same on any Kotlin target; JUnit 5 is just the runner (useJUnitPlatform()).
Coverage priority: pure logic and orchestration first, then config precedence, then arg parsing.
5. Quality gates
Three gates. As with Swift, there is no separate type checker — the compiler is
the type gate, so mypy collapses into ./gradlew build.
./gradlew build # compiles — the Kotlin compiler IS the type gate
./gradlew test # JUnit 5 + kotlin.test
./gradlew ktlintCheck # format/lint- A first run downloads the Kotlin/Gradle toolchain — expect it to be slow, then
cached. The template pins
jvmToolchain(21); thefoojay-resolver-conventionplugin (insettings.gradle.kts) auto-provisions a matching JDK, so any installed JDK works — Gradle downloads JDK 21 when the host differs. ./gradlew ktlintFormatauto-fixes most lint findings.- Indentation is 2-space, set in
.editorconfig(which ktlint reads). Kotlin's own "official" style is 4-space — flipindent_sizeto 4 to follow the ecosystem instead.
Summary of what to produce
Faced with a Kotlin tool request, produce:
- A Gradle project copied from
assets/template/: logic + boundaries incore, a thin Cliktapp, catalog-pinned deps, the §2 structure, the §3 CLI contract and exit codes, and the robustness rules the tool's job earns (shelling out earns the timeout + preflight; the network earns the HTTP boundary + timeout; holding config/secrets earns precedence + redaction). Delete what it doesn't earn. - Tests in
core/src/testcovering pure logic, config precedence, orchestration (with fakes), and any boundary the tool ships. - In one line: how to run it (
./gradlew :app:run --args="…"), and that the three gates —build+test+ktlintCheck— are green (needs a JDK 21+).
Does it work?
Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.
No reviews yet
People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.
- It worked
- Partly
- It didn't
Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.
Your trail
Files you open will collect here, so you can find them again.
Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.
Now do this for your own.
Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.

