|
| 1 | +:date: 2026-05-13 |
| 2 | +:author: Jen Basch |
| 3 | +:author-url: https://github.com/HT154 |
| 4 | + |
| 5 | += Building CLI Tools with Pkl |
| 6 | + |
| 7 | +:use-link-attrs: |
| 8 | + |
| 9 | +// tag::byline[] |
| 10 | +++++ |
| 11 | +<div class="blog-byline"> |
| 12 | +++++ |
| 13 | +by link:https://github.com/HT154[Jen Basch] on May 13th, 2026 |
| 14 | +++++ |
| 15 | +</div> |
| 16 | +++++ |
| 17 | +// end::byline[] |
| 18 | + |
| 19 | +// tag::excerpt[] |
| 20 | +Some Pkl use cases require evaluation to be parameterized by data supplied at runtime by a user, such as code generation or dataset analysis tools. |
| 21 | +xref:main:language-reference:index.adoc#resources[External property (`prop:`) resources] provide a mechanism for soliciting user input, but they're limited and using them can be a clunky experience. |
| 22 | +Pkl 0.31 introduced the xref:main:pkl-cli:index.adoc#cli-tools[`pkl run` command] and link:https://pkl-lang.org/package-docs/pkl/current/Command/index.html[`pkl:Command` standard library module] to provide a framework for building CLI tools that look, feel, and work like good tools should: standard flag syntax, input validation, subcommands, generated help text, and shell completion. |
| 23 | +// end::excerpt[] |
| 24 | + |
| 25 | +CLI tools written in Pkl provide the same basic I/O capabilities that normal Pkl evaluation does: writing to standard output and the filesystem and reading resources, including via xref:main:language-reference:index.adoc#external-readers[external readers], but there are a couple key differences from regular evaluation: |
| 26 | + |
| 27 | +* File output is relative to the command's working directory, not `--multiple-file-output-path`. |
| 28 | +* Imports may be specified dynamically in terms of CLI options using link:https://pkl-lang.org/package-docs/pkl/current/Command/Import.html[`Command.Import`]. |
| 29 | + |
| 30 | +These capabilities have enabled rewriting the code generation tools for xref:swift:ROOT:codegen.adoc[pkl-swift] and xref:go:ROOT:codegen.adoc[pkl-go]. |
| 31 | +We now publish them purely as Pkl modules inside their respective packages instead of separately distributed binaries written in each target language that must function cooperatively with the Pkl portion of the code generator. |
| 32 | +This reduces the complexity of our code, our release processes, and adoption requirements for downstream users. |
| 33 | + |
| 34 | +== Running Commands |
| 35 | + |
| 36 | +Running CLI tools built with Pkl is simple: |
| 37 | + |
| 38 | +[source,bash] |
| 39 | +---- |
| 40 | +pkl run <module> [command options] |
| 41 | +---- |
| 42 | + |
| 43 | +Every command has automatically generated CLI help via the `--help`/`-h` flag. |
| 44 | + |
| 45 | +Pkl commands distributed inside xref:main:language-reference:index.adoc#project-dependencies[dependencies of a Project] may also be executed directly using the dependency's alias: |
| 46 | + |
| 47 | +[source,bash] |
| 48 | +---- |
| 49 | +pkl run @<dependency alias>/<module> <command options> |
| 50 | +---- |
| 51 | + |
| 52 | +On *nix systems, commands in local files may also use a https://en.wikipedia.org/wiki/Shebang_(Unix)[shebang] to allow direct script execution: |
| 53 | + |
| 54 | +[source,pkl] |
| 55 | +.my-command.pkl |
| 56 | +---- |
| 57 | +#!/usr/bin/env -S pkl run |
| 58 | +// ... |
| 59 | +---- |
| 60 | + |
| 61 | +[source,bash] |
| 62 | +---- |
| 63 | +chmod +x my-command.pkl |
| 64 | +./my-command.pkl |
| 65 | +---- |
| 66 | + |
| 67 | +In this execution mode, a `shell-completion` subcommand can be used to generate autocomplete scripts for the bash, zsh, and fish shells. |
| 68 | + |
| 69 | +== Building Commands |
| 70 | + |
| 71 | +Commands are defined declaratively by extending the `pkl:Command` module. |
| 72 | +The module's `output` determines the command's behavior: |
| 73 | + |
| 74 | +* `output.bytes` (by default, derived from `output.text` or `output.value`) is produced to standard output. |
| 75 | +* `output.files` entries are written to the filesystem. Unlike `pkl eval`, file paths are relative to the working directory, not a specified output path. |
| 76 | + |
| 77 | +Command options, both named flags and positional arguments, are defined by the declared class on the module's `options` property. |
| 78 | +Any class or module type may be used for `options`, so commands can easily share and inherit options. |
| 79 | +When a command is executed, its `options` property is overridden with the actual parsed option values. |
| 80 | + |
| 81 | +Each property of the command's options class (excluding `hidden` and `local` properties) becomes an option of the command. |
| 82 | +A property's name becomes its name on the command line, its doc comment becomes its CLI help text, and its type determines how the raw user-provided value is parsed as a Pkl value. |
| 83 | +Properties with nullable types or default values become optional. |
| 84 | +Option behavior is determined by annotating option properties with one of several annotations: `@Argument` for positional arguments, `@Flag` for named flags, `@BooleanFlag` for `--<name>`/`--no-<name>` pairs, and `@CountedFlag` counts how many times the flag as passed. |
| 85 | + |
| 86 | +For `@Argument` and `@Flag`, all of Pkl's primitive types (`String`, `Boolean`, and numerics) are all supported, plus more complex types like `Listing`/`List`, `Mapping`/`Map`, `Set`, `Pair`, and string literal unions. |
| 87 | +Arbitrary types may be also supported through further customization of the option's behavior using the annotations' `convert` and `transformAll` properties. |
| 88 | + |
| 89 | +Here's an example command that shows a variety of different options usage: |
| 90 | +[source,pkl] |
| 91 | +.my-command.pkl |
| 92 | +---- |
| 93 | +extends "pkl:Command" |
| 94 | +
|
| 95 | +options: Options |
| 96 | +
|
| 97 | +class Options { |
| 98 | + /// Maximum number of tries to attempt operation before giving up. |
| 99 | + `max-tries`: UInt // <1> |
| 100 | +
|
| 101 | + /// Whether to use cache data locally. |
| 102 | + @BooleanFlag |
| 103 | + cache: Boolean = true // <2> |
| 104 | +
|
| 105 | + /// Log verbosity. |
| 106 | + @CountedFlag { shortName = "v" } |
| 107 | + verbose: Int(this <= 3) // <3> |
| 108 | +
|
| 109 | + /// File paths to operate on. |
| 110 | + @Argument { completionCandidates = "paths" } |
| 111 | + path: Listing<String> // <4> |
| 112 | +
|
| 113 | + /// Duration after which operation will be timed out. |
| 114 | + @Flag { convert = module.convertDuration; metavar = "duration" } |
| 115 | + `connection-timeout`: Duration? // <5> |
| 116 | +} |
| 117 | +---- |
| 118 | +<1> Flag `--max-tries=<uint>` (no annotation is equivalent to annotating with `@Flag`); `UInt` validates the input is an integer `>= 0`; Flag is required. |
| 119 | +<2> Flags `--cache` and `--no-cache` correspond to `true` and `false` values, respectively; Flag is optional and defaults to `true`. |
| 120 | +<3> Flag `--verbose`/`-v` may be specified multiple times, each increasing the option's value; Flag is optional and defaults to `0`. |
| 121 | +<4> Argument `<path>` accepts multiple string values; shell completion suggests file/directory paths as possible values; Argument is optional and defaults to an empty `Listing`. |
| 122 | +<5> Flag `--connection-timeout=<duration>` accepts a string matching Pkl's `Duration` syntax and converts it to a `Duration` value; The metavar displayed in the CLI help text is `"duration"`; Flag is optional. |
| 123 | + |
| 124 | +This command's generated CLI help looks like this: |
| 125 | +[source,terminaloutput] |
| 126 | +---- |
| 127 | +$ pkl run my-command.pkl |
| 128 | +Usage: my-command.pkl [<options>] [<path>]... <command> [<args>]... |
| 129 | +
|
| 130 | +Options: |
| 131 | + --max-tries=<uint> Maximum number of tries to attempt operation before |
| 132 | + giving up. |
| 133 | + --cache / --no-cache Whether to use cache data locally. |
| 134 | + -v, --verbose Log verbosity. |
| 135 | + --connection-timeout=<duration> |
| 136 | + Duration after which operation will be timed out. |
| 137 | + -h, --help Show this message and exit |
| 138 | +
|
| 139 | +Arguments: |
| 140 | + <path> File paths to operate on. |
| 141 | +
|
| 142 | +Commands: |
| 143 | + shell-completion Generate a completion script for the given shell |
| 144 | +---- |
| 145 | + |
| 146 | +=== Subcommands |
| 147 | + |
| 148 | +Commands may also have subcommands. |
| 149 | +Subcommands are also Pkl modules that extend `pkl:Command`. |
| 150 | + |
| 151 | +[source,pkl] |
| 152 | +.my-command.pkl |
| 153 | +---- |
| 154 | +extends "pkl:Command" |
| 155 | +
|
| 156 | +command { |
| 157 | + subcommands { |
| 158 | + import("my-subcommand.pkl") |
| 159 | + } |
| 160 | +} |
| 161 | +
|
| 162 | +// ... |
| 163 | +---- |
| 164 | + |
| 165 | +[source,pkl] |
| 166 | +.my-subcommand.pkl |
| 167 | +---- |
| 168 | +extends "pkl:Command" |
| 169 | +
|
| 170 | +import "my-command.pkl" |
| 171 | +
|
| 172 | +parent: `my-command` |
| 173 | +
|
| 174 | +// ... |
| 175 | +---- |
| 176 | + |
| 177 | +Like root commands, when a subcommand is executed, its `options` property is overridden with the actual parsed option values. |
| 178 | +Similarly, the `parent` property is set to the instantiated parent command module with _its_ `options` property set (and `parent`, if applicable). |
| 179 | +Overriding the type of the `parent` property is optional; it asserts that there is a parent command and the parent is a specific command, which can simplify code for complex CLIs. |
| 180 | +The `root` property may be overridden similarly. |
| 181 | + |
| 182 | +This subcommand can then be executed: |
| 183 | + |
| 184 | +[source,bash] |
| 185 | +---- |
| 186 | +pkl run my-command.pkl [root command options] my-subcommand [subcommand options] |
| 187 | +---- |
| 188 | + |
| 189 | +=== Dynamic Imports |
| 190 | + |
| 191 | +One of the key differentiators between regular Pkl evaluation and CLI commands is that commands offer a form of dynamic importing. |
| 192 | +Normal Pkl import statements (`import "<uri>"`) and expressions (`import("<uri>")`) only accept string literals, not arbitrary expressions. |
| 193 | +Command options may return link:https://pkl-lang.org/package-docs/pkl/current/Command/Import.html[`Import`] values from option `convert` and `transformAll` functions to trigger dynamic imports. |
| 194 | + |
| 195 | +One example of using dynamic imports is the pkl-swift code generator. |
| 196 | +This command must accept arbitrary `Module` values and analyze them to generate Swift code. |
| 197 | +Here, https://pkl-lang.org/package-docs/pkl/current/Command/Argument.html#convert[`Argument.convert`] is set to a function that directly converts the raw string value to a directive to import the module by URI: |
| 198 | +[source,pkl] |
| 199 | +---- |
| 200 | +/// The Pkl modules to generate as Swift. |
| 201 | +@Argument { |
| 202 | + convert = (it) -> new Import { uri = it } |
| 203 | + completionCandidates = "paths" |
| 204 | +} |
| 205 | +pklInputModules: Listing<Module>? |
| 206 | +---- |
| 207 | + |
| 208 | +=== Advanced Patterns |
| 209 | + |
| 210 | +These capabilities compose to enable some patterns that may not be obvious but are extremely useful! |
| 211 | + |
| 212 | +==== Option reuse |
| 213 | + |
| 214 | +Many command line tools use a common set of options across several subcommands. |
| 215 | +Pkl's own CLI exhibits this pattern: many options are shared by `pkl eval`, `pkl test`, and other subcommands. |
| 216 | + |
| 217 | +There are two main approaches available for option reuse: |
| 218 | + |
| 219 | +* Parent commands contain shared options, subcommands access `parent.options`. |
| 220 | ++ |
| 221 | +[source,pkl] |
| 222 | +.my-command.pkl |
| 223 | +---- |
| 224 | +extends "pkl:Command" |
| 225 | +
|
| 226 | +command { |
| 227 | + subcommands { |
| 228 | + import("my-subcommand.pkl") |
| 229 | + } |
| 230 | +} |
| 231 | +
|
| 232 | +options: Options |
| 233 | +
|
| 234 | +class Options { |
| 235 | + @Flag |
| 236 | + `parent-flag`: String |
| 237 | +} |
| 238 | +---- |
| 239 | ++ |
| 240 | +[source,pkl] |
| 241 | +.my-subcommand.pkl |
| 242 | +---- |
| 243 | +extends "pkl:Command" |
| 244 | +
|
| 245 | +options: Options |
| 246 | +
|
| 247 | +class Options { |
| 248 | + @Flag |
| 249 | + `subcommand-only-flag`: String |
| 250 | +} |
| 251 | +---- |
| 252 | ++ |
| 253 | +On the command line, `--parent-flag` and its value must precede the subcommand name. |
| 254 | + |
| 255 | +* Options classes directly inherit from a shared base class. |
| 256 | ++ |
| 257 | +[source,pkl] |
| 258 | +.BaseOptions.pkl |
| 259 | +---- |
| 260 | +open module BaseOptions |
| 261 | +import "pkl:Command" |
| 262 | +
|
| 263 | +@Command.Flag |
| 264 | +`shared-flag`: String |
| 265 | +---- |
| 266 | ++ |
| 267 | +[source,pkl] |
| 268 | +.my-subcommand.pkl |
| 269 | +---- |
| 270 | +extends "pkl:Command" |
| 271 | +import "BaseOptions.pkl" |
| 272 | +
|
| 273 | +options: Options |
| 274 | +
|
| 275 | +class Options extends BaseOptions { // <1> |
| 276 | + @Flag |
| 277 | + `subcommand-only-flag`: String |
| 278 | +} |
| 279 | +---- |
| 280 | ++ |
| 281 | +On the command line, `--shared-flag` and its value must follow the subcommand name. |
| 282 | + |
| 283 | +==== Import fallback to a well-known path |
| 284 | + |
| 285 | +In some cases, it may be desirable to pass a Pkl config file to a command. |
| 286 | +Often, command line tools will load such configurations from well known paths in the working directory or home directory. |
| 287 | + |
| 288 | +This example, also from pkl-swift, loads a link:https://pkl-lang.org/package-docs/pkg.pkl-lang.org/pkl-swift/pkl.swift/current/GeneratorSettings/index.html[`GeneratorSettings`] from the path specified. |
| 289 | +If no path is specified but a `generator-settings.pkl` exists in the working directory, that file will be loaded instead. |
| 290 | + |
| 291 | +[source,pkl] |
| 292 | +---- |
| 293 | +/// The generator-settings.pkl file to use. (default: ./generator-settings.pkl if present) |
| 294 | +@Flag { |
| 295 | + convert = (it) -> new Import { uri = it } // <1> |
| 296 | + transformAll = (values) -> |
| 297 | + values.firstOrNull // <2> |
| 298 | + ?? if (read?("file://\(pwd)/generator-settings.pkl") != null) // <3> |
| 299 | + new Import { uri = "./generator-settings.pkl" } |
| 300 | + else |
| 301 | + new GeneratorSettings {} // <4> |
| 302 | + completionCandidates = "paths" |
| 303 | +} |
| 304 | +`generator-settings`: GeneratorSettings |
| 305 | +---- |
| 306 | +<1> If a value is supplied, direct Pkl to import it. |
| 307 | +<2> If any value is supplied, use it, otherwise fall back to checking the working directory. |
| 308 | +<3> If the file exists in the working directory, import it. Note that the absolute `file:` URI is used here because `read` is relative to the enclosing module URI and this command is most frequently executed from inside the `pkl.swift` package. |
| 309 | +<4> Otherwise, fall back to an empty/default value. |
| 310 | + |
| 311 | +== Testing Commands |
| 312 | + |
| 313 | +Pkl commands are defined by writing regular modules, which means they can also be tested just like regular Pkl modules! |
| 314 | +Test code may import a command module and instantiate it, directly overriding `options` and/or `parent` as needed to mock out user input: |
| 315 | + |
| 316 | +[source,pkl] |
| 317 | +---- |
| 318 | +amends "pkl:test" |
| 319 | +
|
| 320 | +import "my-command.pkl" |
| 321 | +import "my-subcommand.pkl" |
| 322 | +
|
| 323 | +examples { |
| 324 | + ["Test my-command"] { |
| 325 | + (`my-command`) { |
| 326 | + options { |
| 327 | + // Set my-command options here... |
| 328 | + } |
| 329 | + }.output.text |
| 330 | + } |
| 331 | + ["Test my-subcommand"] { |
| 332 | + (`my-subcommand`) { |
| 333 | + parent { // this amends `my-command` |
| 334 | + options { |
| 335 | + // Set my-command options here... |
| 336 | + } |
| 337 | + } |
| 338 | + options { |
| 339 | + // Set my-subcommand options here... |
| 340 | + } |
| 341 | + }.output.text |
| 342 | + } |
| 343 | +} |
| 344 | +---- |
0 commit comments