jgvariant-tool: New module.

Adds a command line tool that can read and (in the future) manipulate
GVariant-formatted files.

Change-Id: Icc92eb409a97e7cf72dfd7535f6a8b3587dd4a48
diff --git a/README.md b/README.md
index d01d141..caa9938 100644
--- a/README.md
+++ b/README.md
@@ -40,7 +40,31 @@
     List<ExampleRecord> example = decoder.decode(ByteBuffer.wrap(bytes));
 
 
-## Installation
+## Command line tool
+
+The `jgvariant-tool` module contains a tool called `jgvariant` that can
+be used to manipulate [GVariant][]-formatted files from the command line.
+Its primary purpose is to enable the scripting of [OSTree][] repository
+management tasks.
+
+Usage example (dumping the contents of an [OSTree][] summary file):
+
+    $ jgvariant ostree summary read ./jgvariant-ostree/src/test/resources/ostree/summary
+
+You can build the tool either as a shaded JAR or as a native executable.
+
+To build and run a shaded JAR:
+
+    $ mvn package -pl jgvariant-tool -am -Pshade
+    $ java -jar /home/mulk/Arbeitskasten/jgvariant/jgvariant-tool/target/jgvariant-tool-*.jar
+
+To build and run a native executable:
+
+    $ mvn package -pl jgvariant-tool -am -Pnative
+    $ ./jgvariant-tool/target/jgvariant
+
+
+## Library installation
 
 ### Usage with Maven
 
diff --git a/jgvariant-parent/pom.xml b/jgvariant-parent/pom.xml
index 91acdfa..4a2c2f2 100644
--- a/jgvariant-parent/pom.xml
+++ b/jgvariant-parent/pom.xml
@@ -59,10 +59,12 @@
     <failsafe-plugin.version>${surefire-plugin.version}</failsafe-plugin.version>
     <flatten-plugin.version>1.5.0</flatten-plugin.version>
     <jar-plugin.version>3.3.0</jar-plugin.version>
+    <jpackage-plugin.version>0.1.5</jpackage-plugin.version>
     <maven-scm-plugin.version>2.0.1</maven-scm-plugin.version>
     <maven-gpg-plugin.version>3.1.0</maven-gpg-plugin.version>
     <maven-javadoc-plugin.version>3.6.3</maven-javadoc-plugin.version>
     <maven-source-plugin.version>3.3.0</maven-source-plugin.version>
+    <native-plugin.version>0.9.23</native-plugin.version>
     <nexus-staging-plugin.version>1.6.13</nexus-staging-plugin.version>
     <spotless-plugin.version>2.41.1</spotless-plugin.version>
     <surefire-plugin.version>3.2.2</surefire-plugin.version>
@@ -71,10 +73,13 @@
     <apiguardian.version>1.1.2</apiguardian.version>
     <errorprone.version>2.23.0</errorprone.version>
     <google-java-format.version>1.15.0</google-java-format.version>
+    <guava.version>32.1.3-jre</guava.version>
     <inject-resources.version>0.3.3</inject-resources.version>
     <jetbrains-annotations.version>24.1.0</jetbrains-annotations.version>
     <junit-jupiter.version>5.10.1</junit-jupiter.version>
     <nullaway.version>0.10.18</nullaway.version>
+    <picocli.version>4.7.4</picocli.version>
+    <yasson.version>3.0.2</yasson.version>
     <xz.version>1.9</xz.version>
   </properties>
 
@@ -111,6 +116,27 @@
         <version>${xz.version}</version>
       </dependency>
 
+      <!-- Command line tooling -->
+      <dependency>
+        <groupId>info.picocli</groupId>
+        <artifactId>picocli</artifactId>
+        <version>${picocli.version}</version>
+      </dependency>
+
+      <!-- JSON -->
+      <dependency>
+        <groupId>org.eclipse</groupId>
+        <artifactId>yasson</artifactId>
+        <version>${yasson.version}</version>
+      </dependency>
+
+      <!-- Guava -->
+      <dependency>
+        <groupId>com.google.guava</groupId>
+        <artifactId>guava</artifactId>
+        <version>${guava.version}</version>
+      </dependency>
+
       <!-- Testing -->
       <dependency>
         <groupId>org.junit.jupiter</groupId>
@@ -240,6 +266,18 @@
           </executions>
         </plugin>
 
+        <plugin>
+          <groupId>org.graalvm.buildtools</groupId>
+          <artifactId>native-maven-plugin</artifactId>
+          <version>${native-plugin.version}</version>
+        </plugin>
+
+        <plugin>
+          <groupId>com.github.akman</groupId>
+          <artifactId>jpackage-maven-plugin</artifactId>
+          <version>${jpackage-plugin.version}</version>
+        </plugin>
+
       </plugins>
 
     </pluginManagement>
diff --git a/jgvariant-tool/pom.xml b/jgvariant-tool/pom.xml
new file mode 100644
index 0000000..ee2b8a8
--- /dev/null
+++ b/jgvariant-tool/pom.xml
@@ -0,0 +1,239 @@
+<?xml version="1.0" encoding="UTF-8"?>
+
+<!--
+SPDX-FileCopyrightText: © 2023 Matthias Andreas Benkard <code@mail.matthias.benkard.de>
+
+SPDX-License-Identifier: GPL-3.0-or-later
+-->
+
+<project
+  xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd"
+  xmlns="http://maven.apache.org/POM/4.0.0"
+  xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
+
+  <modelVersion>4.0.0</modelVersion>
+
+  <version>0.1.8-SNAPSHOT</version>
+
+  <artifactId>jgvariant-tool</artifactId>
+  <packaging>jar</packaging>
+
+  <name>JGVariant Command Line Tool</name>
+  <url>https://gerrit.benkard.de/plugins/gitiles/jgvariant</url>
+
+  <description>
+    GVariant command line tool.
+  </description>
+
+  <parent>
+    <groupId>eu.mulk.jgvariant</groupId>
+    <artifactId>jgvariant-parent</artifactId>
+    <version>0.1.8-SNAPSHOT</version>
+    <relativePath>../jgvariant-parent/pom.xml</relativePath>
+  </parent>
+
+  <dependencies>
+    <!-- JGVariant -->
+    <dependency>
+      <groupId>eu.mulk.jgvariant</groupId>
+      <artifactId>jgvariant-core</artifactId>
+      <version>0.1.8-SNAPSHOT</version>
+    </dependency>
+    <dependency>
+      <groupId>eu.mulk.jgvariant</groupId>
+      <artifactId>jgvariant-ostree</artifactId>
+      <version>0.1.8-SNAPSHOT</version>
+    </dependency>
+
+    <!-- Annotations -->
+    <dependency>
+      <groupId>com.google.errorprone</groupId>
+      <artifactId>error_prone_annotations</artifactId>
+      <scope>provided</scope>
+    </dependency>
+    <dependency>
+      <groupId>org.jetbrains</groupId>
+      <artifactId>annotations</artifactId>
+      <scope>provided</scope>
+    </dependency>
+    <dependency>
+      <groupId>org.apiguardian</groupId>
+      <artifactId>apiguardian-api</artifactId>
+      <scope>provided</scope>
+    </dependency>
+
+    <!-- Command line tooling -->
+    <dependency>
+      <groupId>info.picocli</groupId>
+      <artifactId>picocli</artifactId>
+    </dependency>
+
+    <!-- JSON -->
+    <dependency>
+      <groupId>org.eclipse</groupId>
+      <artifactId>yasson</artifactId>
+    </dependency>
+
+    <!-- Guava -->
+    <dependency>
+      <groupId>com.google.guava</groupId>
+      <artifactId>guava</artifactId>
+    </dependency>
+  </dependencies>
+
+  <build>
+    <plugins>
+      <plugin>
+        <groupId>org.apache.maven.plugins</groupId>
+        <artifactId>maven-compiler-plugin</artifactId>
+        <configuration>
+          <annotationProcessorPaths>
+            <path>
+              <groupId>info.picocli</groupId>
+              <artifactId>picocli-codegen</artifactId>
+              <version>${picocli.version}</version>
+            </path>
+          </annotationProcessorPaths>
+          <compilerArgs>
+            <arg>-Aproject=${project.groupId}/${project.artifactId}</arg>
+          </compilerArgs>
+        </configuration>
+      </plugin>
+
+      <plugin>
+        <groupId>org.apache.maven.plugins</groupId>
+        <artifactId>maven-jar-plugin</artifactId>
+        <configuration>
+          <archive>
+            <manifest>
+              <mainClass>eu.mulk.jgvariant.tool.Main</mainClass>
+            </manifest>
+          </archive>
+        </configuration>
+      </plugin>
+    </plugins>
+  </build>
+
+  <profiles>
+    <profile>
+      <id>shade</id>
+      <build>
+        <plugins>
+          <plugin>
+            <groupId>org.apache.maven.plugins</groupId>
+            <artifactId>maven-shade-plugin</artifactId>
+            <executions>
+              <execution>
+                <phase>package</phase>
+                <goals>
+                  <goal>shade</goal>
+                </goals>
+              </execution>
+            </executions>
+          </plugin>
+        </plugins>
+      </build>
+    </profile>
+
+    <profile>
+      <id>uberjar</id>
+      <build>
+        <plugins>
+          <plugin>
+            <groupId>org.apache.maven.plugins</groupId>
+            <artifactId>maven-assembly-plugin</artifactId>
+            <executions>
+              <execution>
+                <phase>package</phase>
+                <goals>
+                  <goal>single</goal>
+                </goals>
+                <configuration>
+                  <archive>
+                    <manifest>
+                      <mainClass>eu.mulk.jgvariant.tool.Main</mainClass>
+                    </manifest>
+                  </archive>
+                  <descriptorRefs>
+                    <descriptorRef>jar-with-dependencies</descriptorRef>
+                  </descriptorRefs>
+                </configuration>
+              </execution>
+            </executions>
+          </plugin>
+        </plugins>
+      </build>
+    </profile>
+
+    <profile>
+      <id>native</id>
+      <build>
+        <plugins>
+          <plugin>
+            <groupId>org.graalvm.buildtools</groupId>
+            <artifactId>native-maven-plugin</artifactId>
+            <extensions>true</extensions>
+            <executions>
+              <execution>
+                <id>build-native</id>
+                <goals>
+                  <goal>compile-no-fork</goal>
+                </goals>
+                <phase>package</phase>
+              </execution>
+              <execution>
+                <id>test-native</id>
+                <goals>
+                  <goal>test</goal>
+                </goals>
+                <phase>test</phase>
+              </execution>
+            </executions>
+            <configuration>
+              <debug>false</debug>
+              <fallback>false</fallback>
+              <buildArgs>
+                <arg>-O3</arg>
+                <arg>--strict-image-heap</arg>
+              </buildArgs>
+              <imageName>jgvariant</imageName>
+            </configuration>
+          </plugin>
+        </plugins>
+      </build>
+    </profile>
+
+    <profile>
+      <id>jpackage</id>
+      <build>
+        <plugins>
+          <plugin>
+            <groupId>com.github.akman</groupId>
+            <artifactId>jpackage-maven-plugin</artifactId>
+            <executions>
+              <execution>
+                <phase>package</phase>
+                <goals>
+                  <goal>jpackage</goal>
+                </goals>
+                <configuration>
+                  <type>IMAGE</type>
+                  <module>eu.mulk.jgvariant.tool/eu.mulk.jgvariant.tool.Main</module>
+                  <modulepath>
+                    <dependencysets>
+                      <dependencyset>
+                        <includeoutput>true</includeoutput>
+                        <excludeautomatic>true</excludeautomatic>
+                      </dependencyset>
+                    </dependencysets>
+                  </modulepath>
+                </configuration>
+              </execution>
+            </executions>
+          </plugin>
+        </plugins>
+      </build>
+    </profile>
+  </profiles>
+
+</project>
diff --git a/jgvariant-tool/src/main/java/eu/mulk/jgvariant/tool/Main.java b/jgvariant-tool/src/main/java/eu/mulk/jgvariant/tool/Main.java
new file mode 100644
index 0000000..64e375a
--- /dev/null
+++ b/jgvariant-tool/src/main/java/eu/mulk/jgvariant/tool/Main.java
@@ -0,0 +1,28 @@
+// SPDX-FileCopyrightText: © 2023 Matthias Andreas Benkard <code@mail.matthias.benkard.de>
+//
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package eu.mulk.jgvariant.tool;
+
+import static java.util.logging.Level.WARNING;
+
+import java.util.logging.Logger;
+import picocli.CommandLine;
+
+/**
+ * A command line tool to read and manipulate GVariant-formatted files.
+ *
+ * <p>Also provides ways to manipulate OSTree repositories.
+ */
+public final class Main {
+  static {
+    Logger.getGlobal().setLevel(WARNING);
+  }
+
+  public static void main(String[] args) {
+    int exitCode = new CommandLine(new MainCommand()).execute(args);
+    System.exit(exitCode);
+  }
+
+  private Main() {}
+}
diff --git a/jgvariant-tool/src/main/java/eu/mulk/jgvariant/tool/MainCommand.java b/jgvariant-tool/src/main/java/eu/mulk/jgvariant/tool/MainCommand.java
new file mode 100644
index 0000000..7b25dfc
--- /dev/null
+++ b/jgvariant-tool/src/main/java/eu/mulk/jgvariant/tool/MainCommand.java
@@ -0,0 +1,115 @@
+// SPDX-FileCopyrightText: © 2023 Matthias Andreas Benkard <code@mail.matthias.benkard.de>
+//
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package eu.mulk.jgvariant.tool;
+
+import static java.util.logging.Level.*;
+
+import eu.mulk.jgvariant.ostree.Summary;
+import eu.mulk.jgvariant.tool.jsonb.*;
+import jakarta.json.bind.Jsonb;
+import jakarta.json.bind.JsonbBuilder;
+import jakarta.json.bind.JsonbConfig;
+import java.io.File;
+import java.io.IOException;
+import java.io.PrintWriter;
+import java.nio.ByteBuffer;
+import java.nio.file.FileSystem;
+import java.nio.file.FileSystems;
+import java.nio.file.Files;
+import java.util.logging.Logger;
+import org.jetbrains.annotations.VisibleForTesting;
+import picocli.AutoComplete;
+import picocli.CommandLine;
+import picocli.CommandLine.*;
+
+@Command(
+    name = "jgvariant",
+    mixinStandardHelpOptions = true,
+    description = "Manipulate files in GVariant format.",
+    subcommands = {MainCommand.OstreeCommand.class, AutoComplete.GenerateCompletion.class})
+final class MainCommand {
+
+  private static final Logger LOG = Logger.getLogger("eu.mulk.jgvariant.tool");
+
+  private static final Jsonb jsonb =
+      JsonbBuilder.newBuilder()
+          .withConfig(
+              new JsonbConfig()
+                  .withFormatting(true)
+                  .withAdapters(ChecksumAdapter.INSTANCE)
+                  .withSerializers(
+                      ByteStringSerializer.INSTANCE,
+                      ByteArraySerializer.INSTANCE,
+                      SignatureSerializer.INSTANCE,
+                      VariantSerializer.INSTANCE))
+          .build();
+
+  @Option(
+      names = {"-v", "--verbose"},
+      description = "Enable verbose logging.",
+      scope = CommandLine.ScopeType.INHERIT)
+  void setVerbose(boolean[] verbose) {
+    Logger.getGlobal()
+        .setLevel(
+            switch (verbose.length) {
+              case 0 -> WARNING;
+              case 1 -> INFO;
+              case 2 -> FINE;
+              default -> ALL;
+            });
+  }
+
+  @Command(
+      name = "ostree",
+      mixinStandardHelpOptions = true,
+      description = "Manipulate OSTree files.",
+      subcommands = {OstreeCommand.SummaryCommand.class})
+  static final class OstreeCommand {
+
+    @Command(
+        name = "summary",
+        mixinStandardHelpOptions = true,
+        description = "Manipulate OSTree summary files.")
+    static final class SummaryCommand extends BaseCommand {
+
+      @Command(mixinStandardHelpOptions = true)
+      void read(@Parameters(paramLabel = "<file>") File file) throws IOException {
+        LOG.fine(() -> "Reading file %s".formatted(file));
+        var fileBytes = ByteBuffer.wrap(Files.readAllBytes(fs().getPath(file.getPath())));
+        var decoder = Summary.decoder();
+        var thing = decoder.decode(fileBytes);
+        out().println(jsonb.toJson(thing));
+      }
+
+      SummaryCommand() {}
+    }
+
+    OstreeCommand() {}
+  }
+
+  @Command
+  abstract static class BaseCommand {
+
+    @Spec CommandLine.Model.CommandSpec spec;
+
+    @VisibleForTesting FileSystem fs = FileSystems.getDefault();
+
+    protected BaseCommand() {}
+
+    protected PrintWriter out() {
+      return spec.commandLine().getOut();
+    }
+
+    protected PrintWriter err() {
+      return spec.commandLine().getErr();
+    }
+
+    protected FileSystem fs() {
+      return fs;
+    }
+  }
+
+  MainCommand() {}
+}
diff --git a/jgvariant-tool/src/main/java/eu/mulk/jgvariant/tool/jsonb/ByteArraySerializer.java b/jgvariant-tool/src/main/java/eu/mulk/jgvariant/tool/jsonb/ByteArraySerializer.java
new file mode 100644
index 0000000..7f80440
--- /dev/null
+++ b/jgvariant-tool/src/main/java/eu/mulk/jgvariant/tool/jsonb/ByteArraySerializer.java
@@ -0,0 +1,24 @@
+// SPDX-FileCopyrightText: © 2023 Matthias Andreas Benkard <code@mail.matthias.benkard.de>
+//
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package eu.mulk.jgvariant.tool.jsonb;
+
+import jakarta.json.bind.serializer.JsonbSerializer;
+import jakarta.json.bind.serializer.SerializationContext;
+import jakarta.json.stream.JsonGenerator;
+import java.util.HexFormat;
+
+@SuppressWarnings("java:S6548")
+public final class ByteArraySerializer implements JsonbSerializer<byte[]> {
+
+  public static final ByteArraySerializer INSTANCE = new ByteArraySerializer();
+
+  private ByteArraySerializer() {}
+
+  @Override
+  public void serialize(
+      byte[] o, JsonGenerator jsonGenerator, SerializationContext serializationContext) {
+    jsonGenerator.write(HexFormat.of().formatHex(o));
+  }
+}
diff --git a/jgvariant-tool/src/main/java/eu/mulk/jgvariant/tool/jsonb/ByteStringSerializer.java b/jgvariant-tool/src/main/java/eu/mulk/jgvariant/tool/jsonb/ByteStringSerializer.java
new file mode 100644
index 0000000..3537c3d
--- /dev/null
+++ b/jgvariant-tool/src/main/java/eu/mulk/jgvariant/tool/jsonb/ByteStringSerializer.java
@@ -0,0 +1,24 @@
+// SPDX-FileCopyrightText: © 2023 Matthias Andreas Benkard <code@mail.matthias.benkard.de>
+//
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package eu.mulk.jgvariant.tool.jsonb;
+
+import eu.mulk.jgvariant.ostree.ByteString;
+import jakarta.json.bind.serializer.JsonbSerializer;
+import jakarta.json.bind.serializer.SerializationContext;
+import jakarta.json.stream.JsonGenerator;
+
+@SuppressWarnings("java:S6548")
+public final class ByteStringSerializer implements JsonbSerializer<ByteString> {
+
+  public static final ByteStringSerializer INSTANCE = new ByteStringSerializer();
+
+  private ByteStringSerializer() {}
+
+  @Override
+  public void serialize(
+      ByteString o, JsonGenerator jsonGenerator, SerializationContext serializationContext) {
+    jsonGenerator.write(o.hex());
+  }
+}
diff --git a/jgvariant-tool/src/main/java/eu/mulk/jgvariant/tool/jsonb/ChecksumAdapter.java b/jgvariant-tool/src/main/java/eu/mulk/jgvariant/tool/jsonb/ChecksumAdapter.java
new file mode 100644
index 0000000..58a8951
--- /dev/null
+++ b/jgvariant-tool/src/main/java/eu/mulk/jgvariant/tool/jsonb/ChecksumAdapter.java
@@ -0,0 +1,27 @@
+// SPDX-FileCopyrightText: © 2023 Matthias Andreas Benkard <code@mail.matthias.benkard.de>
+//
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package eu.mulk.jgvariant.tool.jsonb;
+
+import eu.mulk.jgvariant.ostree.ByteString;
+import eu.mulk.jgvariant.ostree.Checksum;
+import jakarta.json.bind.adapter.JsonbAdapter;
+
+@SuppressWarnings("java:S6548")
+public final class ChecksumAdapter implements JsonbAdapter<Checksum, ByteString> {
+
+  public static final ChecksumAdapter INSTANCE = new ChecksumAdapter();
+
+  private ChecksumAdapter() {}
+
+  @Override
+  public ByteString adaptToJson(Checksum obj) throws Exception {
+    return obj.byteString();
+  }
+
+  @Override
+  public Checksum adaptFromJson(ByteString obj) throws Exception {
+    return new Checksum(obj);
+  }
+}
diff --git a/jgvariant-tool/src/main/java/eu/mulk/jgvariant/tool/jsonb/SignatureSerializer.java b/jgvariant-tool/src/main/java/eu/mulk/jgvariant/tool/jsonb/SignatureSerializer.java
new file mode 100644
index 0000000..dca1044
--- /dev/null
+++ b/jgvariant-tool/src/main/java/eu/mulk/jgvariant/tool/jsonb/SignatureSerializer.java
@@ -0,0 +1,24 @@
+// SPDX-FileCopyrightText: © 2023 Matthias Andreas Benkard <code@mail.matthias.benkard.de>
+//
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package eu.mulk.jgvariant.tool.jsonb;
+
+import eu.mulk.jgvariant.core.Signature;
+import jakarta.json.bind.serializer.JsonbSerializer;
+import jakarta.json.bind.serializer.SerializationContext;
+import jakarta.json.stream.JsonGenerator;
+
+@SuppressWarnings("java:S6548")
+public final class SignatureSerializer implements JsonbSerializer<Signature> {
+
+  public static final SignatureSerializer INSTANCE = new SignatureSerializer();
+
+  private SignatureSerializer() {}
+
+  @Override
+  public void serialize(
+      Signature o, JsonGenerator jsonGenerator, SerializationContext serializationContext) {
+    jsonGenerator.write(o.toString());
+  }
+}
diff --git a/jgvariant-tool/src/main/java/eu/mulk/jgvariant/tool/jsonb/VariantSerializer.java b/jgvariant-tool/src/main/java/eu/mulk/jgvariant/tool/jsonb/VariantSerializer.java
new file mode 100644
index 0000000..99ff553
--- /dev/null
+++ b/jgvariant-tool/src/main/java/eu/mulk/jgvariant/tool/jsonb/VariantSerializer.java
@@ -0,0 +1,43 @@
+// SPDX-FileCopyrightText: © 2023 Matthias Andreas Benkard <code@mail.matthias.benkard.de>
+//
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package eu.mulk.jgvariant.tool.jsonb;
+
+import com.google.common.primitives.Bytes;
+import eu.mulk.jgvariant.core.Signature;
+import eu.mulk.jgvariant.core.Variant;
+import jakarta.json.bind.serializer.JsonbSerializer;
+import jakarta.json.bind.serializer.SerializationContext;
+import jakarta.json.stream.JsonGenerator;
+import java.text.ParseException;
+import java.util.List;
+
+@SuppressWarnings("java:S6548")
+public final class VariantSerializer implements JsonbSerializer<Variant> {
+
+  public static final VariantSerializer INSTANCE = new VariantSerializer();
+
+  private final ByteArraySerializer byteArraySerializer = ByteArraySerializer.INSTANCE;
+
+  private final Signature byteArraySignature;
+
+  private VariantSerializer() {
+    try {
+      byteArraySignature = Signature.parse("ay");
+    } catch (ParseException e) {
+      // impossible
+      throw new IllegalArgumentException(e);
+    }
+  }
+
+  @Override
+  @SuppressWarnings("unchecked")
+  public void serialize(Variant obj, JsonGenerator generator, SerializationContext ctx) {
+    if (obj.signature().equals(byteArraySignature)) {
+      byteArraySerializer.serialize(Bytes.toArray((List<Byte>) obj.value()), generator, ctx);
+    } else {
+      ctx.serialize(obj.value(), generator);
+    }
+  }
+}
diff --git a/jgvariant-tool/src/main/java/eu/mulk/jgvariant/tool/package-info.java b/jgvariant-tool/src/main/java/eu/mulk/jgvariant/tool/package-info.java
new file mode 100644
index 0000000..da5c3ba
--- /dev/null
+++ b/jgvariant-tool/src/main/java/eu/mulk/jgvariant/tool/package-info.java
@@ -0,0 +1,8 @@
+// SPDX-FileCopyrightText: © 2023 Matthias Andreas Benkard <code@mail.matthias.benkard.de>
+//
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+@API(status = API.Status.INTERNAL)
+package eu.mulk.jgvariant.tool;
+
+import org.apiguardian.api.API;
diff --git a/jgvariant-tool/src/main/java/module-info.java b/jgvariant-tool/src/main/java/module-info.java
new file mode 100644
index 0000000..e66981c
--- /dev/null
+++ b/jgvariant-tool/src/main/java/module-info.java
@@ -0,0 +1,20 @@
+// SPDX-FileCopyrightText: © 2023 Matthias Andreas Benkard <code@mail.matthias.benkard.de>
+//
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+module eu.mulk.jgvariant.tool {
+  requires transitive eu.mulk.jgvariant.ostree;
+  requires com.google.common;
+  requires info.picocli;
+  requires jakarta.json;
+  requires jakarta.json.bind;
+  requires java.logging;
+  requires static com.google.errorprone.annotations;
+  requires static org.apiguardian.api;
+  requires static org.jetbrains.annotations;
+
+  opens eu.mulk.jgvariant.tool to
+      info.picocli;
+
+  exports eu.mulk.jgvariant.tool;
+}
diff --git a/pom.xml b/pom.xml
index be9fd26..1ab4b48 100644
--- a/pom.xml
+++ b/pom.xml
@@ -37,6 +37,8 @@
     <module>jgvariant-core</module>
     <module>jgvariant-ostree</module>
 
+    <module>jgvariant-tool</module>
+
     <module>jgvariant-bom</module>
   </modules>