From 86bf26f47136ad9525f7b9bade4dc5bf3767d90b Mon Sep 17 00:00:00 2001 From: Tako Schotanus Date: Tue, 31 Mar 2026 14:24:18 +0200 Subject: [PATCH 1/2] feat: implement user configuration file support (issue #107) Add support for user-level configuration files to set default values for common command-line options. Configuration is loaded from (in priority order): 1. --config CLI option 2. JPM_CONFIG environment variable 3. ~/.config/jpm/config.yml (XDG standard location) 4. ~/.jpmcfg.yml (fallback location) Features: - Configuration for cache, directory, no-links, and repositories options - Home directory expansion (~/ in paths) - Graceful degradation if config file cannot be read - CLI options override config file settings - Repository merging (config repos + CLI repos) - Explicit config path via --config or JPM_CONFIG Implementation: - Created UserConfig class for YAML config parsing - Added ConfigMixin for global --config option (follows VerboseMixin pattern) - Updated DepsMixin to load and use UserConfig - Added test isolation using JPM_CONFIG env var - Updated tests to reflect new usage line with --config option --- src/main/java/org/codejive/jpm/Main.java | 143 +++++++++-- .../org/codejive/jpm/config/UserConfig.java | 225 ++++++++++++++++++ .../codejive/jpm/MainCacheOptionsTest.java | 1 + .../jpm/MainRepositoryOptionsTest.java | 2 + src/test/java/org/codejive/jpm/MainTest.java | 3 +- 5 files changed, 347 insertions(+), 27 deletions(-) create mode 100644 src/main/java/org/codejive/jpm/config/UserConfig.java diff --git a/src/main/java/org/codejive/jpm/Main.java b/src/main/java/org/codejive/jpm/Main.java index efe7c5b..437fedd 100644 --- a/src/main/java/org/codejive/jpm/Main.java +++ b/src/main/java/org/codejive/jpm/Main.java @@ -5,7 +5,8 @@ //DEPS org.yaml:snakeyaml:2.5 //DEPS org.jline:jline-console-ui:3.30.6 org.jline:jline-terminal-jni:3.30.6 //DEPS org.slf4j:slf4j-api:2.0.17 org.slf4j:slf4j-simple:2.0.17 -//SOURCES Jpm.java config/AppInfo.java search/Search.java search/SearchSmoRestImpl.java search/SearchSmoApiImpl.java +//SOURCES Jpm.java config/AppInfo.java config/UserConfig.java +//SOURCES search/Search.java search/SearchSolrRestImpl.java search/SearchSmoApiImpl.java //SOURCES util/CommandsParser.java util/FileUtils.java util/Resolver.java util/ScriptUtils.java util/SyncResult.java //SOURCES util/Version.java // spotless:on @@ -20,6 +21,7 @@ import java.util.*; import java.util.concurrent.Callable; import java.util.stream.Collectors; +import org.codejive.jpm.config.UserConfig; import org.codejive.jpm.search.Search.Backends; import org.codejive.jpm.util.SyncResult; import org.codejive.jpm.util.Version; @@ -63,8 +65,10 @@ public class Main { static boolean verbose = false; + static Path configFile = null; @Mixin VerboseMixin verboseMixin; + @Mixin ConfigMixin configMixin; @Command( name = "copy", @@ -75,6 +79,7 @@ public class Main { + "Example:\n jpm copy org.apache.httpcomponents:httpclient:4.5.14\n") static class Copy implements Callable { @Mixin VerboseMixin verboseMixin; + @Mixin ConfigMixin configMixin; @Mixin QuietMixin quietMixin; @Mixin ArtifactsMixin artifactsMixin; @@ -89,8 +94,8 @@ static class Copy implements Callable { public Integer call() throws Exception { SyncResult stats = Jpm.builder() - .directory(artifactsMixin.directory) - .noLinks(artifactsMixin.noLinks) + .directory(artifactsMixin.getDirectory()) + .noLinks(artifactsMixin.getNoLinks()) .cacheDir(artifactsMixin.getCacheDir()) .build() .copy( @@ -115,6 +120,7 @@ public Integer call() throws Exception { + "Example:\n jpm search httpclient\n") static class Search implements Callable { @Mixin VerboseMixin verboseMixin; + @Mixin ConfigMixin configMixin; @Mixin QuietMixin quietMixin; @Mixin DepsMixin depsMixin; @Mixin AppInfoFileMixin appInfoFileMixin; @@ -162,8 +168,8 @@ public Integer call() throws Exception { if ("install".equals(artifactAction)) { SyncResult stats = Jpm.builder() - .directory(depsMixin.directory) - .noLinks(depsMixin.noLinks) + .directory(depsMixin.getDirectory()) + .noLinks(depsMixin.getNoLinks()) .cacheDir(depsMixin.getCacheDir()) .appFile(appInfoFileMixin.appInfoFile) .build() @@ -176,8 +182,8 @@ public Integer call() throws Exception { } else if ("copy".equals(artifactAction)) { SyncResult stats = Jpm.builder() - .directory(depsMixin.directory) - .noLinks(depsMixin.noLinks) + .directory(depsMixin.getDirectory()) + .noLinks(depsMixin.getNoLinks()) .cacheDir(depsMixin.getCacheDir()) .appFile(appInfoFileMixin.appInfoFile) .build() @@ -218,8 +224,8 @@ public Integer call() throws Exception { String[] search(String artifactPattern) { try { return Jpm.builder() - .directory(depsMixin.directory) - .noLinks(depsMixin.noLinks) + .directory(depsMixin.getDirectory()) + .noLinks(depsMixin.getNoLinks()) .cacheDir(depsMixin.getCacheDir()) .appFile(appInfoFileMixin.appInfoFile) .build() @@ -311,6 +317,7 @@ private static String getSelectedId( + "Example:\n jpm install org.apache.httpcomponents:httpclient:4.5.14\n") static class Install implements Callable { @Mixin VerboseMixin verboseMixin; + @Mixin ConfigMixin configMixin; @Mixin QuietMixin quietMixin; @Mixin OptionalArtifactsMixin optionalArtifactsMixin; @Mixin AppInfoFileMixin appInfoFileMixin; @@ -319,8 +326,8 @@ static class Install implements Callable { public Integer call() throws Exception { SyncResult stats = Jpm.builder() - .directory(optionalArtifactsMixin.directory) - .noLinks(optionalArtifactsMixin.noLinks) + .directory(optionalArtifactsMixin.getDirectory()) + .noLinks(optionalArtifactsMixin.getNoLinks()) .cacheDir(optionalArtifactsMixin.getCacheDir()) .appFile(appInfoFileMixin.appInfoFile) .build() @@ -343,6 +350,7 @@ public Integer call() throws Exception { + "Example:\n jpm path org.apache.httpcomponents:httpclient:4.5.14\n") static class PrintPath implements Callable { @Mixin VerboseMixin verboseMixin; + @Mixin ConfigMixin configMixin; @Mixin OptionalArtifactsMixin optionalArtifactsMixin; @Mixin AppInfoFileMixin appInfoFileMixin; @@ -350,8 +358,8 @@ static class PrintPath implements Callable { public Integer call() throws Exception { List files = Jpm.builder() - .directory(optionalArtifactsMixin.directory) - .noLinks(optionalArtifactsMixin.noLinks) + .directory(optionalArtifactsMixin.getDirectory()) + .noLinks(optionalArtifactsMixin.getNoLinks()) .cacheDir(optionalArtifactsMixin.getCacheDir()) .appFile(appInfoFileMixin.appInfoFile) .build() @@ -396,6 +404,7 @@ public Integer call() throws Exception { + " jpm exec @kotlinc -cp {{deps}} -d out/classes src/main/kotlin/App.kt\n") static class Exec implements Callable { @Mixin VerboseMixin verboseMixin; + @Mixin ConfigMixin configMixin; @Mixin DepsMixin depsMixin; @Mixin QuietMixin quietMixin; @Mixin AppInfoFileMixin appInfoFileMixin; @@ -408,8 +417,8 @@ public Integer call() throws Exception { String cmd = String.join(" ", command); try { return Jpm.builder() - .directory(depsMixin.directory) - .noLinks(depsMixin.noLinks) + .directory(depsMixin.getDirectory()) + .noLinks(depsMixin.getNoLinks()) .cacheDir(depsMixin.getCacheDir()) .appFile(appInfoFileMixin.appInfoFile) .verbose(!quietMixin.quiet) @@ -436,6 +445,7 @@ public Integer call() throws Exception { + " jpm do build -a --fresh test -a verbose\n") static class Do implements Callable { @Mixin VerboseMixin verboseMixin; + @Mixin ConfigMixin configMixin; @Mixin DepsMixin depsMixin; @Mixin QuietMixin quietMixin; @Mixin AppInfoFileMixin appInfoFileMixin; @@ -467,8 +477,8 @@ public Integer call() throws Exception { if (list) { List actionNames = Jpm.builder() - .directory(depsMixin.directory) - .noLinks(depsMixin.noLinks) + .directory(depsMixin.getDirectory()) + .noLinks(depsMixin.getNoLinks()) .cacheDir(depsMixin.getCacheDir()) .appFile(appInfoFileMixin.appInfoFile) .build() @@ -513,8 +523,8 @@ public Integer call() throws Exception { } int exitCode = Jpm.builder() - .directory(depsMixin.directory) - .noLinks(depsMixin.noLinks) + .directory(depsMixin.getDirectory()) + .noLinks(depsMixin.getNoLinks()) .cacheDir(depsMixin.getCacheDir()) .appFile(appInfoFileMixin.appInfoFile) .verbose(!quietMixin.quiet) @@ -535,6 +545,7 @@ public Integer call() throws Exception { abstract static class DoAlias implements Callable { @Mixin VerboseMixin verboseMixin; + @Mixin ConfigMixin configMixin; @Mixin DepsMixin depsMixin; @Mixin AppInfoFileMixin appInfoFileMixin; @@ -547,8 +558,8 @@ public Integer call() throws Exception { try { // Use only unmatched args for pass-through to preserve ordering return Jpm.builder() - .directory(depsMixin.directory) - .noLinks(depsMixin.noLinks) + .directory(depsMixin.getDirectory()) + .noLinks(depsMixin.getNoLinks()) .cacheDir(depsMixin.getCacheDir()) .appFile(appInfoFileMixin.appInfoFile) .build() @@ -601,17 +612,19 @@ String actionName() { } static class DepsMixin { + // Cached user configuration loaded from ~/.config/jpm/config.yml or ~/.jpmcfg.yml + private transient UserConfig userConfig; + @Option( names = {"-d", "--directory"}, - description = "Directory to copy artifacts to", - defaultValue = "deps") + description = "Directory to copy artifacts to (default: 'deps')") Path directory; @Option( names = {"-L", "--no-links"}, - description = "Always copy artifacts, don't try to create symlinks", - defaultValue = "false") - boolean noLinks; + description = + "Always copy artifacts, don't try to create symlinks (default: false)") + Boolean noLinks; @Option( names = {"-r", "--repo"}, @@ -625,10 +638,33 @@ static class DepsMixin { "Directory where downloaded artifacts will be cached (default: value of JPM_CACHE environment variable; whatever is set in Maven's settings.xml or $HOME/.m2/repository") Path cacheDir; + /** + * Loads and caches the user configuration. Priority: --config option > JPM_CONFIG env var > + * ~/.config/jpm/config.yml > ~/.jpmcfg.yml. + * + * @return The user configuration (never null) + */ + UserConfig getUserConfig() { + if (userConfig == null) { + userConfig = UserConfig.read(Main.configFile); + } + return userConfig; + } + + /** + * Returns the cache directory to use. Priority: CLI option > UserConfig > JPM_CACHE env var + * > Maven default. + * + * @return The cache directory path or null to use Maven default + */ Path getCacheDir() { if (cacheDir != null) { return cacheDir; } + Path userConfigCache = getUserConfig().cache(); + if (userConfigCache != null) { + return userConfigCache; + } String envCache = System.getenv("JPM_CACHE"); if (envCache != null && !envCache.isEmpty()) { try { @@ -642,8 +678,53 @@ Path getCacheDir() { return null; } + /** + * Returns the directory to use for artifacts. Priority: CLI option > UserConfig > hardcoded + * default ('deps'). + * + * @return The directory path + */ + Path getDirectory() { + if (directory != null) { + return directory; // User explicitly set via CLI + } + Path userConfigDir = getUserConfig().directory(); + if (userConfigDir != null) { + return userConfigDir; // From user config + } + return Path.of("deps"); // Hardcoded default + } + + /** + * Returns whether to disable symlinks. Priority: CLI option > UserConfig > hardcoded + * default (false). + * + * @return true to disable symlinks, false otherwise + */ + boolean getNoLinks() { + if (noLinks != null) { + return noLinks; // User explicitly set via CLI + } + Boolean userConfigNoLinks = getUserConfig().noLinks(); + if (userConfigNoLinks != null) { + return userConfigNoLinks; // From user config + } + return false; // Hardcoded default + } + + /** + * Returns the repository map. Priority: UserConfig repositories (base) + CLI repositories + * (override). + * + * @return Map of repository name to URL + */ Map getRepositoryMap() { Map repoMap = new HashMap<>(); + + // Start with UserConfig repositories (lowest priority) + repoMap.putAll(getUserConfig().repositories()); + + // Add/override with CLI repositories for (String repo : repositories) { String name; String url; @@ -705,6 +786,16 @@ public void setVerbose(boolean verbose) { } } + static class ConfigMixin { + @Option( + names = {"--config"}, + description = + "Path to user configuration file (default: JPM_CONFIG environment variable, ~/.config/jpm/config.yml, or ~/.jpmcfg.yml)") + public void setConfigFile(Path config) { + Main.configFile = config; + } + } + static class QuietMixin { @Option( names = {"-q", "--quiet"}, diff --git a/src/main/java/org/codejive/jpm/config/UserConfig.java b/src/main/java/org/codejive/jpm/config/UserConfig.java new file mode 100644 index 0000000..e2aa8d7 --- /dev/null +++ b/src/main/java/org/codejive/jpm/config/UserConfig.java @@ -0,0 +1,225 @@ +package org.codejive.jpm.config; + +import java.io.IOException; +import java.io.Reader; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.LinkedHashMap; +import java.util.Map; +import org.yaml.snakeyaml.Yaml; + +/** + * Represents the contents of a user configuration file. User configuration files can be located at + * ~/.config/jpm/config.yml or ~/.jpmcfg.yml and provide default values for common command-line + * options. + */ +public class UserConfig { + private Path cache; + private Path directory; + private Boolean noLinks; + private final Map repositories = new LinkedHashMap<>(); + + /** The primary user config file location (XDG standard). */ + public static final String USER_CONFIG_FILE = ".config/jpm/config.yml"; + + /** The fallback user config file location. */ + public static final String USER_CONFIG_FILE_FALLBACK = ".jpmcfg.yml"; + + public Path cache() { + return cache; + } + + public Path directory() { + return directory; + } + + public Boolean noLinks() { + return noLinks; + } + + public Map repositories() { + return repositories; + } + + /** + * Reads the user configuration file. Priority: explicit path parameter > JPM_CONFIG environment + * variable > ~/.config/jpm/config.yml > ~/.jpmcfg.yml. If no file exists or can be read, + * returns an empty UserConfig object. + * + * @param explicitConfig Optional explicit config file path from --config option (can be null) + * @return An instance of UserConfig (never null) + */ + public static UserConfig read(Path explicitConfig) { + try { + // 1. Check explicit --config option + if (explicitConfig != null) { + return readFromPath(explicitConfig); + } + + // 2. Check JPM_CONFIG environment variable + String envConfig = System.getenv("JPM_CONFIG"); + if (envConfig != null && !envConfig.isEmpty()) { + Path envConfigPath = Paths.get(envConfig); + return readFromPath(envConfigPath); + } + + // 3. Check default locations + String userHome = System.getProperty("user.home"); + Path primaryConfig = Paths.get(userHome, USER_CONFIG_FILE); + + if (Files.isRegularFile(primaryConfig)) { + return readFromPath(primaryConfig); + } + + Path fallbackConfig = Paths.get(userHome, USER_CONFIG_FILE_FALLBACK); + if (Files.isRegularFile(fallbackConfig)) { + return readFromPath(fallbackConfig); + } + } catch (Exception e) { + System.err.println("Warning: Error checking for user config file: " + e.getMessage()); + } + + return new UserConfig(); + } + + /** + * Reads the user configuration file from the default locations. Checks ~/.config/jpm/config.yml + * first, then ~/.jpmcfg.yml. If neither file exists, returns an empty UserConfig object. + * + *

This is a convenience method equivalent to calling {@link #read(Path)} with null. + * + * @return An instance of UserConfig (never null) + */ + public static UserConfig read() { + return read(null); + } + + /** + * Reads the user configuration file from the given path. If the file does not exist, returns an + * empty UserConfig object without warning. If the file exists but cannot be read, returns an + * empty UserConfig object with a warning. + * + * @param configFile The path to the configuration file + * @return An instance of UserConfig (never null) + */ + private static UserConfig readFromPath(Path configFile) { + if (!Files.exists(configFile)) { + return new UserConfig(); + } + if (!Files.isRegularFile(configFile)) { + System.err.println( + "Warning: Config path exists but is not a regular file, ignoring: " + + configFile); + return new UserConfig(); + } + try (Reader in = Files.newBufferedReader(configFile)) { + return readFromReader(in); + } catch (IOException e) { + System.err.println( + "Warning: Error reading user config file, ignoring: " + e.getMessage()); + } + return new UserConfig(); + } + + /** + * Reads the user configuration from the given Reader and returns its content as a UserConfig + * object. + * + * @param in The Reader to read the configuration content from + * @return An instance of UserConfig + */ + @SuppressWarnings("unchecked") + private static UserConfig readFromReader(Reader in) { + UserConfig userConfig = new UserConfig(); + try { + Yaml yaml = new Yaml(); + Map data = yaml.load(in); + + if (data == null || !data.containsKey("config")) { + return userConfig; + } + + Object configObj = data.get("config"); + if (!(configObj instanceof Map)) { + System.err.println("Warning: 'config' section must be a map, ignoring"); + return userConfig; + } + + Map config = (Map) configObj; + + // Parse cache + if (config.containsKey("cache")) { + Object cacheObj = config.get("cache"); + if (cacheObj instanceof String) { + String cachePath = (String) cacheObj; + userConfig.cache = expandHomePath(cachePath); + } else { + System.err.println( + "Warning: 'cache' must be a string path, ignoring: " + cacheObj); + } + } + + // Parse directory + if (config.containsKey("directory")) { + Object dirObj = config.get("directory"); + if (dirObj instanceof String) { + String dirPath = (String) dirObj; + userConfig.directory = expandHomePath(dirPath); + } else { + System.err.println( + "Warning: 'directory' must be a string path, ignoring: " + dirObj); + } + } + + // Parse no-links + if (config.containsKey("no-links")) { + Object noLinksObj = config.get("no-links"); + if (noLinksObj instanceof Boolean) { + userConfig.noLinks = (Boolean) noLinksObj; + } else { + System.err.println( + "Warning: 'no-links' must be a boolean, ignoring: " + noLinksObj); + } + } + + // Parse repositories + if (config.containsKey("repositories")) { + Object reposObj = config.get("repositories"); + if (reposObj instanceof Map) { + Map repos = (Map) reposObj; + for (Map.Entry entry : repos.entrySet()) { + if (entry.getValue() != null) { + userConfig.repositories.put( + entry.getKey(), entry.getValue().toString()); + } + } + } else { + System.err.println( + "Warning: 'repositories' must be a map, ignoring: " + reposObj); + } + } + + } catch (Exception e) { + System.err.println( + "Warning: Error parsing user config file, ignoring: " + e.getMessage()); + } + + return userConfig; + } + + /** + * Expands a path starting with "~/" to use the user's home directory. Handles cross-platform + * paths. + * + * @param pathStr The path string to expand + * @return A Path with "~/" expanded to the user's home directory + */ + private static Path expandHomePath(String pathStr) { + if (pathStr.startsWith("~/")) { + String userHome = System.getProperty("user.home"); + return Paths.get(userHome, pathStr.substring(2)); + } + return Paths.get(pathStr); + } +} diff --git a/src/test/java/org/codejive/jpm/MainCacheOptionsTest.java b/src/test/java/org/codejive/jpm/MainCacheOptionsTest.java index 33722a5..1e773b0 100644 --- a/src/test/java/org/codejive/jpm/MainCacheOptionsTest.java +++ b/src/test/java/org/codejive/jpm/MainCacheOptionsTest.java @@ -13,6 +13,7 @@ import org.junitpioneer.jupiter.SetEnvironmentVariable; /** Tests for Main class CLI cache option parsing. */ +@SetEnvironmentVariable(key = "JPM_CONFIG", value = "/nonexistent/config.yml") class MainCacheOptionsTest { @TempDir Path tempCacheDir1; diff --git a/src/test/java/org/codejive/jpm/MainRepositoryOptionsTest.java b/src/test/java/org/codejive/jpm/MainRepositoryOptionsTest.java index 7156a50..8ca4aa8 100644 --- a/src/test/java/org/codejive/jpm/MainRepositoryOptionsTest.java +++ b/src/test/java/org/codejive/jpm/MainRepositoryOptionsTest.java @@ -6,8 +6,10 @@ import java.util.List; import java.util.Map; import org.junit.jupiter.api.Test; +import org.junitpioneer.jupiter.SetEnvironmentVariable; /** Tests for Main class CLI repository option parsing. */ +@SetEnvironmentVariable(key = "JPM_CONFIG", value = "/nonexistent/config.yml") class MainRepositoryOptionsTest { @Test diff --git a/src/test/java/org/codejive/jpm/MainTest.java b/src/test/java/org/codejive/jpm/MainTest.java index c977eb9..5c7a321 100644 --- a/src/test/java/org/codejive/jpm/MainTest.java +++ b/src/test/java/org/codejive/jpm/MainTest.java @@ -242,7 +242,8 @@ void testMainWithNoArgs() { int exitCode = cmd.execute(); assertThat(exitCode >= 0).isTrue(); // Should not be negative (internal error) assertThat(capture.getErr()).contains("Missing required subcommand"); - assertThat(capture.getErr()).contains("Usage: jpm [-hvV] [COMMAND]"); + assertThat(capture.getErr()) + .contains("Usage: jpm [-hvV] [--config=] [COMMAND]"); assertThat(capture.getErr()) .contains("Simple command line tool for managing Maven artifacts"); } From d8bf635c4e02a5eb65a3948da3aaa971b5e8c9c8 Mon Sep 17 00:00:00 2001 From: Tako Schotanus Date: Tue, 31 Mar 2026 14:39:37 +0200 Subject: [PATCH 2/2] docs: document user configuration file feature Add documentation for: - --config CLI option in Common options section - Configuration File section with locations, format, and examples - Configuration precedence order - Path expansion for home directory (~/) - Examples showing various usage patterns --- README.md | 47 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/README.md b/README.md index 49b55fe..3e7693e 100644 --- a/README.md +++ b/README.md @@ -210,6 +210,10 @@ Commands: These options are supported by all commands: ``` + --config= + Path to user configuration file (default: JPM_CONFIG + environment variable, ~/.config/jpm/config.yml, or + ~/.jpmcfg.yml) -c, --cache= Directory where downloaded artifacts will be cached (default: value of JPM_CACHE environment variable; @@ -535,6 +539,49 @@ actions: clean: "rm -f *.class" ``` +## Configuration File + +`jpm` supports user-level configuration files to set default values for common command-line options. This allows you to configure preferences once instead of passing the same options repeatedly. + +### Configuration File Locations + +Configuration files are searched in the following order: + +1. **Explicit path** - Specified via `--config` option or `JPM_CONFIG` environment variable +2. **`~/.config/jpm/config.yml`** - XDG Base Directory standard location (primary) +3. **`~/.jpmcfg.yml`** - Fallback location (home directory) + +The first file found is used. If no configuration file exists, jpm uses built-in defaults. + +### Configuration File Format + +```yaml +config: + cache: ~/my-jpm-cache + directory: libs + no-links: false + repositories: + myrepo: https://my.repo.com/maven2 + private: https://private.repo.com/releases +``` + +### Configuration Options + +- **`cache`** - Directory for caching downloaded artifacts (equivalent to `--cache` option) +- **`directory`** - Default directory to copy artifacts to (equivalent to `--directory` option) +- **`no-links`** - Whether to copy files instead of creating symlinks (equivalent to `--no-links` option) +- **`repositories`** - Map of repository names to URLs (merged with `--repo` options) + +### Path Expansion + +Paths in the configuration file support home directory expansion: + +```yaml +config: + cache: ~/jpm-cache # Expands to /home/user/jpm-cache + directory: ~/.local/lib/jpm # Expands to /home/user/.local/lib/jpm +``` + ## Development To build the project simply run: