Repository: php/pie (php)

Issue:
Support building from a local directory / non-Packagist source
This would be useful to test if what I've written into `composer.json` is correct and whether the build actually works, before committing to my changes by pushing them into a public repository and submitting them to Packagist.

This includes both the test after the initial pie-ification and before submission to Packagist and also tests after adding or removing configure flags.

see https://phpc.social/@timwolla/113367860412703002
Support source repo from non-Packagist.org repositories
Following on from #79 where I had attempted to add support for Private Packagist for example, but discovered that `php-ext` is not currently stored. Will discuss with the conductors if this can be added.

Pull request description:
Add repository management commands
 * Fixes #79 
 * Fixes #175 

Allows the use of non-Packagist type repos, such as local paths, VCS repos, Private Packagist, etc.

Patch:
diff --git a/.github/docs/Dockerfile b/.github/docs/Dockerfile
index 9ca685a..00d58d5 100644
--- a/.github/docs/Dockerfile
+++ b/.github/docs/Dockerfile
@@ -1,4 +1,4 @@
-FROM ghcr.io/roave/docbooktool:1.16.0 AS builder
+FROM ghcr.io/roave/docbooktool:1.19.0 AS builder
 
 COPY ./.github/docs/templates /docs-src/templates
 COPY ./docs /docs-src/book
diff --git a/.github/docs/templates/online.twig b/.github/docs/templates/online.twig
index efc704a..16845a9 100644
--- a/.github/docs/templates/online.twig
+++ b/.github/docs/templates/online.twig
@@ -5,6 +5,8 @@
     <meta name="viewport" content="width=device-width, initial-scale=1" />
     <title>PIE Documentation</title>
     <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH" crossorigin="anonymous">
+    <link href="https://cdnjs.cloudflare.com/ajax/libs/prism/1.22.0/themes/prism.css" rel="stylesheet" />
+    <link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/5.15.2/css/all.min.css" rel="stylesheet" />
     <style>
         .selected {
             background-color: #7A86B8;
@@ -15,6 +17,41 @@
         .hidden {
             display: none;
         }
+        .markdown-alert {
+            border-left: 0.25em solid black;
+            padding: 1em 1em 0.25em;
+            background-color: #f7f7f7;
+        }
+        .markdown-alert-note {
+            border-left-color: #0d68d5;
+        }
+        .markdown-alert-note .markdown-alert-title {
+            color: #0d68d5;
+        }
+        .markdown-alert-tip {
+            border-left-color: #188337;
+        }
+        .markdown-alert-tip .markdown-alert-title {
+            color: #188337;
+        }
+        .markdown-alert-important {
+            border-left-color: #7844d6;
+        }
+        .markdown-alert-important .markdown-alert-title {
+            color: #7844d6;
+        }
+        .markdown-alert-warning {
+            border-left-color: #a67003;
+        }
+        .markdown-alert-warning .markdown-alert-title {
+            color: #a67003;
+        }
+        .markdown-alert-caution {
+            border-left-color: #d61c28;
+        }
+        .markdown-alert-caution .markdown-alert-title {
+            color: #d61c28;
+        }
     </style>
 </head>
 <body>
@@ -47,6 +84,10 @@
      * @param {string} title
      */
     function loadDocBookNavigation(title) {
+        document.querySelectorAll('table').forEach((table) => {
+            table.classList.add("table");
+        })
+
         /**
          * @param {NodeListOf<HTMLElement>} unselectedListElements
          * @param {HTMLElement} selectedListElement
@@ -184,5 +225,7 @@
 
     loadDocBookNavigation("PIE Documentation");
 </script>
+<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/prism.min.js"></script>
+<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/plugins/autoloader/prism-autoloader.js"></script>
 </body>
 </html>
diff --git a/bin/pie b/bin/pie
index 8844367..2b907bb 100755
--- a/bin/pie
+++ b/bin/pie
@@ -9,6 +9,9 @@ use Php\Pie\Command\BuildCommand;
 use Php\Pie\Command\DownloadCommand;
 use Php\Pie\Command\InfoCommand;
 use Php\Pie\Command\InstallCommand;
+use Php\Pie\Command\RepositoryAddCommand;
+use Php\Pie\Command\RepositoryListCommand;
+use Php\Pie\Command\RepositoryRemoveCommand;
 use Php\Pie\Command\ShowCommand;
 use Php\Pie\Util\PieVersion;
 use Symfony\Component\Console\Application;
@@ -31,6 +34,9 @@ $application->setCommandLoader(new ContainerCommandLoader(
         'install' => InstallCommand::class,
         'info' => InfoCommand::class,
         'show' => ShowCommand::class,
+        'repository:list' => RepositoryListCommand::class,
+        'repository:add' => RepositoryAddCommand::class,
+        'repository:remove' => RepositoryRemoveCommand::class,
     ]
 ));
 
diff --git a/docs/extension-maintainers.md b/docs/extension-maintainers.md
index 7465b12..610728b 100644
--- a/docs/extension-maintainers.md
+++ b/docs/extension-maintainers.md
@@ -137,7 +137,7 @@ specify a default value in the `configure-options` definition.
 The `build-path` setting may be used if your source code is not in the root
 of your repository. For example, if your repository structure is like:
 
-```
+```text
 /
   docs/
   src/
diff --git a/docs/usage.md b/docs/usage.md
index ac4e717..f54e34b 100644
--- a/docs/usage.md
+++ b/docs/usage.md
@@ -37,7 +37,7 @@ COPY --from=ghcr.io/php/pie:bin /pie /usr/bin/pie
 
 Instead of `bin` tag (which represents latest binary-only image) you can also use explicit version (in `x.y.z-bin` format). Use [GitHub registry](https://ghcr.io/php/pie) to find available tags.
 
-> [!IMPORTANT]  
+> [!IMPORTANT]
 > Binary-only images don't include PHP runtime so you can't use them for _running_ PIE. This is just an alternative way of distributing PHAR file, you still need to satisfy PIE's runtime requirements on your own.
 
 #### Example of PIE working in a Dockerfile
@@ -197,7 +197,7 @@ functionality, or to provide paths to libraries not automatically detected.
 In order to determine what configure options are available for an extension,
 you may use `pie info <vendor>/<package>` which will return a list, such as:
 
-```
+```text
 Configure options:
     --enable-some-functionality  (whether to enable some additional functionality provided)
     --with-some-library-name=?  (Path for some-library)
@@ -215,6 +215,45 @@ pie install example/some-extension --with-some-library-name=/path/to/the/lib --e
 
 ### Configuring the INI file
 
-At the moment, PIE does not configure the INI file, although this improvement
-is planned soon. In the meantime, you must enable the extension after installing
-by adding a line such as `extension=foo` to your `php.ini`.
+PIE will automatically try to enable the extension by adding `extension=...` or
+`zend_extension=...` in the appropriate INI file. If you want to disable this
+behaviour, pass the `--skip-enable-extension` flag to your `pie install`
+command. The following techniques are used to attempt to enable the extension:
+
+ * `phpenmod`, if using the deb.sury.org distribution
+ * `docker-php-ext-enable` if using Docker's PHP image
+ * Add a new file to the "additional .ini file" path, if configured
+ * Append to the standard php.ini, if configured
+
+If none of these techniques work, or you used the `--skip-enable-extension`
+flag, PIE will warn you that the extension was not enabled, and will note that
+you must enable the extension yourself.
+
+### Adding non-Packagist.org repositories
+
+Sometimes you may want to install an extension from a package repository other
+than Packagist.org (such as [Private Packagist](https://packagist.com/)), or
+from a local directory. Since PIE is based heavily on Composer, it is possible
+to use some other repository types:
+
+* `pie repository:add [--with-php-config=...] path /path/to/your/local/extension`
+* `pie repository:add [--with-php-config=...] vcs https://github.com/youruser/yourextension`
+* `pie repository:add [--with-php-config=...] composer https://repo.packagist.com/your-private-packagist/`
+* `pie repository:add [--with-php-config=...] composer packagist.org`
+
+The `repository:*` commands all support the optional `--with-php-config` flag
+to allow you to specify which PHP installation to use (for example, if you have
+multiple PHP installations on one machine). The above added repositories can be
+removed too, using the inverse `repository:remove` commands:
+
+* `pie repository:remove [--with-php-config=...] /path/to/your/local/extension`
+* `pie repository:remove [--with-php-config=...] https://github.com/youruser/yourextension`
+* `pie repository:remove [--with-php-config=...] https://repo.packagist.com/your-private-packagist/`
+* `pie repository:remove [--with-php-config=...] packagist.org`
+
+Note you do not need to specify the repository type in `repository:remove`,
+just the URL.
+
+You can list the repositories for the target PHP installation with:
+
+* `pie repository:list [--with-php-config=...]`
diff --git a/features/install-extensions.feature b/features/install-extensions.feature
index 27841d2..20422ed 100644
--- a/features/install-extensions.feature
+++ b/features/install-extensions.feature
@@ -1,4 +1,4 @@
-Feature: Extensions can be installed with Behat
+Feature: Extensions can be installed with PIE
 
   Example: The latest version of an extension can be downloaded
     When I run a command to download the latest version of an extension
diff --git a/features/manage-repositories.feature b/features/manage-repositories.feature
new file mode 100644
index 0000000..6fd69b5
--- /dev/null
+++ b/features/manage-repositories.feature
@@ -0,0 +1,11 @@
+Feature: Package repositories can be managed with PIE
+
+  Example: A package repository can be added
+    Given no repositories have previously been added
+    When I add a package repository
+    Then I should see the package repository can be used by PIE
+
+  Example: A package repository can be removed
+    Given I have previously added a package repository
+    When I remove the package repository
+    Then I should see the package repository is not used by PIE
diff --git a/src/Command/CommandHelper.php b/src/Command/CommandHelper.php
index 6fc1368..1fc4c7a 100644
--- a/src/Command/CommandHelper.php
+++ b/src/Command/CommandHelper.php
@@ -4,7 +4,11 @@ declare(strict_types=1);
 
 namespace Php\Pie\Command;
 
+use Composer\Composer;
 use Composer\Package\Version\VersionParser;
+use Composer\Repository\ComposerRepository;
+use Composer\Repository\PathRepository;
+use Composer\Repository\VcsRepository;
 use Composer\Util\Platform;
 use InvalidArgumentException;
 use Php\Pie\DependencyResolver\Package;
@@ -263,4 +267,44 @@ final class CommandHelper
 
         return $configureOptionsValues;
     }
+
+    public static function listRepositories(Composer $composer, OutputInterface $output): void
+    {
+        $output->writeln('The following repositories are in use for this Target PHP:');
+
+        foreach ($composer->getRepositoryManager()->getRepositories() as $repo) {
+            if ($repo instanceof ComposerRepository) {
+                $repoConfig = $repo->getRepoConfig();
+
+                $repoUrl = array_key_exists('url', $repoConfig) && is_string($repoConfig['url']) && $repoConfig['url'] !== '' ? $repoConfig['url'] : null;
+
+                if ($repoUrl === 'https://repo.packagist.org') {
+                    $output->writeln('  - Packagist');
+                    continue;
+                }
+
+                $output->writeln(sprintf('  - Composer (%s)', $repoUrl ?? 'no url?'));
+                continue;
+            }
+
+            if ($repo instanceof VcsRepository) {
+                /** @psalm-suppress InternalMethod */
+                $output->writeln(sprintf(
+                    '  - VCS Repository (%s)',
+                    $repo->getDriver()?->getUrl() ?? 'no url?',
+                ));
+                continue;
+            }
+
+            if (! $repo instanceof PathRepository) {
+                continue;
+            }
+
+            $repoConfig = $repo->getRepoConfig();
+            $output->writeln(sprintf(
+                '  - Path Repository (%s)',
+                array_key_exists('url', $repoConfig) && is_string($repoConfig['url']) && $repoConfig['url'] !== '' ? $repoConfig['url'] : 'no path?',
+            ));
+        }
+    }
 }
diff --git a/src/Command/RepositoryAddCommand.php b/src/Command/RepositoryAddCommand.php
new file mode 100644
index 0000000..57e60be
--- /dev/null
+++ b/src/Command/RepositoryAddCommand.php
@@ -0,0 +1,97 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Php\Pie\Command;
+
+use Php\Pie\ComposerIntegration\PieComposerFactory;
+use Php\Pie\ComposerIntegration\PieComposerRequest;
+use Php\Pie\ComposerIntegration\PieJsonEditor;
+use Psr\Container\ContainerInterface;
+use Symfony\Component\Console\Attribute\AsCommand;
+use Symfony\Component\Console\Command\Command;
+use Symfony\Component\Console\Input\InputArgument;
+use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Output\OutputInterface;
+use Webmozart\Assert\Assert;
+
+use function realpath;
+use function str_contains;
+
+#[AsCommand(
+    name: 'repository:add',
+    description: 'Add a new repository for packages that PIE can use.',
+)]
+final class RepositoryAddCommand extends Command
+{
+    private const ARG_TYPE = 'type';
+    private const ARG_URL  = 'url';
+
+    private const ALLOWED_TYPES = ['vcs', 'path', 'composer'];
+
+    public function __construct(
+        private readonly ContainerInterface $container,
+    ) {
+        parent::__construct();
+    }
+
+    public function configure(): void
+    {
+        parent::configure();
+
+        CommandHelper::configurePhpConfigOptions($this);
+
+        $this->addArgument(
+            self::ARG_TYPE,
+            InputArgument::REQUIRED,
+            'Specify the type of the repository, e.g. vcs, path, composer',
+        );
+        $this->addArgument(
+            self::ARG_URL,
+            InputArgument::REQUIRED,
+            'Specify the URL of the repository, e.g. a Github/Gitlab URL, a filesystem path, or Private Packagist URL',
+        );
+    }
+
+    public function execute(InputInterface $input, OutputInterface $output): int
+    {
+        $targetPlatform = CommandHelper::determineTargetPlatformFromInputs($input, $output);
+        $pieJsonEditor  = PieJsonEditor::fromTargetPlatform($targetPlatform);
+
+        $type = (string) $input->getArgument(self::ARG_TYPE);
+        /** @psalm-var 'vcs'|'path'|'composer' $type */
+        Assert::inArray($type, self::ALLOWED_TYPES);
+
+        $url = $originalUrl = (string) $input->getArgument(self::ARG_URL);
+
+        if ($type === 'path') {
+            $url = realpath($originalUrl);
+        }
+
+        if ($type === 'composer' && str_contains($url, 'packagist.org')) {
+            // "adding packagist" is really just removing an exclusion
+            $pieJsonEditor
+                ->ensureExists()
+                ->removeRepository('packagist.org');
+        } else {
+            Assert::stringNotEmpty($url, 'Could not resolve ' . $originalUrl . ' to a real path');
+
+            $pieJsonEditor
+                ->ensureExists()
+                ->addRepository($type, $url);
+        }
+
+        CommandHelper::listRepositories(
+            PieComposerFactory::createPieComposer(
+                $this->container,
+                PieComposerRequest::noOperation(
+                    $output,
+                    $targetPlatform,
+                ),
+            ),
+            $output,
+        );
+
+        return 0;
+    }
+}
diff --git a/src/Command/RepositoryListCommand.php b/src/Command/RepositoryListCommand.php
new file mode 100644
index 0000000..a2d6ddd
--- /dev/null
+++ b/src/Command/RepositoryListCommand.php
@@ -0,0 +1,49 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Php\Pie\Command;
+
+use Php\Pie\ComposerIntegration\PieComposerFactory;
+use Php\Pie\ComposerIntegration\PieComposerRequest;
+use Psr\Container\ContainerInterface;
+use Symfony\Component\Console\Attribute\AsCommand;
+use Symfony\Component\Console\Command\Command;
+use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Output\OutputInterface;
+
+#[AsCommand(
+    name: 'repository:list',
+    description: 'List the package repositories that PIE uses.',
+)]
+final class RepositoryListCommand extends Command
+{
+    public function __construct(
+        private readonly ContainerInterface $container,
+    ) {
+        parent::__construct();
+    }
+
+    public function configure(): void
+    {
+        parent::configure();
+
+        CommandHelper::configurePhpConfigOptions($this);
+    }
+
+    public function execute(InputInterface $input, OutputInterface $output): int
+    {
+        CommandHelper::listRepositories(
+            PieComposerFactory::createPieComposer(
+                $this->container,
+                PieComposerRequest::noOperation(
+                    $output,
+                    CommandHelper::determineTargetPlatformFromInputs($input, $output),
+                ),
+            ),
+            $output,
+        );
+
+        return 0;
+    }
+}
diff --git a/src/Command/RepositoryRemoveCommand.php b/src/Command/RepositoryRemoveCommand.php
new file mode 100644
index 0000000..d12f62f
--- /dev/null
+++ b/src/Command/RepositoryRemoveCommand.php
@@ -0,0 +1,79 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Php\Pie\Command;
+
+use Php\Pie\ComposerIntegration\PieComposerFactory;
+use Php\Pie\ComposerIntegration\PieComposerRequest;
+use Php\Pie\ComposerIntegration\PieJsonEditor;
+use Psr\Container\ContainerInterface;
+use Symfony\Component\Console\Attribute\AsCommand;
+use Symfony\Component\Console\Command\Command;
+use Symfony\Component\Console\Input\InputArgument;
+use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Output\OutputInterface;
+use Webmozart\Assert\Assert;
+
+use function str_contains;
+
+#[AsCommand(
+    name: 'repository:remove',
+    description: 'Remove a repository for packages that PIE can use.',
+)]
+final class RepositoryRemoveCommand extends Command
+{
+    private const ARG_URL = 'url';
+
+    public function __construct(
+        private readonly ContainerInterface $container,
+    ) {
+        parent::__construct();
+    }
+
+    public function configure(): void
+    {
+        parent::configure();
+
+        CommandHelper::configurePhpConfigOptions($this);
+
+        $this->addArgument(
+            self::ARG_URL,
+            InputArgument::REQUIRED,
+            'Specify the URL of the repository, e.g. a Github/Gitlab URL, or a filesystem path',
+        );
+    }
+
+    public function execute(InputInterface $input, OutputInterface $output): int
+    {
+        $targetPlatform = CommandHelper::determineTargetPlatformFromInputs($input, $output);
+        $pieJsonEditor  = PieJsonEditor::fromTargetPlatform($targetPlatform);
+
+        $url = (string) $input->getArgument(self::ARG_URL);
+        Assert::stringNotEmpty($url);
+
+        if (str_contains($url, 'packagist.org')) {
+            // "removing packagist" is really just adding an exclusion
+            $pieJsonEditor
+                ->ensureExists()
+                ->excludePackagistOrg();
+        } else {
+            $pieJsonEditor
+                ->ensureExists()
+                ->removeRepository($url);
+        }
+
+        CommandHelper::listRepositories(
+            PieComposerFactory::createPieComposer(
+                $this->container,
+                PieComposerRequest::noOperation(
+                    $output,
+                    $targetPlatform,
+                ),
+            ),
+            $output,
+        );
+
+        return 0;
+    }
+}
diff --git a/src/ComposerIntegration/ComposerIntegrationHandler.php b/src/ComposerIntegration/ComposerIntegrationHandler.php
index d246a49..48d93aa 100644
--- a/src/ComposerIntegration/ComposerIntegrationHandler.php
+++ b/src/ComposerIntegration/ComposerIntegrationHandler.php
@@ -7,7 +7,6 @@ namespace Php\Pie\ComposerIntegration;
 use Composer\Composer;
 use Composer\Filter\PlatformRequirementFilter\PlatformRequirementFilterFactory;
 use Composer\Installer;
-use Composer\Json\JsonManipulator;
 use Php\Pie\DependencyResolver\Package;
 use Php\Pie\DependencyResolver\RequestedPackageAndVersion;
 use Php\Pie\Platform;
@@ -15,8 +14,6 @@ use Php\Pie\Platform\TargetPlatform;
 use Psr\Container\ContainerInterface;
 
 use function file_exists;
-use function file_get_contents;
-use function file_put_contents;
 
 /** @internal This is not public API for PIE, so should not be depended upon unless you accept the risk of BC breaks */
 class ComposerIntegrationHandler
@@ -47,10 +44,11 @@ class ComposerIntegrationHandler
 
         // Write the new requirement to pie.json; because we later essentially just do a `composer install` using that file
         $pieComposerJson        = Platform::getPieJsonFilename($targetPlatform);
-        $originalPieJsonContent = file_get_contents($pieComposerJson);
-        $manipulator            = new JsonManipulator($originalPieJsonContent);
-        $manipulator->addLink('require', $requestedPackageAndVersion->package, $recommendedRequireVersion, true);
-        file_put_contents($pieComposerJson, $manipulator->getContents());
+        $pieJsonEditor          = PieJsonEditor::fromTargetPlatform($targetPlatform);
+        $originalPieJsonContent = $pieJsonEditor->addRequire(
+            $requestedPackageAndVersion->package,
+            $recommendedRequireVersion !== '' ? $recommendedRequireVersion : '*',
+        );
 
         // Refresh the Composer instance so it re-reads the updated pie.json
         $composer = PieComposerFactory::recreatePieComposer($this->container, $composer);
@@ -83,7 +81,7 @@ class ComposerIntegrationHandler
 
         if ($resultCode !== Installer::ERROR_NONE) {
             // Revert composer.json change
-            file_put_contents($pieComposerJson, $originalPieJsonContent);
+            $pieJsonEditor->revert($originalPieJsonContent);
 
             throw ComposerRunFailed::fromExitCode($resultCode);
         }
diff --git a/src/ComposerIntegration/PieComposerFactory.php b/src/ComposerIntegration/PieComposerFactory.php
index d135e61..279348d 100644
--- a/src/ComposerIntegration/PieComposerFactory.php
+++ b/src/ComposerIntegration/PieComposerFactory.php
@@ -16,10 +16,6 @@ use Php\Pie\Platform;
 use Psr\Container\ContainerInterface;
 use Webmozart\Assert\Assert;
 
-use function file_exists;
-use function file_put_contents;
-use function mkdir;
-
 /** @internal This is not public API for PIE, so should not be depended upon unless you accept the risk of BC breaks */
 class PieComposerFactory extends Factory
 {
@@ -52,20 +48,9 @@ class PieComposerFactory extends Factory
         ContainerInterface $container,
         PieComposerRequest $composerRequest,
     ): Composer {
-        $workDir = Platform::getPieWorkingDirectory($composerRequest->targetPlatform);
-
-        if (! file_exists($workDir)) {
-            mkdir($workDir, recursive: true);
-        }
-
         $pieComposer = Platform::getPieJsonFilename($composerRequest->targetPlatform);
 
-        if (! file_exists($pieComposer)) {
-            file_put_contents(
-                $pieComposer,
-                "{\n}\n",
-            );
-        }
+        PieJsonEditor::fromTargetPlatform($composerRequest->targetPlatform)->ensureExists();
 
         $io       = $container->get(QuieterConsoleIO::class);
         $composer = (new PieComposerFactory($container, $composerRequest))->createComposer(
diff --git a/src/ComposerIntegration/PieJsonEditor.php b/src/ComposerIntegration/PieJsonEditor.php
new file mode 100644
index 0000000..973bfbf
--- /dev/null
+++ b/src/ComposerIntegration/PieJsonEditor.php
@@ -0,0 +1,155 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Php\Pie\ComposerIntegration;
+
+use Composer\Config\JsonConfigSource;
+use Composer\Json\JsonFile;
+use Php\Pie\Platform;
+use Php\Pie\Platform\TargetPlatform;
+use RuntimeException;
+
+use function file_exists;
+use function file_get_contents;
+use function file_put_contents;
+use function mkdir;
+use function rtrim;
+use function sprintf;
+use function str_replace;
+
+/** @internal This is not public API for PIE, so should not be depended upon unless you accept the risk of BC breaks */
+class PieJsonEditor
+{
+    public const PACKAGIST_ORG_KEY = 'packagist.org';
+
+    public function __construct(
+        private readonly string $pieJsonFilename,
+        private readonly string $pieWorkingDirectory,
+    ) {
+    }
+
+    public static function fromTargetPlatform(TargetPlatform $targetPlatform): self
+    {
+        return new self(
+            Platform::getPieJsonFilename($targetPlatform),
+            Platform::getPieWorkingDirectory($targetPlatform),
+        );
+    }
+
+    /**
+     * Ensure the given `pie.json` exists; if it does not exist, create an
+     * empty but valid `pie.json`.
+     */
+    public function ensureExists(): self
+    {
+        if (file_exists($this->pieJsonFilename)) {
+            return $this;
+        }
+
+        if (! file_exists($this->pieWorkingDirectory)) {
+            mkdir($this->pieWorkingDirectory, recursive: true);
+        }
+
+        if (file_put_contents($this->pieJsonFilename, "{\n}\n") === false) {
+            throw new RuntimeException(sprintf(
+                'Failed to create pie.json in %s (working directory: %s)',
+                $this->pieJsonFilename,
+                $this->pieWorkingDirectory,
+            ));
+        }
+
+        return $this;
+    }
+
+    /**
+     * Add a package to the `require` section of the given `pie.json`. Returns
+     * the original `pie.json` content, in case it needs to be restored later.
+     *
+     * @param non-empty-string $package
+     * @param non-empty-string $version
+     */
+    public function addRequire(string $package, string $version): string
+    {
+        $originalPieJsonContent = file_get_contents($this->pieJsonFilename);
+
+        (new JsonConfigSource(
+            new JsonFile(
+                $this->pieJsonFilename,
+            ),
+        ))->addLink('require', $package, $version);
+
+        return $originalPieJsonContent;
+    }
+
+    public function revert(string $originalPieJsonContent): void
+    {
+        file_put_contents($this->pieJsonFilename, $originalPieJsonContent);
+    }
+
+    public function excludePackagistOrg(): string
+    {
+        $originalPieJsonContent = file_get_contents($this->pieJsonFilename);
+
+        (new JsonConfigSource(
+            new JsonFile(
+                $this->pieJsonFilename,
+            ),
+        ))
+            ->addRepository(self::PACKAGIST_ORG_KEY, false);
+
+        return $originalPieJsonContent;
+    }
+
+    /**
+     * Add a repository to the given `pie.json`. Returns the original
+     * `pie.json` content, in case it needs to be restored later.
+     *
+     * @param 'vcs'|'path'|'composer' $type
+     * @param non-empty-string        $url
+     */
+    public function addRepository(
+        string $type,
+        string $url,
+    ): string {
+        $originalPieJsonContent = file_get_contents($this->pieJsonFilename);
+
+        (new JsonConfigSource(
+            new JsonFile(
+                $this->pieJsonFilename,
+            ),
+        ))
+            ->addRepository($this->normaliseRepositoryName($url), [
+                'type' => $type,
+                'url' => $url,
+            ]);
+
+        return $originalPieJsonContent;
+    }
+
+    /**
+     * Remove a repository from the given `pie.json`. Returns the original
+     * `pie.json` content, in case it needs to be restored later.
+     *
+     * @param non-empty-string $name
+     */
+    public function removeRepository(
+        string $name,
+    ): string {
+        $originalPieJsonContent = file_get_contents($this->pieJsonFilename);
+
+        (new JsonConfigSource(
+            new JsonFile(
+                $this->pieJsonFilename,
+            ),
+        ))
+            ->removeRepository($this->normaliseRepositoryName($name));
+
+        return $originalPieJsonContent;
+    }
+
+    private function normaliseRepositoryName(string $url): string
+    {
+        return rtrim(str_replace('\\', '/', $url), '/');
+    }
+}
diff --git a/src/Container.php b/src/Container.php
index 28e1f88..6f8ef41 100644
--- a/src/Container.php
+++ b/src/Container.php
@@ -13,6 +13,9 @@ use Php\Pie\Command\BuildCommand;
 use Php\Pie\Command\DownloadCommand;
 use Php\Pie\Command\InfoCommand;
 use Php\Pie\Command\InstallCommand;
+use Php\Pie\Command\RepositoryAddCommand;
+use Php\Pie\Command\RepositoryListCommand;
+use Php\Pie\Command\RepositoryRemoveCommand;
 use Php\Pie\Command\ShowCommand;
 use Php\Pie\ComposerIntegration\MinimalHelperSet;
 use Php\Pie\ComposerIntegration\QuieterConsoleIO;
@@ -46,6 +49,9 @@ final class Container
         $container->singleton(InstallCommand::class);
         $container->singleton(InfoCommand::class);
         $container->singleton(ShowCommand::class);
+        $container->singleton(RepositoryListCommand::class);
+        $container->singleton(RepositoryAddCommand::class);
+        $container->singleton(RepositoryRemoveCommand::class);
 
         $container->singleton(QuieterConsoleIO::class, static function (ContainerInterface $container): QuieterConsoleIO {
             return new QuieterConsoleIO(


Test patch:
diff --git a/test/behaviour/CliContext.php b/test/behaviour/CliContext.php
index 801dbe9..31b2f9a 100644
--- a/test/behaviour/CliContext.php
+++ b/test/behaviour/CliContext.php
@@ -146,4 +146,43 @@ class CliContext implements Context
     {
         $this->phpArguments = ['-d', 'extension=invalid_extension'];
     }
+
+    #[When('I add a package repository')]
+    public function iAddAPackageRepository(): void
+    {
+        $this->runPieCommand(['repository:add', 'path', __DIR__]);
+    }
+
+    #[Then('I should see the package repository can be used by PIE')]
+    public function iShouldSeeThePackageRepositoryCanBeUsedByPie(): void
+    {
+        Assert::notNull($this->output);
+        Assert::contains($this->output, 'Path Repository (' . __DIR__ . ')');
+    }
+
+    #[Given('I have previously added a package repository')]
+    public function iHavePreviouslyAddedAPackageRepository(): void
+    {
+        $this->noRepositoriesHavePreviouslyBeenAdded();
+        $this->iAddAPackageRepository();
+    }
+
+    #[Given('no repositories have previously been added')]
+    public function noRepositoriesHavePreviouslyBeenAdded(): void
+    {
+        $this->iRemoveThePackageRepository();
+    }
+
+    #[When('I remove the package repository')]
+    public function iRemoveThePackageRepository(): void
+    {
+        $this->runPieCommand(['repository:remove', __DIR__]);
+    }
+
+    #[Then('I should see the package repository is not used by PIE')]
+    public function iShouldSeeThePackageRepositoryIsNotUsedByPie(): void
+    {
+        Assert::notNull($this->output);
+        Assert::notContains($this->output, 'Path repository (' . __DIR__ . ')');
+    }
 }
diff --git a/test/integration/Command/RepositoryManagementCommandsTest.php b/test/integration/Command/RepositoryManagementCommandsTest.php
new file mode 100644
index 0000000..77ab6c5
--- /dev/null
+++ b/test/integration/Command/RepositoryManagementCommandsTest.php
@@ -0,0 +1,146 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Php\PieIntegrationTest\Command;
+
+use Php\Pie\Command\RepositoryAddCommand;
+use Php\Pie\Command\RepositoryListCommand;
+use Php\Pie\Command\RepositoryRemoveCommand;
+use Php\Pie\Container;
+use PHPUnit\Framework\Attributes\CoversClass;
+use PHPUnit\Framework\TestCase;
+use Symfony\Component\Console\Tester\CommandTester;
+
+use function array_filter;
+use function array_map;
+use function array_values;
+use function explode;
+use function str_starts_with;
+use function substr;
+
+use const PHP_EOL;
+
+#[CoversClass(RepositoryListCommand::class)]
+#[CoversClass(RepositoryAddCommand::class)]
+#[CoversClass(RepositoryRemoveCommand::class)]
+final class RepositoryManagementCommandsTest extends TestCase
+{
+    private const EXAMPLE_PATH_REPOSITORY_URL = __DIR__;
+    private const EXAMPLE_VCS_REPOSITORY_URL  = 'https://github.com/asgrim/example-pie-extension';
+    private const PACKAGIST_ORG_URL           = 'packagist.org';
+
+    private CommandTester $listCommand;
+    private CommandTester $addCommand;
+    private CommandTester $removeCommand;
+
+    public function setUp(): void
+    {
+        $this->listCommand   = new CommandTester(Container::factory()->get(RepositoryListCommand::class));
+        $this->addCommand    = new CommandTester(Container::factory()->get(RepositoryAddCommand::class));
+        $this->removeCommand = new CommandTester(Container::factory()->get(RepositoryRemoveCommand::class));
+
+        $this->addCommand->execute([
+            'type' => 'composer',
+            'url' => self::PACKAGIST_ORG_URL,
+        ]);
+        $this->removeCommand->execute(['url' => self::EXAMPLE_PATH_REPOSITORY_URL]);
+        $this->removeCommand->execute(['url' => self::EXAMPLE_VCS_REPOSITORY_URL]);
+        $this->removeCommand->execute(['url' => self::EXAMPLE_VCS_REPOSITORY_URL . '.git']);
+    }
+
+    public function testPathRepositoriesCanBeManaged(): void
+    {
+        $this->assertRepositoryListDisplayed(['Packagist']);
+
+        $this->addCommand->execute([
+            'type' => 'path',
+            'url' => self::EXAMPLE_PATH_REPOSITORY_URL,
+        ]);
+
+        $this->assertRepositoryListDisplayed(
+            [
+                'Path Repository (' . self::EXAMPLE_PATH_REPOSITORY_URL . ')',
+                'Packagist',
+            ],
+        );
+
+        $this->removeCommand->execute(['url' => self::EXAMPLE_PATH_REPOSITORY_URL]);
+        $this->assertRepositoryListDisplayed(['Packagist']);
+    }
+
+    public function testPathRepositoriesWithTrailingSlashesCanBeManaged(): void
+    {
+        $this->assertRepositoryListDisplayed(['Packagist']);
+
+        $this->addCommand->execute([
+            'type' => 'path',
+            'url' => self::EXAMPLE_PATH_REPOSITORY_URL . '/',
+        ]);
+
+        $this->assertRepositoryListDisplayed(
+            [
+                'Path Repository (' . self::EXAMPLE_PATH_REPOSITORY_URL . ')',
+                'Packagist',
+            ],
+        );
+
+        $this->removeCommand->execute(['url' => self::EXAMPLE_PATH_REPOSITORY_URL . '/']);
+        $this->assertRepositoryListDisplayed(['Packagist']);
+    }
+
+    public function testVcsRepositoriesCanBeManaged(): void
+    {
+        $this->assertRepositoryListDisplayed(['Packagist']);
+
+        $this->addCommand->execute([
+            'type' => 'vcs',
+            'url' => self::EXAMPLE_VCS_REPOSITORY_URL,
+        ]);
+
+        $this->assertRepositoryListDisplayed(
+            [
+                'VCS Repository (' . self::EXAMPLE_VCS_REPOSITORY_URL . '.git)',
+                'Packagist',
+            ],
+        );
+
+        $this->removeCommand->execute(['url' => self::EXAMPLE_VCS_REPOSITORY_URL]);
+        $this->assertRepositoryListDisplayed(['Packagist']);
+    }
+
+    public function testPackagistOrgCanBeManaged(): void
+    {
+        $this->assertRepositoryListDisplayed(['Packagist']);
+
+        $this->removeCommand->execute(['url' => self::PACKAGIST_ORG_URL]);
+
+        $this->assertRepositoryListDisplayed([]);
+
+        $this->addCommand->execute([
+            'type' => 'composer',
+            'url' => self::PACKAGIST_ORG_URL,
+        ]);
+        $this->assertRepositoryListDisplayed(['Packagist']);
+    }
+
+    /** @param list<non-empty-string> $expectedRepositories */
+    private function assertRepositoryListDisplayed(array $expectedRepositories): void
+    {
+        $this->listCommand->execute([]);
+        $this->listCommand->assertCommandIsSuccessful();
+
+        $outputString = $this->listCommand->getDisplay();
+
+        self::assertEquals(
+            $expectedRepositories,
+            array_values(array_map(
+                static fn ($line) => substr($line, 4),
+                array_filter(
+                    explode(PHP_EOL, $outputString),
+                    static fn ($line): bool => str_starts_with($line, '  - '),
+                ),
+            )),
+        );
+    }
+}
diff --git a/test/unit/Command/CommandHelperTest.php b/test/unit/Command/CommandHelperTest.php
index 3418374..f70eb93 100644
--- a/test/unit/Command/CommandHelperTest.php
+++ b/test/unit/Command/CommandHelperTest.php
@@ -4,7 +4,13 @@ declare(strict_types=1);
 
 namespace Php\PieUnitTest\Command;
 
+use Composer\Composer;
 use Composer\Package\CompletePackage;
+use Composer\Repository\ComposerRepository;
+use Composer\Repository\PathRepository;
+use Composer\Repository\RepositoryManager;
+use Composer\Repository\Vcs\GitHubDriver;
+use Composer\Repository\VcsRepository;
 use Composer\Util\Platform;
 use InvalidArgumentException;
 use Php\Pie\Command\CommandHelper;
@@ -22,10 +28,13 @@ use Symfony\Component\Console\Input\ArrayInput;
 use Symfony\Component\Console\Input\InputDefinition;
 use Symfony\Component\Console\Input\InputInterface;
 use Symfony\Component\Console\Input\InputOption;
+use Symfony\Component\Console\Output\BufferedOutput;
 use Symfony\Component\Console\Output\NullOutput;
 
 use function array_combine;
 use function array_map;
+use function str_replace;
+use function trim;
 
 #[CoversClass(CommandHelper::class)]
 final class CommandHelperTest extends TestCase
@@ -175,4 +184,48 @@ final class CommandHelperTest extends TestCase
         $this->expectExceptionMessage('The --with-phpize-path=/path/to/phpize cannot be used on Windows.');
         CommandHelper::determineTargetPlatformFromInputs($input, $output);
     }
+
+    public function testListRepositories(): void
+    {
+        $output = new BufferedOutput();
+
+        $packagistRepo = $this->createMock(ComposerRepository::class);
+        $packagistRepo->method('getRepoConfig')->willReturn(['url' => 'https://repo.packagist.org']);
+
+        $privatePackagistRepo = $this->createMock(ComposerRepository::class);
+        $privatePackagistRepo->method('getRepoConfig')->willReturn(['url' => 'https://repo.packagist.com/example']);
+
+        $githubRepoDriver = $this->createMock(GitHubDriver::class);
+        $githubRepoDriver->method('getUrl')->willReturn('https://github.com/php/pie');
+
+        $vcsRepo = $this->createMock(VcsRepository::class);
+        $vcsRepo->method('getDriver')->willReturn($githubRepoDriver);
+
+        $pathRepo = $this->createMock(PathRepository::class);
+        $pathRepo->method('getRepoConfig')->willReturn(['url' => '/path/to/repo']);
+
+        $repoManager = $this->createMock(RepositoryManager::class);
+        $repoManager->method('getRepositories')->willReturn([
+            $packagistRepo,
+            $privatePackagistRepo,
+            $vcsRepo,
+            $pathRepo,
+        ]);
+
+        $composer = $this->createMock(Composer::class);
+        $composer->method('getRepositoryManager')->willReturn($repoManager);
+
+        CommandHelper::listRepositories($composer, $output);
+
+        self::assertSame(
+            str_replace("\r\n", "\n", <<<'OUTPUT'
+            The following repositories are in use for this Target PHP:
+              - Packagist
+              - Composer (https://repo.packagist.com/example)
+              - VCS Repository (https://github.com/php/pie)
+              - Path Repository (/path/to/repo)
+            OUTPUT),
+            str_replace("\r\n", "\n", trim($output->fetch())),
+        );
+    }
 }
diff --git a/test/unit/ComposerIntegration/PieJsonEditorTest.php b/test/unit/ComposerIntegration/PieJsonEditorTest.php
new file mode 100644
index 0000000..29edc04
--- /dev/null
+++ b/test/unit/ComposerIntegration/PieJsonEditorTest.php
@@ -0,0 +1,197 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Php\PieUnitTest\ComposerIntegration;
+
+use Php\Pie\ComposerIntegration\PieJsonEditor;
+use PHPUnit\Framework\Attributes\CoversClass;
+use PHPUnit\Framework\TestCase;
+
+use function dirname;
+use function file_get_contents;
+use function json_decode;
+use function json_encode;
+use function sys_get_temp_dir;
+use function uniqid;
+
+use const DIRECTORY_SEPARATOR;
+
+#[CoversClass(PieJsonEditor::class)]
+final class PieJsonEditorTest extends TestCase
+{
+    public function testCreatingPieJson(): void
+    {
+        $testPieJson = sys_get_temp_dir() . DIRECTORY_SEPARATOR . uniqid('pie_json_test_', true) . '.json';
+
+        self::assertFileDoesNotExist($testPieJson);
+
+        (new PieJsonEditor($testPieJson, dirname($testPieJson)))->ensureExists();
+
+        self::assertFileExists($testPieJson);
+        self::assertSame(
+            $this->normaliseJson("{\n}\n"),
+            $this->normaliseJson(file_get_contents($testPieJson)),
+        );
+    }
+
+    public function testCanAddRequire(): void
+    {
+        $testPieJson = sys_get_temp_dir() . DIRECTORY_SEPARATOR . uniqid('pie_json_test_', true) . '.json';
+
+        $editor = new PieJsonEditor($testPieJson, dirname($testPieJson));
+        $editor->ensureExists();
+
+        $editor->addRequire('foo/bar', '^1.2');
+        self::assertSame(
+            $this->normaliseJson(<<<'EOF'
+            {
+                "require": {
+                    "foo/bar": "^1.2"
+                }
+            }
+            EOF),
+            $this->normaliseJson(file_get_contents($testPieJson)),
+        );
+    }
+
+    public function testCanRevert(): void
+    {
+        $testPieJson = sys_get_temp_dir() . DIRECTORY_SEPARATOR . uniqid('pie_json_test_', true) . '.json';
+
+        $editor = new PieJsonEditor($testPieJson, dirname($testPieJson));
+        $editor->ensureExists();
+        $originalContent = $editor->addRequire('foo/bar', '^1.2');
+        $editor->revert($originalContent);
+        self::assertSame(
+            $this->normaliseJson($originalContent),
+            $this->normaliseJson(file_get_contents($testPieJson)),
+        );
+    }
+
+    public function testCanAddAndRemoveRepositories(): void
+    {
+        $testPieJson = sys_get_temp_dir() . DIRECTORY_SEPARATOR . uniqid('pie_json_test_', true) . '.json';
+
+        $editor = new PieJsonEditor($testPieJson, dirname($testPieJson));
+        $editor->ensureExists();
+
+        $originalContent = $editor->addRepository(
+            'vcs',
+            'https://github.com/php/pie',
+        );
+
+        self::assertSame(
+            $this->normaliseJson("{\n}\n"),
+            $this->normaliseJson($originalContent),
+        );
+
+        $expectedRepoContent = $this->normaliseJson(<<<'EOF'
+            {
+                "repositories": {
+                    "https://github.com/php/pie": {
+                        "type": "vcs",
+                        "url": "https://github.com/php/pie"
+                    }
+                }
+            }
+            EOF);
+
+        self::assertSame(
+            $expectedRepoContent,
+            $this->normaliseJson(file_get_contents($testPieJson)),
+        );
+
+        $originalContent2 = $editor->removeRepository('https://github.com/php/pie');
+        self::assertSame(
+            $expectedRepoContent,
+            $this->normaliseJson($originalContent2),
+        );
+
+        $noRepositoriesContent = $this->normaliseJson(<<<'EOF'
+            {
+                "repositories": {
+                }
+            }
+            EOF);
+
+        self::assertSame(
+            $noRepositoriesContent,
+            $this->normaliseJson(file_get_contents($testPieJson)),
+        );
+
+        $originalContent3 = $editor->excludePackagistOrg();
+        self::assertSame(
+            $noRepositoriesContent,
+            $this->normaliseJson($originalContent3),
+        );
+
+        self::assertSame(
+            $this->normaliseJson(<<<'EOF'
+            {
+                "repositories": {
+                    "packagist.org": false
+                }
+            }
+            EOF),
+            $this->normaliseJson(file_get_contents($testPieJson)),
+        );
+    }
+
+    public function testCanAddAndRemoveWithTrailingSlash(): void
+    {
+        $testPieJson = sys_get_temp_dir() . DIRECTORY_SEPARATOR . uniqid('pie_json_test_', true) . '.json';
+
+        $editor = new PieJsonEditor($testPieJson, dirname($testPieJson));
+        $editor->ensureExists();
+
+        $originalContent = $editor->addRepository(
+            'path',
+            '/pwd/dummy/',
+        );
+
+        self::assertSame(
+            $this->normaliseJson("{\n}\n"),
+            $this->normaliseJson($originalContent),
+        );
+
+        $expectedRepoContent = $this->normaliseJson(<<<'EOF'
+            {
+                "repositories": {
+                    "/pwd/dummy": {
+                        "type": "path",
+                        "url": "/pwd/dummy/"
+                    }
+                }
+            }
+            EOF);
+
+        self::assertSame(
+            $expectedRepoContent,
+            $this->normaliseJson(file_get_contents($testPieJson)),
+        );
+
+        $originalContent2 = $editor->removeRepository('/pwd/dummy/');
+        self::assertSame(
+            $expectedRepoContent,
+            $this->normaliseJson($originalContent2),
+        );
+
+        $noRepositoriesContent = $this->normaliseJson(<<<'EOF'
+            {
+                "repositories": {
+                }
+            }
+            EOF);
+
+        self::assertSame(
+            $noRepositoriesContent,
+            $this->normaliseJson(file_get_contents($testPieJson)),
+        );
+    }
+
+    private function normaliseJson(string $fileContent): string
+    {
+        return json_encode(json_decode($fileContent));
+    }
+}