This is an automated email from the ASF dual-hosted git repository.

bamaer pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/hop.git


The following commit(s) were added to refs/heads/main by this push:
     new 088e9ee6eb Issue #2916 : Run a project without adding it to 
hop-config.json (#8265)
088e9ee6eb is described below

commit 088e9ee6eb9d76b8e8323aa4de1909bd0b69bcc5
Author: Matt Casters <[email protected]>
AuthorDate: Tue Sep 8 10:56:57 2026 +0200

    Issue #2916 : Run a project without adding it to hop-config.json (#8265)
    
    * Issue #2916 : Run a project without adding it to hop-config.json
    
    Register project folders or archives for a single hop process with
    --project-locations, keep hop-config.json unchanged, and point metadata
    at the project's metadata folder. hop gui now accepts -j/-e/-f so you
    can enable a project or environment and open a file from the command
    line.
    
    * Do not enable the default project on hop gui without -j/-e
    
    Passing the GUI command as a metadata holder made configure() enable
    the default project and write an open audit event before last-used
    restoration. Also restore the config serializer when leaving in-memory
    mode, and mark --project-locations folders read-only only when they are
    archives.
    
    * Harden in-memory project locations from the PR review
    
    - Only treat subfolders as projects when they actually have a project
      config file, so a bare folder is not registered as .git/metadata/datasets
    - Load metadata.json/variables.json only for zip or JSON-export layouts
    - Skip a second enable of the same project/environment
    - Set HOP_CONFIG_IN_MEMORY before HopEnvironment.init when -im/-pl is on
      the command line so the first run does not write hop-config.json
    - Restore a no-file serializer when leaving in-memory mode if no config
      file exists; strip quotes in location splitting
---
 core/src/main/java/org/apache/hop/core/Const.java  |   8 +
 .../java/org/apache/hop/core/config/HopConfig.java |  29 +
 .../apache/hop/core/config/plugin/ConfigFile.java  |  28 +-
 .../hop/core/config/plugin/ConfigPlugin.java       |   2 +
 .../hop/core/config/HopConfigInMemoryTest.java     |  92 +++
 .../modules/ROOT/pages/hop-run/index.adoc          |  25 +
 .../modules/ROOT/pages/hop-tools/hop-gui.adoc      |  73 ++-
 .../modules/ROOT/pages/hop-tools/hop.adoc          |  49 +-
 .../modules/ROOT/pages/projects/advanced.adoc      |  44 ++
 .../modules/ROOT/pages/projects/index.adoc         |   2 +
 .../modules/ROOT/pages/variables.adoc              |   1 +
 engine/src/main/java/org/apache/hop/hop/Hop.java   |  96 ++-
 .../main/java/org/apache/hop/run/HopRunBase.java   |  10 +
 .../projects/config/ProjectsGuiOptionPlugin.java   |  60 ++
 .../hop/projects/config/ProjectsOptionPlugin.java  | 105 ++-
 .../projects/config/ProjectsRootOptionPlugin.java  |  31 +
 .../apache/hop/projects/project/ProjectConfig.java |  27 +-
 .../hop/projects/util/ProjectsConfigHelper.java    | 710 +++++++++++++++++++++
 .../org/apache/hop/projects/util/ProjectsUtil.java |  32 +-
 .../hop/projects/xp/HopGuiStartProjectLoad.java    |  83 ++-
 .../config/ProjectsGuiOptionPluginTest.java        | 151 +++++
 .../projects/util/ProjectsConfigHelperTest.java    | 457 +++++++++++++
 .../org/apache/hop/ui/hopgui/HopCommandGui.java    |  67 +-
 .../main/java/org/apache/hop/ui/hopgui/HopGui.java |  34 +-
 .../apache/hop/ui/hopgui/HopGuiCommandLine.java    | 151 +++++
 .../apache/hop/ui/hopgui/HopCommandGuiTest.java    |  50 ++
 .../hop/ui/hopgui/HopGuiCommandLineTest.java       |  84 +++
 27 files changed, 2415 insertions(+), 86 deletions(-)

diff --git a/core/src/main/java/org/apache/hop/core/Const.java 
b/core/src/main/java/org/apache/hop/core/Const.java
index 497ee0a1bf..8a9af783a6 100644
--- a/core/src/main/java/org/apache/hop/core/Const.java
+++ b/core/src/main/java/org/apache/hop/core/Const.java
@@ -161,6 +161,14 @@ public class Const {
           "Set this variable to 'Y' to automatically create config file when 
it's missing.")
   public static final String HOP_AUTO_CREATE_CONFIG = "HOP_AUTO_CREATE_CONFIG";
 
+  /** The system variable to keep the Hop configuration in memory without 
persisting it to disk */
+  @Variable(
+      scope = VariableScope.SYSTEM,
+      value = "N",
+      description =
+          "Set this variable to 'Y' to keep the Hop configuration in memory 
without persisting to disk.")
+  public static final String HOP_CONFIG_IN_MEMORY = "HOP_CONFIG_IN_MEMORY";
+
   /**
    * The system environment variable pointing to the alternative location for 
the Hop metadata
    * folder
diff --git a/core/src/main/java/org/apache/hop/core/config/HopConfig.java 
b/core/src/main/java/org/apache/hop/core/config/HopConfig.java
index 2c1d1146cf..b5f9732ed8 100644
--- a/core/src/main/java/org/apache/hop/core/config/HopConfig.java
+++ b/core/src/main/java/org/apache/hop/core/config/HopConfig.java
@@ -25,11 +25,13 @@ import java.util.List;
 import java.util.Map;
 import lombok.Getter;
 import lombok.Setter;
+import org.apache.commons.vfs2.FileObject;
 import org.apache.hop.core.Const;
 import org.apache.hop.core.config.plugin.ConfigFile;
 import org.apache.hop.core.exception.HopRuntimeException;
 import org.apache.hop.core.util.Utils;
 import org.apache.hop.core.variables.DescribedVariable;
+import org.apache.hop.core.vfs.HopVfs;
 
 /**
  * This class keeps track of storing and retrieving all the configuration 
options in Hop. This
@@ -59,6 +61,33 @@ public class HopConfig extends ConfigFile {
     return instance;
   }
 
+  public static boolean isInMemoryMode() {
+    return getInstance().isInMemory();
+  }
+
+  public static void setInMemoryMode(boolean inMemory) {
+    HopConfig hopConfig = getInstance();
+    hopConfig.setInMemory(inMemory);
+  }
+
+  @Override
+  public void setInMemory(boolean inMemory) {
+    super.setInMemory(inMemory);
+    if (inMemory) {
+      setSerializer(new ConfigNoFileSerializer());
+      return;
+    }
+    try {
+      boolean exists;
+      try (FileObject configFile = HopVfs.getFileObject(getConfigFilename())) {
+        exists = configFile.exists();
+      }
+      setSerializer(exists ? new ConfigFileSerializer() : new 
ConfigNoFileSerializer());
+    } catch (Exception e) {
+      setSerializer(new ConfigNoFileSerializer());
+    }
+  }
+
   public void saveOption(String optionKey, Object optionValue) {
     synchronized (CONFIG_LOCK) {
       try {
diff --git 
a/core/src/main/java/org/apache/hop/core/config/plugin/ConfigFile.java 
b/core/src/main/java/org/apache/hop/core/config/plugin/ConfigFile.java
index 8562e1b32f..4b23073d8e 100644
--- a/core/src/main/java/org/apache/hop/core/config/plugin/ConfigFile.java
+++ b/core/src/main/java/org/apache/hop/core/config/plugin/ConfigFile.java
@@ -20,13 +20,13 @@ package org.apache.hop.core.config.plugin;
 import com.fasterxml.jackson.annotation.JsonIgnore;
 import com.fasterxml.jackson.annotation.JsonProperty;
 import com.google.gson.Gson;
-import java.io.File;
 import java.util.ArrayList;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
 import lombok.Getter;
 import lombok.Setter;
+import org.apache.commons.vfs2.FileObject;
 import org.apache.hop.core.Const;
 import org.apache.hop.core.config.ConfigFileSerializer;
 import org.apache.hop.core.config.ConfigNoFileSerializer;
@@ -36,6 +36,7 @@ import org.apache.hop.core.exception.HopException;
 import org.apache.hop.core.json.HopJson;
 import org.apache.hop.core.logging.LogChannel;
 import org.apache.hop.core.variables.DescribedVariable;
+import org.apache.hop.core.vfs.HopVfs;
 
 public abstract class ConfigFile implements IConfigFile {
 
@@ -63,6 +64,13 @@ public abstract class ConfigFile implements IConfigFile {
   protected Map<String, Object> configMap;
 
   @Getter @Setter @JsonIgnore protected IHopConfigSerializer serializer;
+  @Setter @JsonIgnore protected boolean inMemory;
+
+  public boolean isInMemory() {
+    return inMemory
+        || "Y".equalsIgnoreCase(System.getProperty(Const.HOP_CONFIG_IN_MEMORY, 
"N"))
+        || 
"true".equalsIgnoreCase(System.getProperty(Const.HOP_CONFIG_IN_MEMORY, 
"false"));
+  }
 
   public ConfigFile() {
     configMap = new HashMap<>();
@@ -71,7 +79,14 @@ public abstract class ConfigFile implements IConfigFile {
 
   public void readFromFile() throws HopException {
     try {
-      if (new File(getConfigFilename()).exists()) {
+      boolean inMemoryMode = isInMemory();
+      boolean exists;
+      try (FileObject configFile = HopVfs.getFileObject(getConfigFilename())) {
+        exists = configFile.exists();
+      }
+      if (inMemoryMode) {
+        this.serializer = new ConfigNoFileSerializer();
+      } else if (exists) {
         // Let's write to the file
         //
         this.serializer = new ConfigFileSerializer();
@@ -89,13 +104,20 @@ public abstract class ConfigFile implements IConfigFile {
           this.serializer = new ConfigNoFileSerializer();
         }
       }
-      configMap = serializer.readFromFile(getConfigFilename());
+      if (inMemoryMode && exists) {
+        configMap = new 
ConfigFileSerializer().readFromFile(getConfigFilename());
+      } else {
+        configMap = serializer.readFromFile(getConfigFilename());
+      }
     } catch (Exception e) {
       throw new HopException("Unable to read config file '" + 
getConfigFilename() + "'", e);
     }
   }
 
   public void saveToFile() throws HopException {
+    if (isInMemory()) {
+      return;
+    }
     synchronized (CONFIG_LOCK) {
       try {
         serializer.writeToFile(getConfigFilename(), configMap);
diff --git 
a/core/src/main/java/org/apache/hop/core/config/plugin/ConfigPlugin.java 
b/core/src/main/java/org/apache/hop/core/config/plugin/ConfigPlugin.java
index a20bd8d808..61bd4529a9 100644
--- a/core/src/main/java/org/apache/hop/core/config/plugin/ConfigPlugin.java
+++ b/core/src/main/java/org/apache/hop/core/config/plugin/ConfigPlugin.java
@@ -28,6 +28,7 @@ import java.lang.annotation.Target;
 @Retention(RetentionPolicy.RUNTIME)
 @Target(ElementType.TYPE)
 public @interface ConfigPlugin {
+  String CATEGORY_ROOT = "root";
   String CATEGORY_CONFIG = "config";
   String CATEGORY_RUN = "run";
   String CATEGORY_SEARCH = "search";
@@ -36,6 +37,7 @@ public @interface ConfigPlugin {
   String CATEGORY_DOC = "doc";
   String CATEGORY_PYTHON = "python";
   String CATEGORY_NAMING = "naming";
+  String CATEGORY_GUI = "gui";
 
   String id();
 
diff --git 
a/core/src/test/java/org/apache/hop/core/config/HopConfigInMemoryTest.java 
b/core/src/test/java/org/apache/hop/core/config/HopConfigInMemoryTest.java
new file mode 100644
index 0000000000..1c4ef7bc97
--- /dev/null
+++ b/core/src/test/java/org/apache/hop/core/config/HopConfigInMemoryTest.java
@@ -0,0 +1,92 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *       http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.core.config;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.HashMap;
+import org.apache.hop.core.Const;
+import org.apache.hop.core.config.plugin.ConfigFile;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+class HopConfigInMemoryTest {
+
+  @TempDir private Path folder;
+
+  @AfterEach
+  void tearDown() {
+    System.clearProperty(Const.HOP_CONFIG_IN_MEMORY);
+    HopConfig.setInMemoryMode(false);
+    HopConfig.getInstance().getConfigMap().remove("ephemeral_option");
+  }
+
+  @Test
+  void testSystemPropertyActivatesInMemoryMode() {
+    System.setProperty(Const.HOP_CONFIG_IN_MEMORY, "Y");
+    assertTrue(HopConfig.isInMemoryMode());
+
+    System.setProperty(Const.HOP_CONFIG_IN_MEMORY, "true");
+    assertTrue(HopConfig.isInMemoryMode());
+
+    System.setProperty(Const.HOP_CONFIG_IN_MEMORY, "N");
+    assertFalse(HopConfig.isInMemoryMode());
+  }
+
+  @Test
+  void testConfigFileInMemoryDoesNotWriteToFile() throws Exception {
+    Path targetFile = folder.resolve("in-memory-config.json");
+    assertFalse(Files.exists(targetFile));
+
+    ConfigFile configFile =
+        new ConfigFile() {
+          private String filename = targetFile.toString();
+
+          @Override
+          public String getConfigFilename() {
+            return filename;
+          }
+
+          @Override
+          public void setConfigFilename(String filename) {
+            this.filename = filename;
+          }
+        };
+    configFile.setInMemory(true);
+    configFile.setConfigMap(new HashMap<>());
+    configFile.getConfigMap().put("testKey", "testVal");
+
+    // Saving to file should be a no-op and not create the file on disk
+    configFile.saveToFile();
+    assertFalse(Files.exists(targetFile));
+  }
+
+  @Test
+  void testHopConfigInMemorySavesOptionOnlyInMemory() throws Exception {
+    HopConfig.setInMemoryMode(true);
+    assertTrue(HopConfig.isInMemoryMode());
+
+    HopConfig.getInstance().saveOption("ephemeral_option", "ephemeral_value");
+    assertEquals("ephemeral_value", HopConfig.readOption("ephemeral_option"));
+  }
+}
diff --git a/docs/hop-user-manual/modules/ROOT/pages/hop-run/index.adoc 
b/docs/hop-user-manual/modules/ROOT/pages/hop-run/index.adoc
index a901a9ae59..04f7428277 100644
--- a/docs/hop-user-manual/modules/ROOT/pages/hop-run/index.adoc
+++ b/docs/hop-user-manual/modules/ROOT/pages/hop-run/index.adoc
@@ -145,6 +145,22 @@ Check the 
xref:pipeline/pipeline-run-configurations/pipeline-run-configurations.
 |```-s```
 |```--system-properties```
 |A comma separated list of KEY=VALUE pairs
+
+|```-im```
+|```--in-memory```
+|Keep Hop configuration in memory. Changes are not written to 
`hop-config.json`. Implied by `--project-locations` and `--environments`.
+
+|```-pl```
+|```--project-locations```
+|Comma-separated project locations to register for this process only 
(`name=/path` or a zip/jar). See 
xref:projects/advanced.adoc#_use_a_project_without_hop_config_json[Use a 
project without hop-config.json].
+
+|
+|```--environments```
+|Comma-separated environment definitions (`name=[project:]file1;file2`) 
registered in memory for this process.
+
+|
+|```--environment-conf-files``` / ```--environment-config-files```
+|Extra environment configuration files to apply to the enabled project or 
environment.
 |===
 
 =====
@@ -243,6 +259,15 @@ Your output will be similar to what is shown below:
 --
 ====
 
+To run a project folder that is not registered in `hop-config.json`:
+
+[source,bash]
+----
+hop --project-locations my-project=/path/to/project run -f pipeline.hpl -r 
local
+----
+
+See xref:projects/advanced.adoc#_use_a_project_without_hop_config_json[Use a 
project without hop-config.json] for the location and environment formats.
+
 === Parameter Examples
 This is a list of examples on how the parameters on this command are parsed
 
diff --git a/docs/hop-user-manual/modules/ROOT/pages/hop-tools/hop-gui.adoc 
b/docs/hop-user-manual/modules/ROOT/pages/hop-tools/hop-gui.adoc
index ec62c09e0c..7fd82a2b67 100644
--- a/docs/hop-user-manual/modules/ROOT/pages/hop-tools/hop-gui.adoc
+++ b/docs/hop-user-manual/modules/ROOT/pages/hop-tools/hop-gui.adoc
@@ -14,7 +14,9 @@ KIND, either express or implied.  See the License for the
 specific language governing permissions and limitations
 under the License.
 ////
-:description: Hop Encrypt is a command line tool to encrypt (obfuscate) 
passwords for use in XML, password or Hop metadata files.Hop Gui is Hop’s 
visual development environment. You’ll spend a lot of time here in the various 
perspectives of Hop GUI.
+:description: Hop GUI is Hop's visual development environment. Start it with 
hop gui or hop-gui.sh / hop-gui.bat, optionally selecting a project, 
environment, and file.
+:openvar: ${
+:closevar: }
 
 = Hop GUI
 
@@ -22,4 +24,71 @@ Hop GUI is the visual integrated development environment 
(IDE) for Apache Hop.
 
 == Usage
 
-Hop GUI doesn't take any parameters or arguments. To start, just run 
`hop-gui.bat` on Window or `hop-gui.sh` on Linux and Mac OS.
+Start Hop GUI with `hop-gui.bat` on Windows or `hop-gui.sh` on Linux and 
macOS, or with the `hop gui` command.
+
+Without extra options Hop GUI opens the last used project.
+You can also select a project, a lifecycle environment, and a file to open:
+
+[source,bash]
+----
+hop gui -j samples -f 
'{openvar}PROJECT_HOME{closevar}/transforms/add-a-checksum.hpl'
+hop gui -e production -f long-running-test.hpl
+----
+
+Relative filenames are resolved against the current directory, then against 
`{openvar}PROJECT_HOME{closevar}` once the project is enabled.
+
+The same `-j`, `-e` and `-f` options work with `hop-gui.sh` / `hop-gui.bat`.
+`--project-locations` and `--in-memory` are parsed by the `hop` command and 
are not available on `hop-gui.sh` / `hop-gui.bat`.
+
+.Usage
+[%collapsible]
+=====
+
+.Output of help
+[source,bash]
+----
+Usage: hop gui [-hV] [-im] [-e=<environmentOption>] [-f=<filename>]
+               [-j=<projectOption>]
+               [--environment-conf-files=<environmentConfigFiles>[,
+               <environmentConfigFiles>...]]... 
[--environments=<environments>[,
+               <environments>...]]... [-pl=<projectLocations>[,
+               <projectLocations>...]]...
+The Hop GUI
+  -e, --environment=<environmentOption>
+                          The name of the lifecycle environment to use
+  -f, --file=<filename>   The filename of the workflow or pipeline to open
+  -h, --help              Show this help message and exit.
+      -im, --in-memory    Keep configuration in memory without persisting to
+                            hop-config.json
+  -j, --project=<projectOption>
+                          The name of the project to use
+      -pl, --project-locations=<projectLocations>[,<projectLocations>...]
+                          Comma-separated list of project locations
+                            (name=location or archive files)
+  -V, --version           Print version information and exit.
+----
+
+The available options are listed in more detail in the table below:
+
+[options="header"]
+|===
+|Short|Extended|Description
+|`-e`|`--environment`|The name of the lifecycle environment to enable. The 
project name is taken from that environment, so you do not need `-j` as well. 
See xref:projects/projects-environments.adoc[Projects and Environments].
+|`-f`|`--file`|The filename of the workflow or pipeline to open. Relative 
paths are resolved against the current directory, then 
`{openvar}PROJECT_HOME{closevar}`.
+|`-h`|`--help`|Displays this help message and quits.
+|`-im`|`--in-memory`|Keep Hop configuration in memory. Changes are not written 
to `hop-config.json`.
+|`-j`|`--project`|The name of the project to enable. Not needed when `-e` is 
set.
+|`-pl`|`--project-locations`|Comma-separated project locations to register for 
this process only (`name=/path` or a zip/jar). See 
xref:projects/advanced.adoc#_use_a_project_without_hop_config_json[Use a 
project without hop-config.json].
+||`--environments`|Comma-separated environment definitions 
(`name=[project:]file1;file2`) registered in memory for this process.
+||`--environment-conf-files` / `--environment-config-files`|Extra environment 
configuration files to apply to the enabled project or environment.
+|`-V`|`--version`|Print version information and exit.
+|===
+
+=====
+
+To open a project that is not listed in `hop-config.json`:
+
+[source,bash]
+----
+hop --project-locations my-project=/path/to/project gui -j my-project -f 
pipeline.hpl
+----
diff --git a/docs/hop-user-manual/modules/ROOT/pages/hop-tools/hop.adoc 
b/docs/hop-user-manual/modules/ROOT/pages/hop-tools/hop.adoc
index 8c0f7342e7..5ffba2f42c 100644
--- a/docs/hop-user-manual/modules/ROOT/pages/hop-tools/hop.adoc
+++ b/docs/hop-user-manual/modules/ROOT/pages/hop-tools/hop.adoc
@@ -27,20 +27,35 @@ The main goal is to have a single wrapper script to make 
maintenance easier for
 
 == Usage
 
-To see the usage of the `hop` tool, you can simply use the `help` command to 
see which commands are available:
+To see the usage of the `hop` tool, you can simply use the `help` command to 
see which commands are available.
+The command list depends on the plugins in your installation; the sample below 
shows the common options and a typical set of commands.
 
 [source,bash]
 ----
 $ ./hop help
-Usage: hop [-hV] [-s=<systemProperties>[,<systemProperties>...]]... [COMMAND]
-  -h, --help      Show this help message and exit.
+Usage: hop [-hV] [--dev-debug] [--dev-debug-wait] [-im]
+           [-e=<environmentOption>] [-j=<projectOption>]
+           [--environment-conf-files=<environmentConfigFiles>[,
+           <environmentConfigFiles>...]]... [--environments=<environments>[,
+           <environments>...]]... [-pl=<projectLocations>[,
+           <projectLocations>...]]... [-s=<systemProperties>[,
+           <systemProperties>...]]... [COMMAND]
+  -e, --environment=<environmentOption>
+                         The name of the lifecycle environment to use
+  -h, --help             Show this help message and exit.
+      -im, --in-memory   Keep configuration in memory without persisting to
+                           hop-config.json
+  -j, --project=<projectOption>
+                         The name of the project to use
+      -pl, --project-locations=<projectLocations>[,<projectLocations>...]
+                         Comma-separated list of project locations
+                           (name=location or archive files)
   -s, --system-properties=<systemProperties>[,<systemProperties>...]
-                  A comma separated list of KEY=VALUE pairs
-  -V, --version   Print version information and exit.
+                         A comma separated list of KEY=VALUE pairs
+  -V, --version          Print version information and exit.
 Commands:
   help     Display help information about the specified command.
   conf     Configure Hop
-  doc      Generate documentation
   encrypt  Encrypt secrets
   gui      The Hop GUI
   import   Import metadata
@@ -56,12 +71,26 @@ Commands:
 
 [options="header"]
 |===
-|Option|Description
-|-s|One or more system parameters.  These will be set prior to execution of a 
given command.
-|-h|Displays this help message and quits
-|-V|Show the current Hop version
+|Short|Extended|Description
+|`-e`|`--environment`|The name of the lifecycle environment to enable before 
the subcommand runs.
+|`-h`|`--help`|Displays this help message and quits.
+|`-im`|`--in-memory`|Keep Hop configuration in memory. Changes are not written 
to `hop-config.json`. Implied by `--project-locations` and `--environments`.
+|`-j`|`--project`|The name of the project to enable before the subcommand runs.
+|`-pl`|`--project-locations`|Comma-separated project locations to register for 
this process only (`name=/path`, a zip/jar, or a bare folder). See 
xref:projects/advanced.adoc#_use_a_project_without_hop_config_json[Use a 
project without hop-config.json].
+||`--environments`|Comma-separated environment definitions 
(`name=[project:]file1;file2`) registered in memory for this process.
+||`--environment-conf-files` / `--environment-config-files`|Extra environment 
configuration files to apply to the enabled project or environment.
+|`-s`|`--system-properties`|A comma separated list of KEY=VALUE pairs, set 
before Hop starts.
+|`-V`|`--version`|Show the current Hop version.
 |===
 
+Root options such as `--project-locations` are processed before the subcommand 
(`run`, `gui`, `search`, ...), so the project is already registered when the 
command runs:
+
+[source,bash]
+----
+hop --project-locations my-project=/data/my-project run -f pipeline.hpl -r 
local
+hop --project-locations my-project=/data/my-project gui -j my-project -f 
pipeline.hpl
+----
+
 == Setup
 
 xref:hop-tools/hop-setup.adoc[hop setup] writes launcher environment variables 
(`HOP_CONFIG_FOLDER`, `HOP_AUDIT_FOLDER`, and related). It is not the same as 
`hop conf`, which edits `hop-config.json`.
diff --git a/docs/hop-user-manual/modules/ROOT/pages/projects/advanced.adoc 
b/docs/hop-user-manual/modules/ROOT/pages/projects/advanced.adoc
index 168d5226e4..6905eb93e2 100644
--- a/docs/hop-user-manual/modules/ROOT/pages/projects/advanced.adoc
+++ b/docs/hop-user-manual/modules/ROOT/pages/projects/advanced.adoc
@@ -175,6 +175,50 @@ System property `HOP_CONFIG_FOLDER` can also be used to 
point to a different fol
 
 &nbsp; +
 
+== Use a project without hop-config.json
+
+You can run or open a project for a single process without adding it to 
`hop-config.json`.
+Hop registers the project in memory, points metadata at 
`{openvar}PROJECT_HOME{closevar}/metadata`, and does not write the registration 
back to disk.
+
+This is useful when the project is provided by another system (a checkout, a 
zip, a container mount) and you do not want to mutate the Hop configuration.
+
+[source,bash]
+----
+hop --project-locations my-project=/path/to/project run -f pipeline.hpl -r 
local
+hop --project-locations my-project=/path/to/project gui -j my-project -f 
pipeline.hpl
+----
+
+`--project-locations` (short `-pl`) accepts a comma-separated list of:
+
+* `name=/path/to/folder`
+* `name=/path/to/archive.zip` or `name=/path/to/archive.jar` (opened read-only 
through VFS)
+* `name=/path/to/folder:custom-config.json` when the project file is not 
`project-config.json`
+* a bare folder or archive: if the folder itself is a project 
(`project-config.json` or `hop-project.config`), it is registered under the 
folder name. If it is a parent of several project folders, each child with a 
project config file is registered.
+
+When more than one project is listed or discovered, Hop enables the leaf 
project (a child that references a parent).
+Parent metadata is still inherited.
+
+A zip export may also contain `metadata.json` and `variables.json`. Those 
files are loaded only for archive or JSON-export layouts (no `metadata/` 
folder), not for a normal project that happens to contain them.
+
+Optional environment files for that process:
+
+[source,bash]
+----
+hop --project-locations edw=/data/edw --environments 
edw-prod=/data/edw-prod.json \
+    run -e edw-prod -f load.hpl -r local
+----
+
+`--environments` uses `name=[project:]file1;file2`.
+If the project name is omitted, Hop infers it from the registered projects.
+`--environment-conf-files` (also `--environment-config-files`) adds extra 
variable files to the enabled project or environment.
+
+`-im` / `--in-memory` keeps Hop configuration in memory even when you are not 
using `--project-locations`.
+The same behaviour can be enabled with the 
`{openvar}HOP_CONFIG_IN_MEMORY{closevar}` system variable set to `Y`.
+
+These options are available on the `hop` root command (so they apply before 
`run`, `gui`, `search`, and so on) and on the subcommands themselves (`hop run 
-pl ...`, `hop gui -pl ...`).
+
+See xref:hop-tools/hop.adoc[hop], xref:hop-run/index.adoc[hop run] and 
xref:hop-tools/hop-gui.adoc[hop gui] for the full option lists.
+
 == Command Line Project Configuration
 
 In addition to the Hop Gui and configuration files, all aspects of and 
operations on projects and environments can be managed through the Hop Conf 
command line tool.
diff --git a/docs/hop-user-manual/modules/ROOT/pages/projects/index.adoc 
b/docs/hop-user-manual/modules/ROOT/pages/projects/index.adoc
index 55a6165a44..f843706b73 100644
--- a/docs/hop-user-manual/modules/ROOT/pages/projects/index.adoc
+++ b/docs/hop-user-manual/modules/ROOT/pages/projects/index.adoc
@@ -47,6 +47,8 @@ TIP: project variables should only be used when you need 
variables on the projec
 
 TIP: Project configurations are stored in hop-config.json, which is read from 
`hop/config` by default. Use the `HOP_CONFIG_FOLDER` operating system variable 
to store your Hop configuration in a folder outside your Hop folder. This will 
let you keep your project list if you switch Hop installations or upgrade to a 
newer Hop version.
 
+TIP: To run or open a project folder without adding it to `hop-config.json`, 
use `--project-locations`. See 
xref:projects/advanced.adoc#_use_a_project_without_hop_config_json[Use a 
project without hop-config.json].
+
 TIP: In Hop Web Docker, keep *project homes* on a host folder or volume as 
well. The image's `default` and `samples` projects are only a starter layout. 
See xref:hop-web-docker.adoc[Hop Web in Docker: persistence and upgrades].
 
 Projects can inherit metadata and variables from a parent project.
diff --git a/docs/hop-user-manual/modules/ROOT/pages/variables.adoc 
b/docs/hop-user-manual/modules/ROOT/pages/variables.adoc
index 1c6bbc99c9..a7d63b35a0 100644
--- a/docs/hop-user-manual/modules/ROOT/pages/variables.adoc
+++ b/docs/hop-user-manual/modules/ROOT/pages/variables.adoc
@@ -332,6 +332,7 @@ Additionally, the following environment variables can help 
you to add even more
 |===
 |Variable|Default|Description
 |HOP_AUTO_CREATE_CONFIG|N|Set this variable to 'Y' to automatically create 
config file when it's missing.
+|HOP_CONFIG_IN_MEMORY|N|Set this variable to 'Y' to keep the Hop configuration 
in memory without persisting to disk. The 
`{openvar}HOP_CONFIG_IN_MEMORY{closevar}` equivalent on the command line is 
`-im` / `--in-memory`.
 |HOP_METADATA_FOLDER|-|The system environment variable pointing to the 
alternative location for the Hop metadata folder
 |HOP_REDIRECT_STDERR|N|Set this variable to Y to redirect stderr to Hop 
logging.
 |HOP_REDIRECT_STDOUT|N|Set this variable to Y to redirect stdout to Hop 
logging.
diff --git a/engine/src/main/java/org/apache/hop/hop/Hop.java 
b/engine/src/main/java/org/apache/hop/hop/Hop.java
index f34d3145db..4d2095412d 100644
--- a/engine/src/main/java/org/apache/hop/hop/Hop.java
+++ b/engine/src/main/java/org/apache/hop/hop/Hop.java
@@ -21,12 +21,15 @@ import java.util.List;
 import lombok.Getter;
 import lombok.Setter;
 import org.apache.commons.lang3.StringUtils;
+import org.apache.hop.core.Const;
 import org.apache.hop.core.HopEnvironment;
 import org.apache.hop.core.HopVersionProvider;
+import org.apache.hop.core.config.plugin.ConfigPlugin;
 import org.apache.hop.core.config.plugin.ConfigPluginType;
 import org.apache.hop.core.config.plugin.IConfigOptions;
 import org.apache.hop.core.exception.HopPluginException;
 import org.apache.hop.core.logging.HopLogStore;
+import org.apache.hop.core.logging.LogChannel;
 import org.apache.hop.core.plugins.IPlugin;
 import org.apache.hop.core.plugins.JarCache;
 import org.apache.hop.core.plugins.PluginRegistry;
@@ -34,6 +37,7 @@ import org.apache.hop.core.variables.IVariables;
 import org.apache.hop.core.variables.Variables;
 import org.apache.hop.hop.plugin.HopCommandPluginType;
 import org.apache.hop.hop.plugin.IHopCommand;
+import org.apache.hop.metadata.api.IHasHopMetadataProvider;
 import org.apache.hop.metadata.serializer.multi.MultiMetadataProvider;
 import org.apache.hop.metadata.util.HopMetadataInstance;
 import org.apache.hop.metadata.util.HopMetadataUtil;
@@ -76,7 +80,11 @@ public class Hop {
   public static void main(String[] args) throws Exception {
     Hop hop = new Hop();
 
-    hop.cmd = new CommandLine(new Hop());
+    hop.cmd = new CommandLine(hop);
+
+    if (args.length > 0) {
+      hop.prepareInternalOptions(new CommandLine(hop), args);
+    }
 
     // We want to apply system properties before we boot up Hop, the plugins, 
and everything
     // associated.
@@ -85,6 +93,7 @@ public class Hop {
     // Apply the system properties to the JVM
     //
     hop.applySystemProperties();
+    hop.activateInMemoryFromArgs(args);
 
     // Initialize the Hop environment: load plugins and more
     //
@@ -107,6 +116,10 @@ public class Hop {
     hop.metadataProvider = 
HopMetadataUtil.getStandardHopMetadataProvider(hop.variables);
     HopMetadataInstance.setMetadataProvider(hop.metadataProvider);
 
+    // Add mixin plugins with the ROOT category (e.g. project locations, 
environments, in-memory)
+    //
+    addMixinPlugins(hop.cmd, ConfigPlugin.CATEGORY_ROOT);
+
     // Look in the plugin registry for @HopCommand plugins.
     // Instantiate and initialize each of them.
     //
@@ -128,10 +141,89 @@ public class Hop {
       System.exit(1);
     }
 
+    // Process options on root command mixins before executing subcommands
+    //
+    for (Object mixin : hop.cmd.getMixins().values()) {
+      if (mixin instanceof IConfigOptions configOptions) {
+        configOptions.handleOption(LogChannel.GENERAL, null, hop.variables);
+      }
+    }
+
+    // Root mixins (for example --project-locations) may have replaced the 
metadata provider on
+    // HopMetadataInstance. Subcommands were initialized with the original 
provider, so copy the
+    // current one onto the selected command.
+    //
+    MultiMetadataProvider currentMetadata = 
HopMetadataInstance.getMetadataProvider();
+    if (currentMetadata != null) {
+      hop.setMetadataProvider(currentMetadata);
+      applyMetadataProviderToParsedCommand(parseResult, currentMetadata);
+    }
+
     int exitCode = hop.cmd.execute(args);
     System.exit(exitCode);
   }
 
+  /**
+   * Apply a metadata provider to the user object of the last parsed 
subcommand, when it holds one.
+   *
+   * @param parseResult picocli parse result
+   * @param metadataProvider provider to set
+   */
+  public static void applyMetadataProviderToParsedCommand(
+      CommandLine.ParseResult parseResult, MultiMetadataProvider 
metadataProvider) {
+    if (parseResult == null || metadataProvider == null) {
+      return;
+    }
+    CommandLine.ParseResult current = parseResult;
+    while (current.hasSubcommand()) {
+      current = current.subcommand();
+    }
+    Object userObject = current.commandSpec().userObject();
+    if (userObject instanceof IHasHopMetadataProvider hasProvider) {
+      hasProvider.setMetadataProvider(metadataProvider);
+    }
+  }
+
+  private void prepareInternalOptions(CommandLine cmd, String[] args) {
+    for (String arg : args) {
+      if (arg.startsWith("-h") || arg.startsWith("--help")) {
+        return;
+      }
+    }
+
+    String[] helpArgs = new String[args.length + 1];
+    System.arraycopy(args, 0, helpArgs, 0, args.length);
+    helpArgs[args.length] = "-h";
+
+    cmd.parseArgs(helpArgs);
+  }
+
+  /**
+   * Mixins are not loaded yet, so scan the raw arguments for in-memory flags 
before {@link
+   * HopEnvironment#init()} can write hop-config.json.
+   */
+  void activateInMemoryFromArgs(String[] args) {
+    if (args == null) {
+      return;
+    }
+    for (String arg : args) {
+      if (arg == null) {
+        continue;
+      }
+      if (arg.equals("-im")
+          || arg.equals("--in-memory")
+          || arg.equals("-pl")
+          || arg.startsWith("-pl=")
+          || arg.equals("--project-locations")
+          || arg.startsWith("--project-locations=")
+          || arg.equals("--environments")
+          || arg.startsWith("--environments=")) {
+        System.setProperty(Const.HOP_CONFIG_IN_MEMORY, "Y");
+        return;
+      }
+    }
+  }
+
   public void applySystemProperties() {
     // Set some System properties if there were any
     //
@@ -148,7 +240,7 @@ public class Hop {
   }
 
   public static void addMixinPlugins(CommandLine cmd, String category) throws 
HopPluginException {
-    // Now add configuration plugins with the RUN category.
+    // Add configuration plugins for the given category (root, run, gui, 
search, ...).
     // The 'projects' plugin for example configures things like the project 
metadata provider.
     //
     List<IPlugin> configPlugins = 
PluginRegistry.getInstance().getPlugins(ConfigPluginType.class);
diff --git a/engine/src/main/java/org/apache/hop/run/HopRunBase.java 
b/engine/src/main/java/org/apache/hop/run/HopRunBase.java
index 7ce773bc8f..ef4649a381 100644
--- a/engine/src/main/java/org/apache/hop/run/HopRunBase.java
+++ b/engine/src/main/java/org/apache/hop/run/HopRunBase.java
@@ -50,6 +50,7 @@ import org.apache.hop.core.variables.Variables;
 import org.apache.hop.core.vfs.HopVfs;
 import org.apache.hop.metadata.api.IHasHopMetadataProvider;
 import org.apache.hop.metadata.serializer.multi.MultiMetadataProvider;
+import org.apache.hop.metadata.util.HopMetadataInstance;
 import org.apache.hop.pipeline.PipelineExecutionConfiguration;
 import org.apache.hop.pipeline.PipelineMeta;
 import org.apache.hop.pipeline.config.PipelineRunConfiguration;
@@ -183,6 +184,15 @@ public abstract class HopRunBase implements Runnable, 
IHasHopMetadataProvider {
         }
       }
 
+      // Root-level options such as --project-locations enable the project 
before this subcommand
+      // runs. Use that metadata provider when it was rebuilt against the 
project folder.
+      //
+      MultiMetadataProvider instanceProvider = 
HopMetadataInstance.getMetadataProvider();
+      if (instanceProvider != null
+          && 
StringUtils.isNotEmpty(variables.getVariable(Const.HOP_METADATA_FOLDER))) {
+        metadataProvider = instanceProvider;
+      }
+
       // Optionally we can configure metadata to come from a JSON export file.
       //
       String metadataExportFile = variables.resolve(getMetadataExportFile());
diff --git 
a/plugins/misc/projects/src/main/java/org/apache/hop/projects/config/ProjectsGuiOptionPlugin.java
 
b/plugins/misc/projects/src/main/java/org/apache/hop/projects/config/ProjectsGuiOptionPlugin.java
new file mode 100644
index 0000000000..6cf8c0ab9b
--- /dev/null
+++ 
b/plugins/misc/projects/src/main/java/org/apache/hop/projects/config/ProjectsGuiOptionPlugin.java
@@ -0,0 +1,60 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.projects.config;
+
+import lombok.Getter;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.hop.core.config.plugin.ConfigPlugin;
+import org.apache.hop.core.config.plugin.IConfigOptions;
+import org.apache.hop.core.exception.HopException;
+import org.apache.hop.core.logging.ILogChannel;
+import org.apache.hop.core.variables.IVariables;
+import org.apache.hop.metadata.api.IHasHopMetadataProvider;
+
+@ConfigPlugin(
+    id = "ProjectsGuiOptionPlugin",
+    description = "Project and Environment configuration options for hop-gui",
+    category = ConfigPlugin.CATEGORY_GUI)
+public class ProjectsGuiOptionPlugin extends ProjectsOptionPlugin implements 
IConfigOptions {
+
+  @Getter private static volatile String requestedProjectName;
+  @Getter private static volatile String requestedEnvironmentName;
+
+  /** Clears remembered GUI startup project and environment (for tests). */
+  public static void clearRequested() {
+    requestedProjectName = null;
+    requestedEnvironmentName = null;
+  }
+
+  @Override
+  public boolean handleOption(
+      ILogChannel log, IHasHopMetadataProvider hasHopMetadataProvider, 
IVariables variables)
+      throws HopException {
+    // Do not pass the GUI command as metadata holder. configure() would 
enable the default
+    // project and write an "open" audit event before last-used restoration in 
HopGuiStart.
+    boolean result = super.handleOption(log, null, variables);
+    // Picocli consumes -j/-e on the gui subcommand, so remember them for 
HopGuiStartProjectLoad.
+    if (StringUtils.isNotEmpty(getProjectName())) {
+      requestedProjectName = getProjectName();
+    }
+    if (StringUtils.isNotEmpty(getEnvironmentName())) {
+      requestedEnvironmentName = getEnvironmentName();
+    }
+    return result;
+  }
+}
diff --git 
a/plugins/misc/projects/src/main/java/org/apache/hop/projects/config/ProjectsOptionPlugin.java
 
b/plugins/misc/projects/src/main/java/org/apache/hop/projects/config/ProjectsOptionPlugin.java
index d1943e7b3e..6a8de46a1b 100644
--- 
a/plugins/misc/projects/src/main/java/org/apache/hop/projects/config/ProjectsOptionPlugin.java
+++ 
b/plugins/misc/projects/src/main/java/org/apache/hop/projects/config/ProjectsOptionPlugin.java
@@ -18,19 +18,27 @@
 package org.apache.hop.projects.config;
 
 import java.util.ArrayList;
+import java.util.Collections;
 import java.util.List;
+import lombok.Getter;
+import lombok.Setter;
 import org.apache.commons.lang3.StringUtils;
 import org.apache.hop.core.config.plugin.IConfigOptions;
 import org.apache.hop.core.exception.HopException;
 import org.apache.hop.core.logging.ILogChannel;
 import org.apache.hop.core.variables.IVariables;
 import org.apache.hop.metadata.api.IHasHopMetadataProvider;
+import org.apache.hop.metadata.serializer.multi.MultiMetadataProvider;
+import org.apache.hop.metadata.util.HopMetadataInstance;
 import org.apache.hop.projects.environment.LifecycleEnvironment;
 import org.apache.hop.projects.project.Project;
 import org.apache.hop.projects.project.ProjectConfig;
+import org.apache.hop.projects.util.ProjectsConfigHelper;
 import org.apache.hop.projects.util.ProjectsUtil;
 import picocli.CommandLine;
 
+@Getter
+@Setter
 public class ProjectsOptionPlugin implements IConfigOptions {
 
   @CommandLine.Option(
@@ -43,6 +51,31 @@ public class ProjectsOptionPlugin implements IConfigOptions {
       description = "The name of the project to use")
   private String projectOption = null;
 
+  @CommandLine.Option(
+      names = {"-pl", "--project-locations"},
+      description = "Comma-separated list of project locations (name=location 
or archive files)",
+      split = ",")
+  protected String[] projectLocations = null;
+
+  @CommandLine.Option(
+      names = {"--environments"},
+      description =
+          "Comma-separated list of environment definitions 
(name=[project:]configFile1;configFile2)",
+      split = ",")
+  protected String[] environments = null;
+
+  @CommandLine.Option(
+      names = {"--environment-conf-files", "--environment-config-files"},
+      description =
+          "Comma-separated list of configuration files to apply to the project 
or environment",
+      split = ",")
+  protected String[] environmentConfigFiles = null;
+
+  @CommandLine.Option(
+      names = {"-im", "--in-memory"},
+      description = "Keep configuration in memory without persisting to 
hop-config.json")
+  protected boolean inMemory = false;
+
   protected String projectName;
   protected String environmentName;
 
@@ -51,9 +84,49 @@ public class ProjectsOptionPlugin implements IConfigOptions {
       ILogChannel log, IHasHopMetadataProvider hasHopMetadataProvider, 
IVariables variables)
       throws HopException {
 
+    if (inMemory
+        || projectLocations != null
+        || environments != null
+        || environmentConfigFiles != null) {
+      ProjectsConfigHelper.enableInMemoryMode(log);
+    }
+
+    List<String> registeredProjects = new ArrayList<>();
+    if (projectLocations != null && projectLocations.length > 0) {
+      registeredProjects =
+          ProjectsConfigHelper.addProjectLocations(log, variables, 
projectLocations);
+    }
+
+    if (environments != null && environments.length > 0) {
+      ProjectsConfigHelper.addEnvironments(log, variables, environments, 
registeredProjects);
+    }
+
     projectName = projectOption;
     environmentName = environmentOption;
-    return configure(log, variables, hasHopMetadataProvider, projectName, 
environmentName);
+
+    if (StringUtils.isEmpty(projectName) && 
StringUtils.isEmpty(environmentName)) {
+      projectName =
+          ProjectsConfigHelper.determineActiveProject(
+              projectName, environmentName, registeredProjects, variables);
+    }
+
+    if (hasHopMetadataProvider == null
+        && StringUtils.isEmpty(projectName)
+        && StringUtils.isEmpty(environmentName)) {
+      return false;
+    }
+
+    List<String> extraConfigFiles = new ArrayList<>();
+    if (environmentConfigFiles != null) {
+      for (String cf : environmentConfigFiles) {
+        if (StringUtils.isNotEmpty(cf)) {
+          extraConfigFiles.add(cf.trim());
+        }
+      }
+    }
+
+    return configure(
+        log, variables, hasHopMetadataProvider, projectName, environmentName, 
extraConfigFiles);
   }
 
   public static final boolean configure(
@@ -63,6 +136,23 @@ public class ProjectsOptionPlugin implements IConfigOptions 
{
       String projectName,
       String environmentName)
       throws HopException {
+    return configure(
+        log,
+        variables,
+        hasHopMetadataProvider,
+        projectName,
+        environmentName,
+        Collections.emptyList());
+  }
+
+  public static final boolean configure(
+      ILogChannel log,
+      IVariables variables,
+      IHasHopMetadataProvider hasHopMetadataProvider,
+      String projectName,
+      String environmentName,
+      List<String> extraConfigFiles)
+      throws HopException {
     ProjectsConfig config = ProjectsConfigSingleton.getConfig();
     ProjectConfig projectConfig;
     List<String> configurationFiles = new ArrayList<>();
@@ -141,6 +231,19 @@ public class ProjectsOptionPlugin implements 
IConfigOptions {
       return false;
     }
 
+    if (extraConfigFiles != null && !extraConfigFiles.isEmpty()) {
+      configurationFiles.addAll(extraConfigFiles);
+    }
+
+    if (ProjectsConfigHelper.alreadyEnabled(projectName, environmentName)
+        && (extraConfigFiles == null || extraConfigFiles.isEmpty())) {
+      MultiMetadataProvider current = 
HopMetadataInstance.getMetadataProvider();
+      if (hasHopMetadataProvider != null && current != null) {
+        hasHopMetadataProvider.setMetadataProvider(current);
+      }
+      return true;
+    }
+
     try {
       Project project = projectConfig.loadProject(variables);
       log.logBasic("Enabling project '" + projectName + "'");
diff --git 
a/plugins/misc/projects/src/main/java/org/apache/hop/projects/config/ProjectsRootOptionPlugin.java
 
b/plugins/misc/projects/src/main/java/org/apache/hop/projects/config/ProjectsRootOptionPlugin.java
new file mode 100644
index 0000000000..d2f154eb33
--- /dev/null
+++ 
b/plugins/misc/projects/src/main/java/org/apache/hop/projects/config/ProjectsRootOptionPlugin.java
@@ -0,0 +1,31 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *       http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.projects.config;
+
+import lombok.Getter;
+import lombok.Setter;
+import org.apache.hop.core.config.plugin.ConfigPlugin;
+import org.apache.hop.core.config.plugin.IConfigOptions;
+
+@Getter
+@Setter
+@ConfigPlugin(
+    id = "ProjectsRootOptionPlugin",
+    description = "Root CLI project and environment options",
+    category = ConfigPlugin.CATEGORY_ROOT)
+public class ProjectsRootOptionPlugin extends ProjectsOptionPlugin implements 
IConfigOptions {}
diff --git 
a/plugins/misc/projects/src/main/java/org/apache/hop/projects/project/ProjectConfig.java
 
b/plugins/misc/projects/src/main/java/org/apache/hop/projects/project/ProjectConfig.java
index 15bd6104ae..502c739b4a 100644
--- 
a/plugins/misc/projects/src/main/java/org/apache/hop/projects/project/ProjectConfig.java
+++ 
b/plugins/misc/projects/src/main/java/org/apache/hop/projects/project/ProjectConfig.java
@@ -29,9 +29,15 @@ import org.apache.commons.vfs2.FileObject;
 import org.apache.hop.core.exception.HopException;
 import org.apache.hop.core.variables.IVariables;
 import org.apache.hop.core.vfs.HopVfs;
+import org.apache.hop.projects.config.ProjectsConfig;
 
 public class ProjectConfig {
 
+  /** Project config filenames recognised besides the declared 
`configFilename`. */
+  public static final String[] CONFIG_FILENAME_CANDIDATES = {
+    ProjectsConfig.DEFAULT_PROJECT_CONFIG_FILENAME, "hop-project.config"
+  };
+
   /**
    * VFS schemes that provide read-only access to archive contents (Zip, Jar 
and Tar family). See
    * 
https://commons.apache.org/proper/commons-vfs/filesystems.html#Zip.2C_Jar_and_Tar
@@ -131,21 +137,30 @@ public class ProjectConfig {
                 + "' does not exist");
       }
       String actualConfigFilename = variables.resolve(getConfigFilename());
+      if (StringUtils.isEmpty(actualConfigFilename)) {
+        actualConfigFilename = ProjectsConfig.DEFAULT_PROJECT_CONFIG_FILENAME;
+      }
+      FileObject configFile = actualHome.resolveFile(actualConfigFilename);
+      if (!configFile.exists()) {
+        for (String candidate : CONFIG_FILENAME_CANDIDATES) {
+          FileObject cand = actualHome.resolveFile(candidate);
+          if (cand.exists()) {
+            configFile = cand;
+            actualConfigFilename = candidate;
+            break;
+          }
+        }
+      }
       // Use VFS resolve so archive/HTTP URIs work (FilenameUtils.concat 
mangles schemes).
       // For plain local paths keep the previous FilenameUtils behaviour for 
compatibility.
       //
       String scheme = actualHome.getName().getScheme();
       if (scheme != null && !"file".equalsIgnoreCase(scheme)) {
-        FileObject configFile = actualHome.resolveFile(actualConfigFilename);
         return configFile.getName().getURI();
       }
       String fullFilename = FilenameUtils.concat(actualHome.toString(), 
actualConfigFilename);
       if (fullFilename == null) {
-        throw new HopException(
-            "Unable to determine full path to the configuration file '"
-                + actualConfigFilename
-                + "' in home folder '"
-                + actualHomeFolder);
+        return configFile.getName().getPath();
       }
       return fullFilename;
     } catch (Exception e) {
diff --git 
a/plugins/misc/projects/src/main/java/org/apache/hop/projects/util/ProjectsConfigHelper.java
 
b/plugins/misc/projects/src/main/java/org/apache/hop/projects/util/ProjectsConfigHelper.java
new file mode 100644
index 0000000000..a8ffa8f1bd
--- /dev/null
+++ 
b/plugins/misc/projects/src/main/java/org/apache/hop/projects/util/ProjectsConfigHelper.java
@@ -0,0 +1,710 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *       http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.projects.util;
+
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.CopyOnWriteArrayList;
+import org.apache.commons.io.FilenameUtils;
+import org.apache.commons.io.IOUtils;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.commons.vfs2.FileObject;
+import org.apache.commons.vfs2.FileType;
+import org.apache.hop.core.config.HopConfig;
+import org.apache.hop.core.exception.HopException;
+import org.apache.hop.core.logging.HopLogStore;
+import org.apache.hop.core.logging.ILogChannel;
+import org.apache.hop.core.metadata.SerializableMetadataProvider;
+import org.apache.hop.core.variables.IVariables;
+import org.apache.hop.core.vfs.HopVfs;
+import org.apache.hop.metadata.serializer.multi.MultiMetadataProvider;
+import org.apache.hop.projects.config.ProjectsConfig;
+import org.apache.hop.projects.config.ProjectsConfigSingleton;
+import org.apache.hop.projects.environment.LifecycleEnvironment;
+import org.apache.hop.projects.project.Project;
+import org.apache.hop.projects.project.ProjectConfig;
+
+/**
+ * Helper class for in-memory Hop configuration and dynamic project and 
environment registration.
+ */
+public class ProjectsConfigHelper {
+
+  private static final String[] CONFIG_CANDIDATES = 
ProjectConfig.CONFIG_FILENAME_CANDIDATES;
+
+  /**
+   * Project names registered in this process via {@code --project-locations}. 
Used so a subcommand
+   * mixin (for example hop-run) can enable a project that was registered on 
the root command.
+   */
+  private static final List<String> sessionRegisteredProjects = new 
CopyOnWriteArrayList<>();
+
+  private static volatile String lastEnabledProjectName;
+  private static volatile String lastEnabledEnvironmentName;
+
+  /** Private constructor to prevent instantiation. */
+  private ProjectsConfigHelper() {}
+
+  /**
+   * Project names registered via {@code --project-locations} in this process.
+   *
+   * @return unmodifiable copy of the session list
+   */
+  public static List<String> getSessionRegisteredProjects() {
+    return Collections.unmodifiableList(new 
ArrayList<>(sessionRegisteredProjects));
+  }
+
+  /** Clears the session list of dynamically registered projects (for tests). 
*/
+  public static void clearSessionRegisteredProjects() {
+    sessionRegisteredProjects.clear();
+    lastEnabledProjectName = null;
+    lastEnabledEnvironmentName = null;
+  }
+
+  /**
+   * True when this process already enabled the same project and environment, 
so a later mixin can
+   * skip a second full enable.
+   */
+  public static boolean alreadyEnabled(String projectName, String 
environmentName) {
+    return StringUtils.equals(projectName, lastEnabledProjectName)
+        && StringUtils.equals(
+            StringUtils.defaultString(environmentName),
+            StringUtils.defaultString(lastEnabledEnvironmentName));
+  }
+
+  public static void markEnabled(String projectName, String environmentName) {
+    lastEnabledProjectName = projectName;
+    lastEnabledEnvironmentName = environmentName;
+  }
+
+  private static void rememberRegisteredProject(String projectName) {
+    if (StringUtils.isNotEmpty(projectName) && 
!sessionRegisteredProjects.contains(projectName)) {
+      sessionRegisteredProjects.add(projectName);
+    }
+  }
+
+  private static void logBasic(ILogChannel log, String message) {
+    if (log != null && HopLogStore.isInitialized() && log.isBasic()) {
+      log.logBasic(message);
+    }
+  }
+
+  private static void logError(ILogChannel log, String message, Throwable e) {
+    if (log != null && HopLogStore.isInitialized()) {
+      log.logError(message, e);
+    }
+  }
+
+  /** Activates in-memory mode on HopConfig to ensure that changes are never 
persisted to disk. */
+  public static void enableInMemoryMode(ILogChannel log) {
+    if (!HopConfig.isInMemoryMode()) {
+      HopConfig.setInMemoryMode(true);
+      logBasic(
+          log, "Hop configuration is running in in-memory mode. No changes 
will be saved to disk.");
+    }
+  }
+
+  /**
+   * Normalizes a project location. If the location points to an archive file 
(.zip or .jar), it is
+   * converted to a Commons VFS archive URI (e.g. 
zip:file:///path/to/archive.zip!/).
+   */
+  public static String normalizeProjectHome(String path, IVariables variables) 
{
+    if (StringUtils.isEmpty(path)) {
+      return path;
+    }
+    String resolved = (variables != null) ? variables.resolve(path).trim() : 
path.trim();
+    if (ProjectConfig.isArchiveUri(resolved)) {
+      return resolved;
+    }
+
+    String lower = resolved.toLowerCase(Locale.ROOT);
+    if (lower.endsWith(".zip") || lower.endsWith(".jar")) {
+      try {
+        FileObject fileObject = HopVfs.getFileObject(resolved, variables);
+        String uri = fileObject.getName().getURI();
+        return "zip:" + uri + "!/";
+      } catch (Exception e) {
+        return "zip:file://" + resolved + "!/";
+      }
+    }
+
+    if (resolved.contains(".zip!") || resolved.contains(".jar!")) {
+      if (!resolved.startsWith("zip:") && !resolved.startsWith("jar:")) {
+        return "zip:" + resolved;
+      }
+    }
+
+    return resolved;
+  }
+
+  /**
+   * Registers dynamic project locations in the in-memory ProjectsConfig.
+   *
+   * @param log logger
+   * @param variables variables space
+   * @param locations array of location strings (can be comma-separated or 
key=val)
+   * @return list of registered project names
+   * @throws HopException on error
+   */
+  public static List<String> addProjectLocations(
+      ILogChannel log, IVariables variables, String[] locations) throws 
HopException {
+    List<String> registeredNames = new ArrayList<>();
+    if (locations == null || locations.length == 0) {
+      return registeredNames;
+    }
+
+    enableInMemoryMode(log);
+    ProjectsConfig config = ProjectsConfigSingleton.getConfig();
+
+    List<String> entries = new ArrayList<>();
+    for (String locItem : locations) {
+      if (StringUtils.isNotEmpty(locItem)) {
+        for (String splitItem : splitRespectingQuotes(locItem, ',')) {
+          if (StringUtils.isNotEmpty(splitItem)) {
+            entries.add(splitItem.trim());
+          }
+        }
+      }
+    }
+
+    for (String entry : entries) {
+      int equalsIdx = entry.indexOf('=');
+      if (equalsIdx > 0) {
+        String projectName = entry.substring(0, equalsIdx).trim();
+        String locPart = entry.substring(equalsIdx + 1).trim();
+        String explicitConfigFile = null;
+
+        // Check for optional :configFilename suffix (e.g. 
/path/project:hop-project.config)
+        int colonIdx = locPart.lastIndexOf(':');
+        if (colonIdx > 0 && colonIdx < locPart.length() - 1) {
+          String suffix = locPart.substring(colonIdx + 1);
+          if (suffix.endsWith(".json") || suffix.endsWith(".config")) {
+            // Ensure this colon is not part of a URI scheme (like 
zip:file:///...)
+            String prefix = locPart.substring(0, colonIdx);
+            if (!prefix.endsWith("!/")) {
+              explicitConfigFile = suffix;
+              locPart = prefix;
+            }
+          }
+        }
+
+        String normalizedHome = normalizeProjectHome(locPart, variables);
+        normalizedHome = resolveExportSubfolder(normalizedHome, projectName, 
variables);
+        String configFile = explicitConfigFile;
+        if (configFile == null) {
+          configFile = detectConfigFilename(normalizedHome, variables);
+        }
+        if (configFile == null) {
+          configFile = ProjectsConfig.DEFAULT_PROJECT_CONFIG_FILENAME;
+        }
+
+        ProjectConfig pc = new ProjectConfig(projectName, normalizedHome, 
configFile);
+        if (ProjectConfig.isArchiveUri(normalizedHome)) {
+          pc.setReadOnly(true);
+        }
+
+        config.addProjectConfig(pc);
+        registeredNames.add(projectName);
+        rememberRegisteredProject(projectName);
+
+        logBasic(
+            log,
+            "Registered in-memory project '"
+                + projectName
+                + "' with home '"
+                + normalizedHome
+                + "' (config: '"
+                + configFile
+                + "', read-only: "
+                + pc.isReadOnly()
+                + ")");
+      } else {
+        // Standalone folder or zip archive without name= prefix
+        String normalizedHome = normalizeProjectHome(entry, variables);
+        List<String> discovered =
+            discoverAndRegisterProjects(log, variables, config, 
normalizedHome);
+        registeredNames.addAll(discovered);
+      }
+    }
+
+    return registeredNames;
+  }
+
+  /**
+   * If an archive contains a single subfolder matching projectName or 
containing the project
+   * config, resolve projectHome to point inside that subfolder.
+   */
+  private static String resolveExportSubfolder(
+      String homeUri, String projectName, IVariables variables) {
+    if (!ProjectConfig.isArchiveUri(homeUri)) {
+      return homeUri;
+    }
+    try {
+      FileObject rootObj = HopVfs.getFileObject(homeUri, variables);
+      if (rootObj.exists()) {
+        // If root has candidate config file, keep root
+        for (String candidate : CONFIG_CANDIDATES) {
+          if (rootObj.resolveFile(candidate).exists()) {
+            return homeUri;
+          }
+        }
+        if (rootObj.resolveFile("metadata.json").exists()) {
+          return homeUri;
+        }
+
+        // Check if there is a subfolder named after the project
+        if (StringUtils.isNotEmpty(projectName)) {
+          FileObject projectSubfolder = rootObj.resolveFile(projectName);
+          if (projectSubfolder.exists() && projectSubfolder.getType() == 
FileType.FOLDER) {
+            return projectSubfolder.getName().getURI();
+          }
+        }
+
+        // Check if there is exactly one subfolder containing a project config
+        FileObject[] children = rootObj.getChildren();
+        if (children != null) {
+          for (FileObject child : children) {
+            if (child.getType() == FileType.FOLDER) {
+              for (String candidate : CONFIG_CANDIDATES) {
+                if (child.resolveFile(candidate).exists()) {
+                  return child.getName().getURI();
+                }
+              }
+              if (child.resolveFile("metadata.json").exists()) {
+                return child.getName().getURI();
+              }
+            }
+          }
+        }
+      }
+    } catch (Exception e) {
+      // Fallback to original URI
+    }
+    return homeUri;
+  }
+
+  /** Discovers and registers projects from a standalone folder or archive. */
+  private static List<String> discoverAndRegisterProjects(
+      ILogChannel log, IVariables variables, ProjectsConfig config, String 
homeUri)
+      throws HopException {
+    List<String> registered = new ArrayList<>();
+    try {
+      FileObject homeObj = HopVfs.getFileObject(homeUri, variables);
+      if (!homeObj.exists()) {
+        throw new HopException("Project location '" + homeUri + "' does not 
exist");
+      }
+
+      // Check subfolders for projects
+      FileObject[] children = homeObj.getChildren();
+      if (children != null) {
+        for (FileObject child : children) {
+          if (child.getType() == FileType.FOLDER) {
+            String detectedConfig = 
detectConfigFilename(child.getName().getURI(), variables);
+            if (detectedConfig != null || 
child.resolveFile("metadata.json").exists()) {
+              String name = child.getName().getBaseName();
+              String configFilename =
+                  (detectedConfig != null)
+                      ? detectedConfig
+                      : ProjectsConfig.DEFAULT_PROJECT_CONFIG_FILENAME;
+              ProjectConfig pc = new ProjectConfig(name, 
child.getName().getURI(), configFilename);
+              if (ProjectConfig.isArchiveUri(homeUri)) {
+                pc.setReadOnly(true);
+              }
+              config.addProjectConfig(pc);
+              registered.add(name);
+              rememberRegisteredProject(name);
+              logBasic(
+                  log,
+                  "Discovered in-memory project '"
+                      + name
+                      + "' in '"
+                      + child.getName().getURI()
+                      + "'");
+            }
+          }
+        }
+      }
+
+      // If no subfolder projects were discovered, check the root itself
+      if (registered.isEmpty()) {
+        String detectedConfig = detectConfigFilename(homeUri, variables);
+        String name = homeObj.getName().getBaseName();
+        if (name.endsWith("!/")) {
+          name = name.substring(0, name.length() - 2);
+        }
+        if (name.contains(".")) {
+          name = FilenameUtils.getBaseName(name);
+        }
+        if (StringUtils.isEmpty(name)) {
+          name = "default";
+        }
+        String configFilename =
+            (detectedConfig != null)
+                ? detectedConfig
+                : ProjectsConfig.DEFAULT_PROJECT_CONFIG_FILENAME;
+        ProjectConfig pc = new ProjectConfig(name, homeUri, configFilename);
+        if (ProjectConfig.isArchiveUri(homeUri)) {
+          pc.setReadOnly(true);
+        }
+        config.addProjectConfig(pc);
+        registered.add(name);
+        rememberRegisteredProject(name);
+        logBasic(
+            log, "Registered standalone in-memory project '" + name + "' at '" 
+ homeUri + "'");
+      }
+    } catch (Exception e) {
+      throw new HopException("Error discovering projects in location '" + 
homeUri + "'", e);
+    }
+    return registered;
+  }
+
+  /** Detects the config filename within a project home directory. */
+  public static String detectConfigFilename(String homeUri, IVariables 
variables) {
+    try {
+      FileObject homeObj = HopVfs.getFileObject(homeUri, variables);
+      if (homeObj.exists()) {
+        for (String candidate : CONFIG_CANDIDATES) {
+          if (homeObj.resolveFile(candidate).exists()) {
+            return candidate;
+          }
+        }
+      }
+    } catch (Exception e) {
+      // Ignored
+    }
+    return null;
+  }
+
+  /**
+   * Registers dynamic lifecycle environments in the in-memory ProjectsConfig. 
Format:
+   * envName=[project:]file1[;file2...] or envName=file1
+   */
+  public static void addEnvironments(
+      ILogChannel log, IVariables variables, String[] envDefs, List<String> 
registeredProjectNames)
+      throws HopException {
+    if (envDefs == null || envDefs.length == 0) {
+      return;
+    }
+
+    enableInMemoryMode(log);
+    ProjectsConfig config = ProjectsConfigSingleton.getConfig();
+
+    List<String> entries = new ArrayList<>();
+    for (String envItem : envDefs) {
+      if (StringUtils.isNotEmpty(envItem)) {
+        for (String splitItem : splitRespectingQuotes(envItem, ',')) {
+          if (StringUtils.isNotEmpty(splitItem)) {
+            entries.add(splitItem.trim());
+          }
+        }
+      }
+    }
+
+    for (String entry : entries) {
+      int equalsIdx = entry.indexOf('=');
+      if (equalsIdx <= 0) {
+        continue;
+      }
+      String envName = entry.substring(0, equalsIdx).trim();
+      String valPart = entry.substring(equalsIdx + 1).trim();
+
+      String targetProject = null;
+      String filesStr = valPart;
+
+      // Check if project name is specified before a colon: e.g. 
edw:/path/conf.json
+      // Be careful of Windows drive letters (e.g. C:/...)
+      int colonIdx = valPart.indexOf(':');
+      if (colonIdx > 0 && colonIdx < valPart.length() - 1) {
+        boolean isDriveLetter =
+            (colonIdx == 1)
+                && Character.isLetter(valPart.charAt(0))
+                && (valPart.charAt(2) == '/' || valPart.charAt(2) == '\\');
+        if (!isDriveLetter && !valPart.startsWith("file:") && 
!valPart.startsWith("zip:")) {
+          targetProject = valPart.substring(0, colonIdx).trim();
+          filesStr = valPart.substring(colonIdx + 1).trim();
+        }
+      }
+
+      // If target project not explicitly given, infer from envName or 
registered projects
+      if (StringUtils.isEmpty(targetProject)) {
+        targetProject = inferTargetProject(envName, registeredProjectNames, 
config, variables);
+      }
+
+      List<String> configFiles = new ArrayList<>();
+      for (String file : filesStr.split("[;,]")) {
+        String trimmed = file.trim();
+        if (StringUtils.isNotEmpty(trimmed)) {
+          configFiles.add(trimmed);
+        }
+      }
+
+      LifecycleEnvironment env =
+          new LifecycleEnvironment(envName, "In-memory", targetProject, 
configFiles);
+      config.addEnvironment(env);
+
+      logBasic(
+          log,
+          "Registered in-memory lifecycle environment '"
+              + envName
+              + "' for project '"
+              + targetProject
+              + "' with configuration files: "
+              + configFiles);
+    }
+  }
+
+  /** Infers which project an environment belongs to. */
+  private static String inferTargetProject(
+      String envName,
+      List<String> registeredProjectNames,
+      ProjectsConfig config,
+      IVariables variables) {
+    // 1. Check prefix match with registered projects: e.g. "edw-prod" starts 
with "edw"
+    if (registeredProjectNames != null) {
+      for (String proj : registeredProjectNames) {
+        if (envName.equalsIgnoreCase(proj)
+            || 
envName.toLowerCase(Locale.ROOT).startsWith(proj.toLowerCase(Locale.ROOT) + "-")
+            || 
envName.toLowerCase(Locale.ROOT).startsWith(proj.toLowerCase(Locale.ROOT) + "_")
+            || 
envName.toLowerCase(Locale.ROOT).startsWith(proj.toLowerCase(Locale.ROOT) + 
".")) {
+          return proj;
+        }
+      }
+    }
+
+    // 2. Check leaf project among registered projects (a project that is 
child and not parent)
+    if (registeredProjectNames != null && !registeredProjectNames.isEmpty()) {
+      String leaf = findLeafProject(registeredProjectNames, config, variables);
+      if (leaf != null) {
+        return leaf;
+      }
+      return registeredProjectNames.get(registeredProjectNames.size() - 1);
+    }
+
+    // 3. Fallback to first configured project
+    if (!config.getProjectConfigurations().isEmpty()) {
+      return config.getProjectConfigurations().get(0).getProjectName();
+    }
+
+    return "default";
+  }
+
+  /** Finds the leaf project in a hierarchy (e.g. child referencing a parent). 
*/
+  public static String findLeafProject(
+      List<String> projectNames, ProjectsConfig config, IVariables variables) {
+    if (projectNames == null || projectNames.isEmpty()) {
+      return null;
+    }
+    if (projectNames.size() == 1) {
+      return projectNames.get(0);
+    }
+
+    Set<String> parents = new HashSet<>();
+    for (String name : projectNames) {
+      ProjectConfig pc = config.findProjectConfig(name);
+      if (pc != null) {
+        try {
+          Project p = pc.loadProject(variables);
+          if (p != null && StringUtils.isNotEmpty(p.getParentProjectName())) {
+            parents.add(p.getParentProjectName());
+          }
+        } catch (Exception e) {
+          // Ignored
+        }
+      }
+    }
+
+    // A leaf project is one that is NOT a parent of any other project
+    for (int i = projectNames.size() - 1; i >= 0; i--) {
+      String name = projectNames.get(i);
+      if (!parents.contains(name)) {
+        return name;
+      }
+    }
+
+    return projectNames.get(projectNames.size() - 1);
+  }
+
+  /** Determines the active project if not explicitly supplied via CLI. */
+  public static String determineActiveProject(
+      String projectName,
+      String environmentName,
+      List<String> registeredProjectNames,
+      IVariables variables) {
+    if (StringUtils.isNotEmpty(projectName)) {
+      return projectName;
+    }
+
+    ProjectsConfig config = ProjectsConfigSingleton.getConfig();
+    if (StringUtils.isEmpty(environmentName) && variables != null) {
+      environmentName = 
variables.getVariable(Defaults.VARIABLE_HOP_ENVIRONMENT_NAME);
+    }
+    if (StringUtils.isNotEmpty(environmentName)) {
+      LifecycleEnvironment env = config.findEnvironment(environmentName);
+      if (env != null && StringUtils.isNotEmpty(env.getProjectName())) {
+        return env.getProjectName();
+      }
+    }
+
+    List<String> candidates = registeredProjectNames;
+    if ((candidates == null || candidates.isEmpty()) && 
!sessionRegisteredProjects.isEmpty()) {
+      candidates = sessionRegisteredProjects;
+    }
+    if (candidates != null && !candidates.isEmpty()) {
+      return findLeafProject(candidates, config, variables);
+    }
+
+    if (variables != null) {
+      String activeProject = 
variables.getVariable(Defaults.VARIABLE_HOP_PROJECT_NAME);
+      if (StringUtils.isNotEmpty(activeProject)
+          && config.findProjectConfig(activeProject) != null) {
+        return activeProject;
+      }
+    }
+
+    return null;
+  }
+
+  /**
+   * True when the home is an archive or a JSON-export layout (metadata.json / 
variables.json and no
+   * metadata folder). Regular projects with a {@code metadata/} folder are 
not treated as exports.
+   */
+  public static boolean isJsonExportHome(String projectHome, IVariables 
variables) {
+    if (StringUtils.isEmpty(projectHome)) {
+      return false;
+    }
+    if (ProjectConfig.isArchiveUri(projectHome)) {
+      return true;
+    }
+    try {
+      FileObject homeObj = HopVfs.getFileObject(projectHome, variables);
+      if (!homeObj.exists()) {
+        return false;
+      }
+      FileObject metadataDir = homeObj.resolveFile("metadata");
+      if (metadataDir.exists() && metadataDir.getType() == FileType.FOLDER) {
+        return false;
+      }
+      FileObject metadataJson = homeObj.resolveFile("metadata.json");
+      FileObject variablesJson = homeObj.resolveFile("variables.json");
+      return (metadataJson.exists() && metadataJson.isFile())
+          || (variablesJson.exists() && variablesJson.isFile());
+    } catch (Exception e) {
+      return false;
+    }
+  }
+
+  /**
+   * Loads metadata and variables from project export files (metadata.json, 
variables.json) if
+   * present in a JSON-export or archive home.
+   */
+  public static void applyProjectExportFiles(
+      ILogChannel log,
+      String projectHome,
+      IVariables variables,
+      MultiMetadataProvider metadataProvider) {
+    applyProjectExportFiles(log, projectHome, variables, metadataProvider, 
true, true);
+  }
+
+  public static void applyProjectExportFiles(
+      ILogChannel log,
+      String projectHome,
+      IVariables variables,
+      MultiMetadataProvider metadataProvider,
+      boolean loadVariables,
+      boolean loadMetadata) {
+    if (StringUtils.isEmpty(projectHome) || (!loadVariables && !loadMetadata)) 
{
+      return;
+    }
+    try {
+      String realProjectHome = (variables != null) ? 
variables.resolve(projectHome) : projectHome;
+      if (!isJsonExportHome(realProjectHome, variables)) {
+        return;
+      }
+      FileObject projectHomeObj = HopVfs.getFileObject(realProjectHome, 
variables);
+      if (!projectHomeObj.exists()) {
+        return;
+      }
+
+      if (loadMetadata && metadataProvider != null) {
+        FileObject metadataJsonObj = 
projectHomeObj.resolveFile("metadata.json");
+        if (metadataJsonObj.exists() && metadataJsonObj.isFile()) {
+          try (InputStream in = HopVfs.getInputStream(metadataJsonObj)) {
+            String json = IOUtils.toString(in, StandardCharsets.UTF_8);
+            metadataProvider.getProviders().add(new 
SerializableMetadataProvider(json));
+            logBasic(log, "Loaded exported metadata from: " + 
metadataJsonObj.getName().getURI());
+          }
+        }
+      }
+
+      if (loadVariables && variables != null) {
+        FileObject variablesJsonObj = 
projectHomeObj.resolveFile("variables.json");
+        if (variablesJsonObj.exists() && variablesJsonObj.isFile()) {
+          try (InputStream in = HopVfs.getInputStream(variablesJsonObj)) {
+            ObjectMapper mapper = new ObjectMapper();
+            Map<String, String> varMap =
+                mapper.readValue(in, new TypeReference<Map<String, String>>() 
{});
+            for (Map.Entry<String, String> entry : varMap.entrySet()) {
+              variables.setVariable(entry.getKey(), entry.getValue());
+            }
+            logBasic(log, "Loaded exported variables from: " + 
variablesJsonObj.getName().getURI());
+          }
+        }
+      }
+    } catch (Exception e) {
+      logError(log, "Error applying project export files from home: " + 
projectHome, e);
+    }
+  }
+
+  /** Splits a string by delimiter, respecting single or double quotes. */
+  private static List<String> splitRespectingQuotes(String str, char 
delimiter) {
+    List<String> tokens = new ArrayList<>();
+    if (str == null) {
+      return tokens;
+    }
+    StringBuilder current = new StringBuilder();
+    boolean inSingleQuote = false;
+    boolean inDoubleQuote = false;
+
+    for (int i = 0; i < str.length(); i++) {
+      char c = str.charAt(i);
+      if (c == '\'' && !inDoubleQuote) {
+        inSingleQuote = !inSingleQuote;
+        continue;
+      } else if (c == '"' && !inSingleQuote) {
+        inDoubleQuote = !inDoubleQuote;
+        continue;
+      } else if (c == delimiter && !inSingleQuote && !inDoubleQuote) {
+        tokens.add(current.toString().trim());
+        current.setLength(0);
+        continue;
+      }
+      current.append(c);
+    }
+    if (current.length() > 0) {
+      tokens.add(current.toString().trim());
+    }
+    return tokens;
+  }
+}
diff --git 
a/plugins/misc/projects/src/main/java/org/apache/hop/projects/util/ProjectsUtil.java
 
b/plugins/misc/projects/src/main/java/org/apache/hop/projects/util/ProjectsUtil.java
index 020cef141d..8132cfe2f0 100644
--- 
a/plugins/misc/projects/src/main/java/org/apache/hop/projects/util/ProjectsUtil.java
+++ 
b/plugins/misc/projects/src/main/java/org/apache/hop/projects/util/ProjectsUtil.java
@@ -101,6 +101,9 @@ public class ProjectsUtil {
     //
     project.modifyVariables(variables, projectConfig, configurationFiles, 
environmentName);
 
+    ProjectsConfigHelper.applyProjectExportFiles(
+        log, projectConfig.getProjectHome(), variables, null, true, false);
+
     // Re-bind the process-global two-way password encoder from 
project/environment variables
     // (HOP_PASSWORD_ENCODER_PLUGIN, HOP_AES_ENCODER_KEY / 
HOP_AES_ENCODER_KEY_FILE). This resets
     // AES keys between projects and allows falling back to Hop obfuscation 
when unset.
@@ -124,14 +127,32 @@ public class ProjectsUtil {
           "Error initializing the two-way password encoder for project '" + 
projectName + "'", e);
     }
 
-    // Change the metadata provider in the GUI
+    // Point metadata at the project's metadataBaseFolder 
(HOP_METADATA_FOLDER). Do this even when
+    // the caller has no IHasHopMetadataProvider (root CLI mixins): HopRun and 
other subcommands
+    // pick the provider up from HopMetadataInstance.
     //
+    MultiMetadataProvider metadataProvider =
+        HopMetadataUtil.getStandardHopMetadataProvider(variables);
+    if (StringUtils.isNotEmpty(project.getParentProjectName())) {
+      ProjectConfig parentPc = 
config.findProjectConfig(project.getParentProjectName());
+      if (parentPc != null) {
+        ProjectsConfigHelper.applyProjectExportFiles(
+            log, parentPc.getProjectHome(), variables, metadataProvider, 
false, true);
+      }
+    }
+    ProjectsConfigHelper.applyProjectExportFiles(
+        log, projectConfig.getProjectHome(), variables, metadataProvider, 
false, true);
     if (hasHopMetadataProvider != null) {
-      MultiMetadataProvider metadataProvider =
-          HopMetadataUtil.getStandardHopMetadataProvider(variables);
       hasHopMetadataProvider.setMetadataProvider(metadataProvider);
-      HopMetadataInstance.setMetadataProvider(metadataProvider);
-      project.setMetadataProvider(metadataProvider);
+    }
+    HopMetadataInstance.setMetadataProvider(metadataProvider);
+    project.setMetadataProvider(metadataProvider);
+    if (log.isBasic()) {
+      log.logBasic(
+          "Project '"
+              + projectName
+              + "' metadata folder: "
+              + Const.NVL(variables.getVariable(Const.HOP_METADATA_FOLDER), 
""));
     }
 
     // The named VFS connections live in the metadata of this project, so hand 
HopVfs the variables
@@ -184,6 +205,7 @@ public class ProjectsUtil {
         buildAttributesContext(config, projectConfig, projectName, 
environmentName, variables);
     ExtensionPointHandler.callExtensionPoint(
         log, variables, 
HopExtensionPoint.HopProjectEnvironmentAfterEnabled.id, attributesContext);
+    ProjectsConfigHelper.markEnabled(projectName, environmentName);
   }
 
   /**
diff --git 
a/plugins/misc/projects/src/main/java/org/apache/hop/projects/xp/HopGuiStartProjectLoad.java
 
b/plugins/misc/projects/src/main/java/org/apache/hop/projects/xp/HopGuiStartProjectLoad.java
index 1baeba5be3..aaa0b610ac 100644
--- 
a/plugins/misc/projects/src/main/java/org/apache/hop/projects/xp/HopGuiStartProjectLoad.java
+++ 
b/plugins/misc/projects/src/main/java/org/apache/hop/projects/xp/HopGuiStartProjectLoad.java
@@ -29,6 +29,7 @@ import org.apache.hop.history.AuditEvent;
 import org.apache.hop.history.AuditManager;
 import org.apache.hop.projects.config.ProjectsConfig;
 import org.apache.hop.projects.config.ProjectsConfigSingleton;
+import org.apache.hop.projects.config.ProjectsGuiOptionPlugin;
 import org.apache.hop.projects.environment.LifecycleEnvironment;
 import org.apache.hop.projects.gui.ProjectsGuiPlugin;
 import org.apache.hop.projects.project.Project;
@@ -38,6 +39,7 @@ import org.apache.hop.projects.util.ProjectsUtil;
 import org.apache.hop.ui.core.dialog.ErrorDialog;
 import org.apache.hop.ui.core.dialog.MessageBox;
 import org.apache.hop.ui.hopgui.HopGui;
+import org.apache.hop.ui.hopgui.HopGuiCommandLine;
 import org.eclipse.swt.SWT;
 
 @ExtensionPoint(
@@ -61,24 +63,35 @@ public class HopGuiStartProjectLoad implements 
IExtensionPoint {
       if (ProjectsConfigSingleton.getConfig().isEnabled()) {
         logChannelInterface.logBasic("Projects enabled");
 
-        // Build list of candidate projects to try: last used first, then 
default.
-        // If -project= is present in URL/command line args, prefer that 
project first.
+        // Build list of candidate projects to try: CLI/URL first, then last 
used, then default.
         // Defensive: try multiple recent projects so one failing does not 
block startup.
         //
         List<String> candidateNames = new ArrayList<>();
-        for (String arg : hopGui.getCommandLineArguments()) {
-          if (arg != null && arg.startsWith("-project=")) {
-            String projectName = arg.substring("-project=".length()).trim();
-            if (StringUtils.isNotEmpty(projectName)
-                && config.findProjectConfig(projectName) != null
-                && !candidateNames.contains(projectName)) {
-              candidateNames.add(projectName);
-              logChannelInterface.logBasic(
-                  "Using project from URL/arguments: '" + projectName + "'");
-            }
-            break;
+        String cliEnvironment =
+            firstNonEmpty(
+                ProjectsGuiOptionPlugin.getRequestedEnvironmentName(),
+                HopGuiCommandLine.findOption(
+                    hopGui.getCommandLineArguments(), 
HopGuiCommandLine.ENVIRONMENT_OPTION_NAMES));
+        String cliProject =
+            firstNonEmpty(
+                ProjectsGuiOptionPlugin.getRequestedProjectName(),
+                HopGuiCommandLine.findOption(
+                    hopGui.getCommandLineArguments(), 
HopGuiCommandLine.PROJECT_OPTION_NAMES));
+        LifecycleEnvironment cliLifecycleEnvironment = null;
+        if (StringUtils.isNotEmpty(cliEnvironment)) {
+          cliLifecycleEnvironment = config.findEnvironment(cliEnvironment);
+          if (cliLifecycleEnvironment != null
+              && StringUtils.isEmpty(cliProject)
+              && 
StringUtils.isNotEmpty(cliLifecycleEnvironment.getProjectName())) {
+            cliProject = cliLifecycleEnvironment.getProjectName();
           }
         }
+        if (StringUtils.isNotEmpty(cliProject)
+            && config.findProjectConfig(cliProject) != null
+            && !candidateNames.contains(cliProject)) {
+          candidateNames.add(cliProject);
+          logChannelInterface.logBasic("Using project from command line: '" + 
cliProject + "'");
+        }
         List<AuditEvent> auditEvents =
             AuditManager.findEvents(
                 ProjectsUtil.STRING_PROJECTS_AUDIT_GROUP,
@@ -122,20 +135,24 @@ public class HopGuiStartProjectLoad implements 
IExtensionPoint {
             logChannelInterface.logBasic("Enabling project : '" + 
lastProjectName + "'");
 
             LifecycleEnvironment lastEnvironment = null;
-
-            List<AuditEvent> envEvents =
-                AuditManager.findEvents(
-                    ProjectsUtil.STRING_PROJECTS_AUDIT_GROUP,
-                    ProjectsUtil.STRING_ENVIRONMENT_AUDIT_TYPE,
-                    "open",
-                    100,
-                    true);
-
-            for (AuditEvent envEvent : envEvents) {
-              LifecycleEnvironment environment = 
config.findEnvironment(envEvent.getName());
-              if (environment != null && 
lastProjectName.equals(environment.getProjectName())) {
-                lastEnvironment = environment;
-                break;
+            if (cliLifecycleEnvironment != null
+                && 
lastProjectName.equals(cliLifecycleEnvironment.getProjectName())) {
+              lastEnvironment = cliLifecycleEnvironment;
+            } else {
+              List<AuditEvent> envEvents =
+                  AuditManager.findEvents(
+                      ProjectsUtil.STRING_PROJECTS_AUDIT_GROUP,
+                      ProjectsUtil.STRING_ENVIRONMENT_AUDIT_TYPE,
+                      "open",
+                      100,
+                      true);
+
+              for (AuditEvent envEvent : envEvents) {
+                LifecycleEnvironment environment = 
config.findEnvironment(envEvent.getName());
+                if (environment != null && 
lastProjectName.equals(environment.getProjectName())) {
+                  lastEnvironment = environment;
+                  break;
+                }
               }
             }
 
@@ -192,4 +209,16 @@ public class HopGuiStartProjectLoad implements 
IExtensionPoint {
           hopGui.getActiveShell(), "Error", "Error initializing the Projects 
system", e);
     }
   }
+
+  private static String firstNonEmpty(String... values) {
+    if (values == null) {
+      return null;
+    }
+    for (String value : values) {
+      if (StringUtils.isNotEmpty(value)) {
+        return value;
+      }
+    }
+    return null;
+  }
 }
diff --git 
a/plugins/misc/projects/src/test/java/org/apache/hop/projects/config/ProjectsGuiOptionPluginTest.java
 
b/plugins/misc/projects/src/test/java/org/apache/hop/projects/config/ProjectsGuiOptionPluginTest.java
new file mode 100644
index 0000000000..6a3cbd819c
--- /dev/null
+++ 
b/plugins/misc/projects/src/test/java/org/apache/hop/projects/config/ProjectsGuiOptionPluginTest.java
@@ -0,0 +1,151 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *       http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.projects.config;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import org.apache.hop.core.config.HopConfig;
+import org.apache.hop.core.logging.HopLogStore;
+import org.apache.hop.core.logging.LogChannel;
+import org.apache.hop.core.variables.Variables;
+import org.apache.hop.metadata.api.IHasHopMetadataProvider;
+import org.apache.hop.metadata.serializer.multi.MultiMetadataProvider;
+import org.apache.hop.projects.util.ProjectsConfigHelper;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+class ProjectsGuiOptionPluginTest {
+
+  @TempDir Path tempRoot;
+
+  @BeforeAll
+  static void beforeAll() {
+    HopLogStore.init();
+  }
+
+  @BeforeEach
+  void setUp() {
+    ProjectsGuiOptionPlugin.clearRequested();
+    ProjectsConfigHelper.clearSessionRegisteredProjects();
+  }
+
+  @AfterEach
+  void tearDown() {
+    ProjectsGuiOptionPlugin.clearRequested();
+    ProjectsConfigHelper.clearSessionRegisteredProjects();
+    ProjectsConfig config = ProjectsConfigSingleton.getConfig();
+    config.removeProjectConfig("gui-proj");
+    HopConfig.setInMemoryMode(false);
+  }
+
+  @Test
+  void handleOptionWithoutProjectDoesNotRememberDefault() throws Exception {
+    ProjectsGuiOptionPlugin plugin = new ProjectsGuiOptionPlugin();
+    plugin.handleOption(LogChannel.GENERAL, new MetadataHolder(), new 
Variables());
+
+    assertNull(ProjectsGuiOptionPlugin.getRequestedProjectName());
+    assertNull(ProjectsGuiOptionPlugin.getRequestedEnvironmentName());
+  }
+
+  @Test
+  void handleOptionRemembersProjectForGuiStartup() throws Exception {
+    Path projectDir = tempRoot.resolve("gui-proj");
+    Files.createDirectories(projectDir);
+    Files.writeString(
+        projectDir.resolve(ProjectsConfig.DEFAULT_PROJECT_CONFIG_FILENAME),
+        "{\n  \"metadataBaseFolder\" : \"${PROJECT_HOME}/metadata\"\n}\n",
+        StandardCharsets.UTF_8);
+
+    ProjectsGuiOptionPlugin plugin = new ProjectsGuiOptionPlugin();
+    plugin.setProjectLocations(new String[] {"gui-proj=" + 
projectDir.toAbsolutePath()});
+    plugin.setProjectOption("gui-proj");
+
+    plugin.handleOption(LogChannel.GENERAL, null, new Variables());
+
+    assertEquals("gui-proj", 
ProjectsGuiOptionPlugin.getRequestedProjectName());
+    assertNull(ProjectsGuiOptionPlugin.getRequestedEnvironmentName());
+  }
+
+  @Test
+  void handleOptionRemembersProjectFromLocationsWithoutMinusJ() throws 
Exception {
+    Path projectDir = tempRoot.resolve("gui-proj");
+    Files.createDirectories(projectDir);
+    Files.writeString(
+        projectDir.resolve(ProjectsConfig.DEFAULT_PROJECT_CONFIG_FILENAME),
+        "{\n  \"metadataBaseFolder\" : \"${PROJECT_HOME}/metadata\"\n}\n",
+        StandardCharsets.UTF_8);
+
+    ProjectsGuiOptionPlugin plugin = new ProjectsGuiOptionPlugin();
+    plugin.setProjectLocations(new String[] {"gui-proj=" + 
projectDir.toAbsolutePath()});
+    plugin.handleOption(LogChannel.GENERAL, null, new Variables());
+
+    assertEquals("gui-proj", 
ProjectsGuiOptionPlugin.getRequestedProjectName());
+  }
+
+  @Test
+  void handleOptionRemembersEnvironmentForGuiStartup() throws Exception {
+    Path projectDir = tempRoot.resolve("gui-proj");
+    Files.createDirectories(projectDir);
+    Files.writeString(
+        projectDir.resolve(ProjectsConfig.DEFAULT_PROJECT_CONFIG_FILENAME),
+        "{\n  \"metadataBaseFolder\" : \"${PROJECT_HOME}/metadata\"\n}\n",
+        StandardCharsets.UTF_8);
+
+    ProjectsConfigSingleton.getConfig()
+        .addProjectConfig(
+            new org.apache.hop.projects.project.ProjectConfig(
+                "gui-proj",
+                projectDir.toAbsolutePath().toString(),
+                ProjectsConfig.DEFAULT_PROJECT_CONFIG_FILENAME));
+    org.apache.hop.projects.environment.LifecycleEnvironment env =
+        new org.apache.hop.projects.environment.LifecycleEnvironment(
+            "gui-prod", "test", "gui-proj", java.util.List.of());
+    ProjectsConfigSingleton.getConfig().addEnvironment(env);
+
+    try {
+      ProjectsGuiOptionPlugin plugin = new ProjectsGuiOptionPlugin();
+      plugin.setEnvironmentOption("gui-prod");
+      plugin.handleOption(LogChannel.GENERAL, null, new Variables());
+
+      assertEquals("gui-prod", 
ProjectsGuiOptionPlugin.getRequestedEnvironmentName());
+    } finally {
+      ProjectsConfigSingleton.getConfig().removeEnvironment("gui-prod");
+    }
+  }
+
+  private static final class MetadataHolder implements IHasHopMetadataProvider 
{
+    private MultiMetadataProvider provider;
+
+    @Override
+    public MultiMetadataProvider getMetadataProvider() {
+      return provider;
+    }
+
+    @Override
+    public void setMetadataProvider(MultiMetadataProvider metadataProvider) {
+      this.provider = metadataProvider;
+    }
+  }
+}
diff --git 
a/plugins/misc/projects/src/test/java/org/apache/hop/projects/util/ProjectsConfigHelperTest.java
 
b/plugins/misc/projects/src/test/java/org/apache/hop/projects/util/ProjectsConfigHelperTest.java
new file mode 100644
index 0000000000..d3ca59f2f2
--- /dev/null
+++ 
b/plugins/misc/projects/src/test/java/org/apache/hop/projects/util/ProjectsConfigHelperTest.java
@@ -0,0 +1,457 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *       http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.projects.util;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.File;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.List;
+import java.util.stream.Stream;
+import java.util.zip.ZipEntry;
+import java.util.zip.ZipOutputStream;
+import org.apache.hop.core.Const;
+import org.apache.hop.core.config.HopConfig;
+import org.apache.hop.core.logging.HopLogStore;
+import org.apache.hop.core.logging.LogChannel;
+import org.apache.hop.core.variables.IVariables;
+import org.apache.hop.core.variables.Variables;
+import org.apache.hop.metadata.api.IHasHopMetadataProvider;
+import org.apache.hop.metadata.serializer.multi.MultiMetadataProvider;
+import org.apache.hop.metadata.util.HopMetadataInstance;
+import org.apache.hop.metadata.util.HopMetadataUtil;
+import org.apache.hop.projects.config.ProjectsConfig;
+import org.apache.hop.projects.config.ProjectsConfigSingleton;
+import org.apache.hop.projects.config.ProjectsGuiOptionPlugin;
+import org.apache.hop.projects.config.ProjectsOptionPlugin;
+import org.apache.hop.projects.config.ProjectsRunOptionPlugin;
+import org.apache.hop.projects.environment.LifecycleEnvironment;
+import org.apache.hop.projects.project.Project;
+import org.apache.hop.projects.project.ProjectConfig;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+public class ProjectsConfigHelperTest {
+
+  private Path tempRoot;
+  private final List<String> registeredProjects = new ArrayList<>();
+  private final List<String> registeredEnvironments = new ArrayList<>();
+  private MultiMetadataProvider previousMetadataProvider;
+
+  @BeforeAll
+  public static void beforeAll() {
+    HopLogStore.init();
+  }
+
+  @BeforeEach
+  public void setUp() throws Exception {
+    tempRoot = Files.createTempDirectory("hop-in-memory-test");
+    previousMetadataProvider = HopMetadataInstance.getMetadataProvider();
+  }
+
+  @AfterEach
+  public void tearDown() throws Exception {
+    ProjectsConfig config = ProjectsConfigSingleton.getConfig();
+    for (String p : registeredProjects) {
+      config.removeProjectConfig(p);
+    }
+    registeredProjects.clear();
+    for (String e : registeredEnvironments) {
+      config.removeEnvironment(e);
+    }
+    registeredEnvironments.clear();
+    ProjectsConfigHelper.clearSessionRegisteredProjects();
+    ProjectsGuiOptionPlugin.clearRequested();
+    HopMetadataInstance.setMetadataProvider(previousMetadataProvider);
+
+    if (tempRoot != null && Files.exists(tempRoot)) {
+      try (Stream<Path> walk = Files.walk(tempRoot)) {
+        
walk.sorted(Comparator.reverseOrder()).map(Path::toFile).forEach(File::delete);
+      }
+      tempRoot = null;
+    }
+    HopConfig.setInMemoryMode(false);
+  }
+
+  private static final class TestMetadataHolder implements 
IHasHopMetadataProvider {
+    private MultiMetadataProvider metadataProvider;
+
+    @Override
+    public MultiMetadataProvider getMetadataProvider() {
+      return metadataProvider;
+    }
+
+    @Override
+    public void setMetadataProvider(MultiMetadataProvider metadataProvider) {
+      this.metadataProvider = metadataProvider;
+    }
+  }
+
+  @Test
+  public void testInMemoryMode() throws Exception {
+    HopConfig.setInMemoryMode(true);
+    assertTrue(HopConfig.isInMemoryMode());
+
+    // Saving option should not throw and should not write to disk
+    HopConfig.getInstance().saveOption("test_in_memory_key", "test_val");
+    assertEquals("test_val", HopConfig.readOption("test_in_memory_key"));
+    HopConfig.getInstance().saveToFile();
+  }
+
+  @Test
+  public void testNormalizeProjectHome() {
+    IVariables variables = new Variables();
+    variables.setVariable("MY_DIR", "/projects/test");
+
+    String normalDir = ProjectsConfigHelper.normalizeProjectHome("${MY_DIR}", 
variables);
+    assertEquals("/projects/test", normalDir);
+
+    String zipPath = "/tmp/my-archive.zip";
+    String normalZip = ProjectsConfigHelper.normalizeProjectHome(zipPath, 
variables);
+    assertTrue(normalZip.startsWith("zip:"));
+    assertTrue(normalZip.endsWith(".zip!/"));
+
+    String alreadyArchive = "zip:file:///path/project.zip!/subfolder";
+    assertEquals(
+        alreadyArchive, 
ProjectsConfigHelper.normalizeProjectHome(alreadyArchive, variables));
+  }
+
+  @Test
+  public void testAddProjectLocationsAndParentChildRelationship() throws 
Exception {
+    Path sharedDir = tempRoot.resolve("shared");
+    Path edwDir = tempRoot.resolve("edw");
+    Files.createDirectories(sharedDir);
+    Files.createDirectories(edwDir);
+
+    // Write minimal config for shared (parent)
+    String sharedConfig =
+        "{\n"
+            + "  \"description\" : \"shared project\",\n"
+            + "  \"metadataBaseFolder\" : \"${PROJECT_HOME}/metadata\",\n"
+            + "  \"parentProjectName\" : null,\n"
+            + "  \"config\" : { \"variables\" : [ ] }\n"
+            + "}\n";
+    Files.writeString(
+        sharedDir.resolve(ProjectsConfig.DEFAULT_PROJECT_CONFIG_FILENAME),
+        sharedConfig,
+        StandardCharsets.UTF_8);
+
+    // Write minimal config for edw (child referencing shared) using 
hop-project.config filename
+    String edwConfig =
+        "{\n"
+            + "  \"description\" : \"edw project\",\n"
+            + "  \"metadataBaseFolder\" : \"${PROJECT_HOME}/metadata\",\n"
+            + "  \"parentProjectName\" : \"shared\",\n"
+            + "  \"config\" : { \"variables\" : [ ] }\n"
+            + "}\n";
+    Files.writeString(edwDir.resolve("hop-project.config"), edwConfig, 
StandardCharsets.UTF_8);
+
+    IVariables variables = new Variables();
+    String locParam = "shared=" + sharedDir.toAbsolutePath() + ",edw=" + 
edwDir.toAbsolutePath();
+
+    List<String> registered =
+        ProjectsConfigHelper.addProjectLocations(
+            LogChannel.GENERAL, variables, new String[] {locParam});
+    registeredProjects.addAll(registered);
+
+    assertEquals(2, registered.size());
+    assertTrue(registered.contains("shared"));
+    assertTrue(registered.contains("edw"));
+
+    ProjectsConfig config = ProjectsConfigSingleton.getConfig();
+    ProjectConfig sharedPc = config.findProjectConfig("shared");
+    assertNotNull(sharedPc);
+    ProjectConfig edwPc = config.findProjectConfig("edw");
+    assertNotNull(edwPc);
+    assertEquals("hop-project.config", edwPc.getConfigFilename());
+    assertFalse(sharedPc.isReadOnly());
+    assertFalse(edwPc.isReadOnly());
+
+    // Verify leaf project resolution correctly identifies edw (child) as leaf
+    String leaf = ProjectsConfigHelper.findLeafProject(registered, config, 
variables);
+    assertEquals("edw", leaf);
+
+    // Load edw and verify parentProjectName resolves shared
+    Project edwProject = edwPc.loadProject(variables);
+    assertEquals("shared", edwProject.getParentProjectName());
+  }
+
+  @Test
+  public void testAddEnvironments() throws Exception {
+    Path edwDir = tempRoot.resolve("edw");
+    Files.createDirectories(edwDir);
+    Path confFile = tempRoot.resolve("edw-production.json");
+    Files.writeString(confFile, "{}", StandardCharsets.UTF_8);
+
+    ProjectConfig edwPc =
+        new ProjectConfig("edw", edwDir.toAbsolutePath().toString(), 
"project-config.json");
+    ProjectsConfigSingleton.getConfig().addProjectConfig(edwPc);
+    registeredProjects.add("edw");
+
+    IVariables variables = new Variables();
+    String[] envDefs = new String[] {"edw-prod=" + confFile.toAbsolutePath()};
+
+    ProjectsConfigHelper.addEnvironments(LogChannel.GENERAL, variables, 
envDefs, List.of("edw"));
+    registeredEnvironments.add("edw-prod");
+
+    LifecycleEnvironment env = 
ProjectsConfigSingleton.getConfig().findEnvironment("edw-prod");
+    assertNotNull(env);
+    assertEquals("edw-prod", env.getName());
+    assertEquals("edw", env.getProjectName());
+    assertEquals(1, env.getConfigurationFiles().size());
+    assertEquals(confFile.toAbsolutePath().toString(), 
env.getConfigurationFiles().get(0));
+  }
+
+  @Test
+  public void testProjectExportZipHandling() throws Exception {
+    Path zipFile = tempRoot.resolve("export.zip");
+
+    String projConfig =
+        "{\n"
+            + "  \"description\" : \"exported project\",\n"
+            + "  \"metadataBaseFolder\" : \"${PROJECT_HOME}/metadata\",\n"
+            + "  \"parentProjectName\" : null,\n"
+            + "  \"config\" : { \"variables\" : [ ] }\n"
+            + "}\n";
+
+    String variablesJson = "{\"MY_EXPORTED_VAR\":\"hello_world\"}";
+    String metadataJson = "{}";
+
+    try (ZipOutputStream zos = new 
ZipOutputStream(Files.newOutputStream(zipFile))) {
+      // Add project-config.json
+      zos.putNextEntry(new 
ZipEntry(ProjectsConfig.DEFAULT_PROJECT_CONFIG_FILENAME));
+      zos.write(projConfig.getBytes(StandardCharsets.UTF_8));
+      zos.closeEntry();
+
+      // Add variables.json
+      zos.putNextEntry(new ZipEntry("variables.json"));
+      zos.write(variablesJson.getBytes(StandardCharsets.UTF_8));
+      zos.closeEntry();
+
+      // Add metadata.json
+      zos.putNextEntry(new ZipEntry("metadata.json"));
+      zos.write(metadataJson.getBytes(StandardCharsets.UTF_8));
+      zos.closeEntry();
+    }
+
+    IVariables variables = new Variables();
+    String loc = "exported=" + zipFile.toAbsolutePath();
+    List<String> registered =
+        ProjectsConfigHelper.addProjectLocations(LogChannel.GENERAL, 
variables, new String[] {loc});
+    registeredProjects.addAll(registered);
+
+    assertEquals(1, registered.size());
+    assertEquals("exported", registered.get(0));
+
+    ProjectConfig pc = 
ProjectsConfigSingleton.getConfig().findProjectConfig("exported");
+    assertNotNull(pc);
+    assertTrue(pc.isReadOnly());
+
+    MultiMetadataProvider metadataProvider =
+        HopMetadataUtil.getStandardHopMetadataProvider(variables);
+    int initialProvidersCount = metadataProvider.getProviders().size();
+
+    ProjectsConfigHelper.applyProjectExportFiles(
+        LogChannel.GENERAL, pc.getProjectHome(), variables, metadataProvider);
+
+    assertEquals("hello_world", variables.getVariable("MY_EXPORTED_VAR"));
+    assertEquals(initialProvidersCount + 1, 
metadataProvider.getProviders().size());
+  }
+
+  @Test
+  public void testProjectsOptionPlugin() throws Exception {
+    Path projectDir = tempRoot.resolve("my-proj");
+    Files.createDirectories(projectDir);
+    Path confFile = tempRoot.resolve("my-conf.json");
+    Files.writeString(
+        confFile,
+        "{\n"
+            + "  \"description\" : \"my environment config\",\n"
+            + "  \"variables\" : [ {\"name\" : \"TEST_ENV_VAR\", \"value\" : 
\"test_val\"} ]\n"
+            + "}\n",
+        StandardCharsets.UTF_8);
+
+    String projectConfig =
+        "{\n"
+            + "  \"description\" : \"my test project\",\n"
+            + "  \"metadataBaseFolder\" : \"${PROJECT_HOME}/metadata\",\n"
+            + "  \"parentProjectName\" : null,\n"
+            + "  \"config\" : { \"variables\" : [ ] }\n"
+            + "}\n";
+    Files.writeString(
+        projectDir.resolve(ProjectsConfig.DEFAULT_PROJECT_CONFIG_FILENAME),
+        projectConfig,
+        StandardCharsets.UTF_8);
+
+    ProjectsOptionPlugin plugin = new ProjectsOptionPlugin();
+    plugin.setProjectLocations(new String[] {"my-proj=" + 
projectDir.toAbsolutePath()});
+    plugin.setEnvironments(new String[] {"my-env=" + 
confFile.toAbsolutePath()});
+    plugin.setEnvironmentOption("my-env");
+
+    IVariables variables = new Variables();
+    plugin.handleOption(LogChannel.GENERAL, null, variables);
+
+    registeredProjects.add("my-proj");
+    registeredEnvironments.add("my-env");
+
+    assertTrue(HopConfig.isInMemoryMode());
+    
assertNotNull(ProjectsConfigSingleton.getConfig().findProjectConfig("my-proj"));
+    
assertNotNull(ProjectsConfigSingleton.getConfig().findEnvironment("my-env"));
+  }
+
+  @Test
+  public void testDetermineActiveProjectFromSessionRegistration() throws 
Exception {
+    Path projectDir = tempRoot.resolve("session-proj");
+    Files.createDirectories(projectDir);
+    Files.writeString(
+        projectDir.resolve(ProjectsConfig.DEFAULT_PROJECT_CONFIG_FILENAME),
+        "{\n  \"metadataBaseFolder\" : \"${PROJECT_HOME}/metadata\"\n}\n",
+        StandardCharsets.UTF_8);
+
+    IVariables variables = new Variables();
+    List<String> registered =
+        ProjectsConfigHelper.addProjectLocations(
+            LogChannel.GENERAL,
+            variables,
+            new String[] {"session-proj=" + projectDir.toAbsolutePath()});
+    registeredProjects.addAll(registered);
+
+    // Subcommand mixin has no --project-locations of its own
+    String determined =
+        ProjectsConfigHelper.determineActiveProject(null, null, List.of(), 
variables);
+    assertEquals("session-proj", determined);
+  }
+
+  @Test
+  public void testRootThenRunMixinUsesProjectMetadataFolder() throws Exception 
{
+    Path projectDir = tempRoot.resolve("ttt");
+    Path metadataDir = 
projectDir.resolve("metadata").resolve("pipeline-run-configuration");
+    Files.createDirectories(metadataDir);
+    Files.writeString(
+        projectDir.resolve(ProjectsConfig.DEFAULT_PROJECT_CONFIG_FILENAME),
+        "{\n"
+            + "  \"description\" : \"in-memory project\",\n"
+            + "  \"metadataBaseFolder\" : \"${PROJECT_HOME}/metadata\",\n"
+            + "  \"parentProjectName\" : null,\n"
+            + "  \"config\" : { \"variables\" : [ ] }\n"
+            + "}\n",
+        StandardCharsets.UTF_8);
+    Files.writeString(
+        metadataDir.resolve("local.json"),
+        "{ \"name\" : \"local\", \"engine\" : \"local\" }\n",
+        StandardCharsets.UTF_8);
+
+    // Root mixin: hop --project-locations ttt=/path run ...
+    ProjectsOptionPlugin rootPlugin = new ProjectsOptionPlugin();
+    rootPlugin.setProjectLocations(new String[] {"ttt=" + 
projectDir.toAbsolutePath()});
+    IVariables variables = new Variables();
+    rootPlugin.handleOption(LogChannel.GENERAL, null, variables);
+    registeredProjects.add("ttt");
+
+    String metadataFolder = variables.getVariable(Const.HOP_METADATA_FOLDER);
+    assertNotNull(metadataFolder);
+    assertTrue(
+        metadataFolder.replace('\\', '/').contains("ttt")
+            && metadataFolder.replace('\\', '/').contains("metadata"),
+        "HOP_METADATA_FOLDER should point at the project metadata: " + 
metadataFolder);
+
+    MultiMetadataProvider instanceProvider = 
HopMetadataInstance.getMetadataProvider();
+    assertNotNull(instanceProvider);
+    assertTrue(
+        instanceProvider.getDescription().contains("metadata"), 
instanceProvider.getDescription());
+
+    // Run mixin: no --project-locations on the subcommand, but the root 
already registered ttt
+    TestMetadataHolder runHolder = new TestMetadataHolder();
+    
runHolder.setMetadataProvider(HopMetadataUtil.getStandardHopMetadataProvider(new
 Variables()));
+    ProjectsOptionPlugin runPlugin = new ProjectsRunOptionPlugin();
+    runPlugin.handleOption(LogChannel.GENERAL, runHolder, variables);
+
+    assertNotNull(runHolder.getMetadataProvider());
+    String runDescription = runHolder.getMetadataProvider().getDescription();
+    assertTrue(
+        runDescription.contains("ttt") && runDescription.contains("metadata"),
+        "run metadata provider should use the project folder: " + 
runDescription);
+  }
+
+  @Test
+  public void testBareFolderDoesNotRegisterEverySubdirectory() throws 
Exception {
+    Path projectDir = tempRoot.resolve("plain-proj");
+    Files.createDirectories(projectDir.resolve(".git"));
+    Files.createDirectories(projectDir.resolve("datasets"));
+    Files.createDirectories(projectDir.resolve("metadata"));
+    Files.writeString(
+        projectDir.resolve(ProjectsConfig.DEFAULT_PROJECT_CONFIG_FILENAME),
+        "{ \"metadataBaseFolder\" : \"${PROJECT_HOME}/metadata\" }\n",
+        StandardCharsets.UTF_8);
+
+    IVariables variables = new Variables();
+    List<String> registered =
+        ProjectsConfigHelper.addProjectLocations(
+            LogChannel.GENERAL, variables, new String[] 
{projectDir.toAbsolutePath().toString()});
+    registeredProjects.addAll(registered);
+
+    assertEquals(1, registered.size());
+    assertEquals("plain-proj", registered.get(0));
+    assertNull(ProjectsConfigSingleton.getConfig().findProjectConfig(".git"));
+    
assertNull(ProjectsConfigSingleton.getConfig().findProjectConfig("metadata"));
+    
assertNull(ProjectsConfigSingleton.getConfig().findProjectConfig("datasets"));
+  }
+
+  @Test
+  public void testDetectConfigFilenameReturnsNullWhenMissing() throws 
Exception {
+    Path emptyDir = tempRoot.resolve("empty");
+    Files.createDirectories(emptyDir);
+    assertNull(
+        ProjectsConfigHelper.detectConfigFilename(
+            emptyDir.toAbsolutePath().toString(), new Variables()));
+  }
+
+  @Test
+  public void testJsonExportHomeRequiresExportLayout() throws Exception {
+    Path projectDir = tempRoot.resolve("normal");
+    Files.createDirectories(projectDir.resolve("metadata"));
+    Files.writeString(
+        projectDir.resolve(ProjectsConfig.DEFAULT_PROJECT_CONFIG_FILENAME),
+        "{}\n",
+        StandardCharsets.UTF_8);
+    Files.writeString(
+        projectDir.resolve("variables.json"),
+        "{\"RUNCFG\":\"from-export\"}\n",
+        StandardCharsets.UTF_8);
+
+    IVariables variables = new Variables();
+    assertFalse(
+        
ProjectsConfigHelper.isJsonExportHome(projectDir.toAbsolutePath().toString(), 
variables));
+
+    Path exportDir = tempRoot.resolve("export-only");
+    Files.createDirectories(exportDir);
+    Files.writeString(exportDir.resolve("metadata.json"), "{}\n", 
StandardCharsets.UTF_8);
+    assertTrue(
+        
ProjectsConfigHelper.isJsonExportHome(exportDir.toAbsolutePath().toString(), 
variables));
+  }
+}
diff --git a/ui/src/main/java/org/apache/hop/ui/hopgui/HopCommandGui.java 
b/ui/src/main/java/org/apache/hop/ui/hopgui/HopCommandGui.java
index c78457cb13..565cd11ba2 100644
--- a/ui/src/main/java/org/apache/hop/ui/hopgui/HopCommandGui.java
+++ b/ui/src/main/java/org/apache/hop/ui/hopgui/HopCommandGui.java
@@ -18,14 +18,24 @@
 
 package org.apache.hop.ui.hopgui;
 
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
 import lombok.Getter;
 import lombok.Setter;
+import org.apache.commons.lang3.StringUtils;
 import org.apache.hop.core.Const;
 import org.apache.hop.core.HopVersionProvider;
+import org.apache.hop.core.config.plugin.ConfigPlugin;
+import org.apache.hop.core.config.plugin.IConfigOptions;
 import org.apache.hop.core.exception.HopException;
+import org.apache.hop.core.logging.ILogChannel;
+import org.apache.hop.core.logging.LogChannel;
 import org.apache.hop.core.variables.IVariables;
+import org.apache.hop.hop.Hop;
 import org.apache.hop.hop.plugin.HopCommand;
 import org.apache.hop.hop.plugin.IHopCommand;
+import org.apache.hop.metadata.api.IHasHopMetadataProvider;
 import org.apache.hop.metadata.serializer.multi.MultiMetadataProvider;
 import picocli.CommandLine;
 
@@ -36,24 +46,69 @@ import picocli.CommandLine;
     mixinStandardHelpOptions = true,
     description = "The Hop GUI")
 @HopCommand(id = "gui", description = "The Hop GUI")
-public class HopCommandGui implements Runnable, IHopCommand {
+public class HopCommandGui implements Runnable, IHopCommand, 
IHasHopMetadataProvider {
+
+  @CommandLine.Option(
+      names = {"-f", "--file"},
+      description = "The filename of the workflow or pipeline to open")
+  private String filename;
+
   @CommandLine.Unmatched private String[] unmatchedArguments;
 
+  private CommandLine cmd;
+  private IVariables variables;
+  private MultiMetadataProvider metadataProvider;
+  private ILogChannel log;
+
   public HopCommandGui() {}
 
   @Override
   public void initialize(
       CommandLine cmd, IVariables variables, MultiMetadataProvider 
metadataProvider)
       throws HopException {
-    // Nothing specific
+    this.cmd = cmd;
+    this.variables = variables;
+    this.metadataProvider = metadataProvider;
+    this.log = new LogChannel("HopGui");
+    Hop.addMixinPlugins(cmd, ConfigPlugin.CATEGORY_GUI);
   }
 
   @Override
   public void run() {
-    if (unmatchedArguments == null) {
-      unmatchedArguments = new String[] {};
-    }
     System.setProperty(Const.HOP_PLATFORM_RUNTIME, "GUI");
-    HopGui.main(unmatchedArguments);
+    try {
+      handleMixinOptions();
+    } catch (Exception e) {
+      throw new CommandLine.ExecutionException(cmd, "Error handling Hop GUI 
options", e);
+    }
+    HopGui.main(buildHopGuiArguments());
+  }
+
+  private void handleMixinOptions() throws HopException {
+    if (cmd == null) {
+      return;
+    }
+    Map<String, Object> mixins = cmd.getMixins();
+    for (Object mixin : mixins.values()) {
+      if (mixin instanceof IConfigOptions configOptions) {
+        configOptions.handleOption(log, this, variables);
+      }
+    }
+  }
+
+  String[] buildHopGuiArguments() {
+    List<String> args = new ArrayList<>();
+    if (unmatchedArguments != null) {
+      for (String unmatched : unmatchedArguments) {
+        if (StringUtils.isNotEmpty(unmatched)) {
+          args.add(unmatched);
+        }
+      }
+    }
+    if (StringUtils.isNotEmpty(filename)) {
+      String resolved = HopGuiCommandLine.resolveFile(variables, filename);
+      args.add("-file=" + resolved);
+    }
+    return args.toArray(new String[0]);
   }
 }
diff --git a/ui/src/main/java/org/apache/hop/ui/hopgui/HopGui.java 
b/ui/src/main/java/org/apache/hop/ui/hopgui/HopGui.java
index 8580fbb5d8..88bf432e52 100644
--- a/ui/src/main/java/org/apache/hop/ui/hopgui/HopGui.java
+++ b/ui/src/main/java/org/apache/hop/ui/hopgui/HopGui.java
@@ -844,48 +844,34 @@ public class HopGui
   }
 
   /**
-   * If -file= was passed in command line args (e.g. from Hop Web URL 
?file=...), open that file
-   * once and remove the arg so the URL can later reflect the current tab.
+   * If -file= / --file / -f was passed in command line args (e.g. from Hop 
Web URL ?file=... or
+   * {@code hop gui -f}), open that file once and remove the arg so the URL 
can later reflect the
+   * current tab.
    */
   private void openFileFromCommandLineArgs() {
     List<String> args = getCommandLineArguments();
     if (args == null) {
       return;
     }
-    String filePath = null;
-    for (int i = 0; i < args.size(); i++) {
-      String arg = args.get(i);
-      if (arg != null && arg.startsWith("-file=")) {
-        filePath = arg.substring("-file=".length()).trim();
-        args.remove(i);
-        break;
-      }
-    }
+    String filePath = HopGuiCommandLine.takeOption(args, 
HopGuiCommandLine.FILE_OPTION_NAMES);
     if (StringUtils.isEmpty(filePath)) {
       return;
     }
     try {
-      String resolved = variables.resolve(filePath);
+      String resolved = HopGuiCommandLine.resolveFile(variables, filePath);
       if (StringUtils.isNotEmpty(resolved)) {
         fileDelegate.fileOpen(resolved, true);
       }
     } catch (Exception e) {
-      log.logError("Error opening file from URL '" + filePath + "'", e);
+      log.logError("Error opening file from command line '" + filePath + "'", 
e);
     }
   }
 
-  /** True if command line args contain -file=... (e.g. from Hop Web URL). */
+  /** True if command line args contain a file to open. */
   private boolean hasFileInCommandLineArgs() {
-    List<String> args = getCommandLineArguments();
-    if (args == null) {
-      return false;
-    }
-    for (String arg : args) {
-      if (arg != null && arg.startsWith("-file=")) {
-        return true;
-      }
-    }
-    return false;
+    return StringUtils.isNotEmpty(
+        HopGuiCommandLine.findOption(
+            getCommandLineArguments(), HopGuiCommandLine.FILE_OPTION_NAMES));
   }
 
   private void loadPerspectives() {
diff --git a/ui/src/main/java/org/apache/hop/ui/hopgui/HopGuiCommandLine.java 
b/ui/src/main/java/org/apache/hop/ui/hopgui/HopGuiCommandLine.java
new file mode 100644
index 0000000000..8b36fc7a6c
--- /dev/null
+++ b/ui/src/main/java/org/apache/hop/ui/hopgui/HopGuiCommandLine.java
@@ -0,0 +1,151 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *       http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.ui.hopgui;
+
+import java.util.List;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.commons.vfs2.FileObject;
+import org.apache.hop.core.variables.IVariables;
+import org.apache.hop.core.vfs.HopVfs;
+
+/** Parses Hop GUI command line options in both picocli and {@code 
-name=value} forms. */
+public final class HopGuiCommandLine {
+
+  public static final String[] PROJECT_OPTION_NAMES = {"-j", "--project", 
"-project"};
+  public static final String[] ENVIRONMENT_OPTION_NAMES = {"-e", 
"--environment", "-environment"};
+  public static final String[] FILE_OPTION_NAMES = {"-f", "--file", "-file"};
+
+  private HopGuiCommandLine() {
+    // utility
+  }
+
+  /**
+   * Find an option in the argument list. Accepts {@code -name value}, {@code 
--name value}, and
+   * {@code -name=value} / {@code --name=value}.
+   *
+   * @param args argument list (may be null)
+   * @param names option names including dashes, e.g. {@code -j}, {@code 
--project}
+   * @return the option value, or null when not present
+   */
+  public static String findOption(List<String> args, String... names) {
+    if (args == null || names == null) {
+      return null;
+    }
+    for (int i = 0; i < args.size(); i++) {
+      String arg = args.get(i);
+      if (arg == null) {
+        continue;
+      }
+      for (String name : names) {
+        if (StringUtils.isEmpty(name)) {
+          continue;
+        }
+        if (arg.equals(name)) {
+          if (i + 1 < args.size() && !isFlag(args.get(i + 1))) {
+            return args.get(i + 1);
+          }
+        } else if (arg.startsWith(name + "=")) {
+          String value = arg.substring(name.length() + 1).trim();
+          if (StringUtils.isNotEmpty(value)) {
+            return value;
+          }
+        }
+      }
+    }
+    return null;
+  }
+
+  /**
+   * Find a file option and remove it from the list so later URL/CLI handling 
does not reopen it.
+   *
+   * @param args argument list (modified in place)
+   * @param names option names including dashes
+   * @return the option value, or null when not present
+   */
+  public static String takeOption(List<String> args, String... names) {
+    if (args == null || names == null) {
+      return null;
+    }
+    for (int i = 0; i < args.size(); i++) {
+      String arg = args.get(i);
+      if (arg == null) {
+        continue;
+      }
+      for (String name : names) {
+        if (StringUtils.isEmpty(name)) {
+          continue;
+        }
+        if (arg.equals(name)) {
+          args.remove(i);
+          if (i < args.size() && !isFlag(args.get(i))) {
+            return args.remove(i);
+          }
+          return null;
+        }
+        if (arg.startsWith(name + "=")) {
+          args.remove(i);
+          String value = arg.substring(name.length() + 1).trim();
+          return StringUtils.isNotEmpty(value) ? value : null;
+        }
+      }
+    }
+    return null;
+  }
+
+  /**
+   * Resolve a filename, trying the value as-is and then relative to {@code 
PROJECT_HOME}.
+   *
+   * @param variables variable space (may be null)
+   * @param filePath filename or path
+   * @return a path that exists when possible, otherwise the resolved original 
path
+   */
+  public static String resolveFile(IVariables variables, String filePath) {
+    if (StringUtils.isEmpty(filePath)) {
+      return filePath;
+    }
+    String resolved = variables != null ? variables.resolve(filePath) : 
filePath;
+    if (exists(resolved, variables)) {
+      return resolved;
+    }
+    String projectHome = variables != null ? 
variables.getVariable("PROJECT_HOME") : null;
+    if (StringUtils.isNotEmpty(projectHome)) {
+      String alternative =
+          variables.resolve(projectHome + "/" + filePath.replaceFirst("^\\./", 
""));
+      if (exists(alternative, variables)) {
+        return alternative;
+      }
+    }
+    return resolved;
+  }
+
+  private static boolean exists(String path, IVariables variables) {
+    if (StringUtils.isEmpty(path)) {
+      return false;
+    }
+    try {
+      FileObject fileObject = HopVfs.getFileObject(path, variables);
+      return fileObject.exists();
+    } catch (Exception e) {
+      return false;
+    }
+  }
+
+  private static boolean isFlag(String arg) {
+    return arg != null && arg.startsWith("-");
+  }
+}
diff --git a/ui/src/test/java/org/apache/hop/ui/hopgui/HopCommandGuiTest.java 
b/ui/src/test/java/org/apache/hop/ui/hopgui/HopCommandGuiTest.java
new file mode 100644
index 0000000000..3cb676e778
--- /dev/null
+++ b/ui/src/test/java/org/apache/hop/ui/hopgui/HopCommandGuiTest.java
@@ -0,0 +1,50 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *       http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.ui.hopgui;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import org.apache.hop.core.variables.Variables;
+import org.junit.jupiter.api.Test;
+
+class HopCommandGuiTest {
+
+  @Test
+  void buildHopGuiArgumentsIncludesFile() {
+    HopCommandGui command = new HopCommandGui();
+    command.setVariables(new Variables());
+    command.setFilename("/tmp/pipeline.hpl");
+
+    String[] args = command.buildHopGuiArguments();
+    assertEquals(1, args.length);
+    assertTrue(args[0].startsWith("-file="));
+    assertTrue(args[0].contains("pipeline.hpl"));
+  }
+
+  @Test
+  void buildHopGuiArgumentsPassesUnmatchedThrough() {
+    HopCommandGui command = new HopCommandGui();
+    command.setVariables(new Variables());
+    command.setUnmatchedArguments(new String[] {"-project=samples"});
+
+    String[] args = command.buildHopGuiArguments();
+    assertEquals(1, args.length);
+    assertEquals("-project=samples", args[0]);
+  }
+}
diff --git 
a/ui/src/test/java/org/apache/hop/ui/hopgui/HopGuiCommandLineTest.java 
b/ui/src/test/java/org/apache/hop/ui/hopgui/HopGuiCommandLineTest.java
new file mode 100644
index 0000000000..80ee125efc
--- /dev/null
+++ b/ui/src/test/java/org/apache/hop/ui/hopgui/HopGuiCommandLineTest.java
@@ -0,0 +1,84 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *       http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.ui.hopgui;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.List;
+import org.apache.hop.core.variables.Variables;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+class HopGuiCommandLineTest {
+
+  @TempDir Path folder;
+
+  @Test
+  void findOptionSupportsEqualsAndSeparateValue() {
+    assertEquals(
+        "ttt",
+        HopGuiCommandLine.findOption(List.of("-j", "ttt"), 
HopGuiCommandLine.PROJECT_OPTION_NAMES));
+    assertEquals(
+        "ttt",
+        HopGuiCommandLine.findOption(
+            List.of("--project=ttt"), HopGuiCommandLine.PROJECT_OPTION_NAMES));
+    assertEquals(
+        "ttt",
+        HopGuiCommandLine.findOption(
+            List.of("-project=ttt"), HopGuiCommandLine.PROJECT_OPTION_NAMES));
+    assertEquals(
+        "prod",
+        HopGuiCommandLine.findOption(
+            List.of("-e", "prod"), 
HopGuiCommandLine.ENVIRONMENT_OPTION_NAMES));
+  }
+
+  @Test
+  void findOptionDoesNotMatchProjectLocationsAsProject() {
+    assertNull(
+        HopGuiCommandLine.findOption(
+            List.of("--project-locations=ttt=/tmp/p"), 
HopGuiCommandLine.PROJECT_OPTION_NAMES));
+  }
+
+  @Test
+  void takeOptionRemovesFileFlag() {
+    List<String> args = new ArrayList<>(List.of("-j", "ttt", 
"-file=/tmp/a.hpl", "-x"));
+    String file = HopGuiCommandLine.takeOption(args, 
HopGuiCommandLine.FILE_OPTION_NAMES);
+    assertEquals("/tmp/a.hpl", file);
+    assertEquals(List.of("-j", "ttt", "-x"), args);
+  }
+
+  @Test
+  void resolveFileUsesProjectHomeWhenRelative() throws Exception {
+    Path projectHome = folder.resolve("proj");
+    Files.createDirectories(projectHome);
+    Path pipeline = projectHome.resolve("long-running-test.hpl");
+    Files.writeString(pipeline, "<pipeline/>");
+
+    Variables variables = new Variables();
+    variables.setVariable("PROJECT_HOME", 
projectHome.toAbsolutePath().toString());
+
+    String resolved = HopGuiCommandLine.resolveFile(variables, 
"long-running-test.hpl");
+    assertTrue(resolved.replace('\\', '/').endsWith("long-running-test.hpl"));
+    assertTrue(resolved.contains("proj"));
+  }
+}

Reply via email to