agentleFS
buildosaurusbuildosaurus/scripteratops/skills/kotlin-script/SKILL.md

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.

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
  1. Very specific:

    Keep dependencies few: Clikt (CLI) and kotlinx.serialization (JSON config) cover most tools.

    line 50
  2. Very specific:

    Single native binary (no JVM): add the org.graalvm.buildtools.native Gradle plugin and a GraalVM native-image toolchain, then ./gradlew …

    line 177
  3. Very specific:

    Every tool ships tests in core/src/test/kotlin, run by ./gradlew test.

    line 221
  4. Very specific:

    Use kotlin.test assertions (assertEquals, assertTrue) so the test reads the same on any Kotlin target.

    line 228

≈ “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.

Keep what fits and drop the rest. Your version, ready for your agents.
Files
SKILL.md

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:

  1. Distribution → a Gradle project, core (library) + app (executable). Kotlin, like Swift, has no clean testable single-file story (.main.kts and JBang exist but can't be unit-tested the way this architecture needs). A two-module project buys dependencies and testability.
  2. Interface → Clikt (com.github.ajalt.clikt).
  3. 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.kt

The 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 plain assertEquals.
  • 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 app is thin: parse arguments, build the real boundaries, call doWork, 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", …))) — no sh -c, so no injection from a filename or argument.
  • Always a timeout. process.waitFor(seconds, TimeUnit.SECONDS); on false, destroyForcibly() and throw ToolError.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 defaults

resolveConfig 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 throw ToolError.Http; a 404 page would otherwise sail through as a "successful" body.
  • Inject the client (or fake the HttpFetcher interface) 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 not TERM=dumb).
  • Log colorizes the level name when enabled. TTY detection in app prefers Console.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 to System.console() != null on 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 IOException on write, not a kill). So there is no C-style signal handler to install — add a Runtime.getRuntime().addShutdownHook { … } only if you have real cleanup.
  • Preflight external tools for glue scripts (a which-style check up front), throwing ToolError.Config listing 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.1

Then 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:installDist produces app/build/install/tool/ (a bin/tool launcher + lib/*.jar). Copy the tree and symlink the launcher onto PATH (see the template README.md). Needs a JRE at runtime.
  • Single native binary (no JVM): add the org.graalvm.buildtools.native Gradle plugin and a GraalVM native-image toolchain, 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/--help is automatic; --version via versionOption("…") in init.
  • Verbosity: option("-v","--verbose").counted() counts occurrences (-vv = 2); same for quiet. Map the net count to a level with logLevelForVerbosity(verbose - quiet).
  • --color is a choice("auto" to …, "always" to …, "never" to …) over a ColorMode enum.
  • --dry-run short-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 --help heading, with the baseline flags in the default section.
  • Stream discipline: program data → stdout (println); logs, progress, diagnostics → stderr (the Log).

Exit codes

Map typed errors to a code in app's catch. Keep the convention:

CodeMeaning
0Success (also --help/--version)
1Runtime error (a ToolError caught in run()); Clikt also uses 1 for usage errors
124A subprocess exceeded its timeout (ToolError.Timeout)
130Interrupted — SIGINT / Ctrl-C (JVM default)
143Terminated — 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.test assertions (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); the foojay-resolver-convention plugin (in settings.gradle.kts) auto-provisions a matching JDK, so any installed JDK works — Gradle downloads JDK 21 when the host differs.
  • ./gradlew ktlintFormat auto-fixes most lint findings.
  • Indentation is 2-space, set in .editorconfig (which ktlint reads). Kotlin's own "official" style is 4-space — flip indent_size to 4 to follow the ecosystem instead.

Summary of what to produce

Faced with a Kotlin tool request, produce:

  1. A Gradle project copied from assets/template/: logic + boundaries in core, a thin Clikt app, 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.
  2. Tests in core/src/test covering pure logic, config precedence, orchestration (with fakes), and any boundary the tool ships.
  3. 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+).

Download now (ZIP)

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
What did you use it for? e.g. Reviewing pull requests in a Rails app

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.