Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ Initial packages:
- `stellarwp/foundation-log`
- `stellarwp/foundation-pipeline`
- `stellarwp/foundation-wpcli`
- `stellarwp/foundation-cli`

## Namespaces

Expand Down Expand Up @@ -42,6 +43,26 @@ Feature-local interfaces should live in a `Contracts/` folder inside the feature

Shared infrastructure interfaces should live under that shared namespace's `Contracts/` folder, for example `Process/Contracts/ProcessRunner.php`.

Generator commands should be grouped by the `make:*` workflow under `src/Cli/Commands/Make/`, for example `src/Cli/Commands/Make/WPCliCommand.php`. Shared generation infrastructure that is not itself a console command should live under `src/Cli/Generation/`.

Default stubs should live with the package that owns the generated class shape. For example, WP-CLI command stubs live in `src/WPCli/stubs/` because the WPCli package owns the base `Command` API. The CLI package owns resolving, rendering, and writing generated files.

Project-level stub overrides should use `foundation/stubs/<feature>/`, for example `foundation/stubs/wpcli/command.stub`.

When generating classes intended for WordPress projects, use Snake_Case class names and WordPress formatting in the generated stub output, even though Foundation source follows this repository's formatter.

Generators that write references to Foundation classes should detect `extra.strauss.namespace_prefix` from the consuming project's `composer.json` and render those Foundation imports with the configured prefix.

## CLI Tooling Boundary

`stellarwp/foundation-cli` is developer tooling and should normally be installed by consuming projects with `composer require --dev stellarwp/foundation-cli`. It should not be packaged into production WordPress plugin zips.

Do not instruct consuming WordPress plugins to register `StellarWP\Foundation\Cli\CliProvider` in their application providers. `CliProvider` boots the Foundation Symfony Console application for the `foundation` binary only.

When generated code depends on runtime APIs, require the runtime package normally. For WP-CLI commands, install `stellarwp/foundation-wpcli` in `require` if the plugin ships those commands, and install `stellarwp/foundation-cli` in `require-dev` only for generation.

If local scaffolding assets such as `foundation/stubs/` should not be included in a consuming project's release archive, add them to that project's `.gitattributes` production zip exclusions.

## Container Providers

When writing providers or container registration code, prefer container-driven construction over inline factories with explicit `new` calls. Bind classes and interfaces directly when the container can autowire them.
Expand Down
33 changes: 33 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ Foundation is a StellarWP Composer monorepo for reusable PHP packages intended f
- [stellarwp/foundation-pipeline](https://github.com/stellarwp/foundation-pipeline)
- [stellarwp/foundation-log](https://github.com/stellarwp/foundation-log)
- [stellarwp/foundation-wpcli](https://github.com/stellarwp/foundation-wpcli)
- [stellarwp/foundation-cli](https://github.com/stellarwp/foundation-cli)

## Installation

Expand Down Expand Up @@ -114,6 +115,38 @@ composer run foundation -- package:create <Package> --apply

The command creates the `stellarwp/foundation-<package>` repository with the standard `[READ ONLY]` description, disables issues, wiki, and projects, and relies on the package's `close-pull-request.yml` workflow to close pull requests.

#### Generating WP-CLI Commands

In a consuming WordPress project, install the generator as a development dependency:

```bash
composer require --dev stellarwp/foundation-cli
```

If the generated command will ship with the plugin, install the runtime WP-CLI package as a normal dependency:

```bash
composer require stellarwp/foundation-wpcli
```

Then generate a starter WP-CLI command:

```bash
composer run foundation -- make:wpcli-command Sync_Products_Command
```

The command reads the project's PSR-4 Composer autoload namespace, writes a Snake_Case command class under `src/Cli/Commands`, and uses the default WP-CLI command stub from `foundation-wpcli`.

`stellarwp/foundation-cli` is build-time developer tooling. Do not register `StellarWP\Foundation\Cli\CliProvider` in a WordPress plugin application, and do not include the CLI package in production plugin zips. Production installs should normally run with Composer's `--no-dev` mode so the generator, Symfony Console, and other dev tooling are not packaged.

To customize the generated command stub in a project, create:

```text
foundation/stubs/wpcli/command.stub
```

Project-level stubs are local scaffolding assets. Add them to the consuming project's production zip exclusions, such as `.gitattributes`, when they should not be included in release archives.

## License

Copyright © 2026 Nexcess Corp.
Expand Down
3 changes: 3 additions & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,9 @@
"StellarWP\\Foundation\\Tests\\": "tests/"
}
},
"bin": [
"src/Cli/bin/foundation"
],
"config": {
"optimize-autoloader": true,
"preferred-install": "dist",
Expand Down
25 changes: 25 additions & 0 deletions src/Cli/CliProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,19 @@
namespace StellarWP\Foundation\Cli;

use lucatume\DI52\Container;
use StellarWP\Foundation\Cli\Commands\Make\WPCliCommand;
use StellarWP\Foundation\Cli\Commands\Package\Contracts\PackageRepositoryCreator;
use StellarWP\Foundation\Cli\Commands\Package\CreateCommand;
use StellarWP\Foundation\Cli\Commands\Package\GitHubPackageRepositoryCreator;
use StellarWP\Foundation\Cli\Commands\Package\PackageFilesValidator;
use StellarWP\Foundation\Cli\Commands\Package\PackageRepositoryPlanFactory;
use StellarWP\Foundation\Cli\Commands\Package\PackageResolver;
use StellarWP\Foundation\Cli\Commands\Package\PackageScaffolder;
use StellarWP\Foundation\Cli\Generation\ClassNameResolver;
use StellarWP\Foundation\Cli\Generation\ComposerAutoloadResolver;
use StellarWP\Foundation\Cli\Generation\GeneratedFileWriter;
use StellarWP\Foundation\Cli\Generation\StubRenderer;
use StellarWP\Foundation\Cli\Generation\StubResolver;
use StellarWP\Foundation\Cli\Process\Contracts\ProcessRunner;
use StellarWP\Foundation\Cli\Process\ShellProcessRunner;
use StellarWP\Foundation\Container\Contracts\Provider;
Expand All @@ -35,10 +41,23 @@ public function register(): void {
->needs('$rootPath')
->give(static fn (Container $c): string => $c->get(self::ROOT_PATH));

$this->container->when(ComposerAutoloadResolver::class)
->needs('$rootPath')
->give(static fn (Container $c): string => $c->get(self::ROOT_PATH));

$this->container->when(StubResolver::class)
->needs('$rootPath')
->give(static fn (Container $c): string => $c->get(self::ROOT_PATH));

$this->container->when(WPCliCommand::class)
->needs('$rootPath')
->give(static fn (Container $c): string => $c->get(self::ROOT_PATH));

$this->container->when(Application::class)
->needs('$commands')
->give(static fn (Container $c): array => [
$c->get(CreateCommand::class),
$c->get(WPCliCommand::class),
]);

$this->container->singleton(PackageResolver::class);
Expand All @@ -49,6 +68,12 @@ public function register(): void {
$this->container->bind(ProcessRunner::class, ShellProcessRunner::class);
$this->container->bind(PackageRepositoryCreator::class, GitHubPackageRepositoryCreator::class);
$this->container->singleton(CreateCommand::class);
$this->container->singleton(ClassNameResolver::class);
$this->container->singleton(ComposerAutoloadResolver::class);
$this->container->singleton(GeneratedFileWriter::class);
$this->container->singleton(StubRenderer::class);
$this->container->singleton(StubResolver::class);
$this->container->singleton(WPCliCommand::class);
$this->container->singleton(Application::class);
}
}
143 changes: 143 additions & 0 deletions src/Cli/Commands/Make/WPCliCommand.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
<?php declare(strict_types=1);

namespace StellarWP\Foundation\Cli\Commands\Make;

use RuntimeException;
use StellarWP\Foundation\Cli\Generation\ClassNameResolver;
use StellarWP\Foundation\Cli\Generation\ComposerAutoloadResolver;
use StellarWP\Foundation\Cli\Generation\GeneratedFileWriter;
use StellarWP\Foundation\Cli\Generation\StubRenderer;
use StellarWP\Foundation\Cli\Generation\StubResolver;
use StellarWP\Foundation\Cli\Generation\ValueObjects\AutoloadNamespace;
use StellarWP\Foundation\Cli\Generation\ValueObjects\GeneratedFile;
use StellarWP\Foundation\WPCli\WPCliStubPath;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;

/**
* Generates a WP-CLI command class that extends the Foundation WPCli command base.
*
* Use this from a consuming WordPress project to quickly create a command with
* the expected Snake_Case class name, synopsis constants, and WP formatting.
*/
final class WPCliCommand extends Command
{
private const string NAME = 'make:wpcli-command';

public function __construct(
private readonly string $rootPath,
private readonly ComposerAutoloadResolver $autoloadResolver,
private readonly ClassNameResolver $classNameResolver,
private readonly StubResolver $stubResolver,
private readonly StubRenderer $stubRenderer,
private readonly GeneratedFileWriter $fileWriter
) {
parent::__construct(self::NAME);
}

protected function configure(): void {
$this->setDescription('Generate a WP-CLI command class that extends the Foundation command base.')
->addArgument('name', InputArgument::REQUIRED, 'Command class name, e.g. Sync_Products_Command, SyncProducts, or sync-products.')
->addOption('namespace', null, InputOption::VALUE_REQUIRED, 'Namespace for the generated command class.')
->addOption('path', null, InputOption::VALUE_REQUIRED, 'Directory where the command class should be written.')
->addOption('subcommand', null, InputOption::VALUE_REQUIRED, 'WP-CLI subcommand name under the configured command prefix.')
->addOption('description', null, InputOption::VALUE_REQUIRED, 'Command description shown in WP-CLI help.')
->addOption('force', null, InputOption::VALUE_NONE, 'Overwrite the file if it already exists.');
}

protected function execute(InputInterface $input, OutputInterface $output): int {
try {
$file = $this->generatedFile($input);
$this->fileWriter->write($file, (bool) $input->getOption('force'));
} catch (RuntimeException $exception) {
$output->writeln('<error>' . $exception->getMessage() . '</error>');

return Command::FAILURE;
}

$output->writeln(sprintf('<info>Created:</info> %s', $file->relativePath));
$output->writeln('');
$output->writeln('<comment>Register this command from your WP-CLI provider and configure its $commandPrefix container argument.</comment>');

return Command::SUCCESS;
}

private function generatedFile(InputInterface $input): GeneratedFile {
$className = $this->classNameResolver->commandClass((string) $input->getArgument('name'));
$autoload = $this->autoloadResolver->firstPsr4Namespace();
$namespace = $this->namespace($input, $autoload);
$path = $this->path($input, $namespace, $autoload);
$stub = $this->stubResolver->resolve('wpcli', 'command', WPCliStubPath::command());
$relative = $this->relativePath($path . '/' . $className . '.php');
$description = (string) ($input->getOption('description') ?: $this->classNameResolver->description($className));
$subcommand = (string) ($input->getOption('subcommand') ?: $this->classNameResolver->subcommand($className));

return new GeneratedFile(
path: $path . '/' . $className . '.php',
relativePath: $relative,
contents: $this->stubRenderer->render($stub, [
'namespace' => $namespace,
'class' => $className,
'foundation_wpcli_command' => $this->foundationClass('StellarWP\\Foundation\\WPCli\\Command'),
'subcommand' => $subcommand,
'description' => $description,
])
);
}

private function foundationClass(string $class): string {
return ($this->autoloadResolver->straussNamespacePrefix() ?? '') . $class;
}

private function namespace(InputInterface $input, AutoloadNamespace $autoload): string {
$namespace = $input->getOption('namespace');

if (is_string($namespace) && trim($namespace) !== '') {
return trim($namespace, '\\');
}

return trim($autoload->namespace, '\\') . '\\Cli\\Commands';
}

private function path(InputInterface $input, string $namespace, AutoloadNamespace $autoload): string {
$path = $input->getOption('path');

if (is_string($path) && trim($path) !== '') {
return $this->absolutePath($path);
}

$autoloadNamespace = trim($autoload->namespace, '\\');
$relativeNamespace = '';

if (str_starts_with($namespace, $autoloadNamespace)) {
$relativeNamespace = trim(substr($namespace, strlen($autoloadNamespace)), '\\');
}

$segments = $relativeNamespace === '' ? '' : '/' . str_replace('\\', '/', $relativeNamespace);

return $this->rootPath . '/' . trim($autoload->path, '/') . $segments;
}

private function absolutePath(string $path): string {
$path = trim($path);

if (str_starts_with($path, '/')) {
return rtrim($path, '/');
}

return $this->rootPath . '/' . trim($path, '/');
}

private function relativePath(string $path): string {
$root = rtrim($this->rootPath, '/') . '/';

if (str_starts_with($path, $root)) {
return substr($path, strlen($root));
}

return $path;
}
}
62 changes: 62 additions & 0 deletions src/Cli/Generation/ClassNameResolver.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
<?php declare(strict_types=1);

namespace StellarWP\Foundation\Cli\Generation;

use RuntimeException;

/**
* Normalizes generator input into WP-style class, command, and description names.
*/
final class ClassNameResolver
{
public function commandClass(string $input): string {
$words = $this->words($input);

if ($words === []) {
throw new RuntimeException(sprintf('Could not create a class name from "%s".', $input));
}

if (strtolower((string) end($words)) !== 'command') {
$words[] = 'command';
}

return implode('_', array_map($this->pascalWord(...), $words));
}

public function subcommand(string $className): string {
$words = $this->words((string) preg_replace('/_?Command$/', '', $className));

return implode('-', array_map(strtolower(...), $words));
}

public function description(string $className): string {
$words = $this->words((string) preg_replace('/_?Command$/', '', $className));

if ($words === []) {
return 'Run the command.';
}

return ucfirst(implode(' ', array_map(strtolower(...), $words))) . '.';
}

/**
* @return list<string>
*/
private function words(string $input): array {
$input = trim($input);
$input = str_replace('\\', '/', $input);
$input = basename($input);
$input = (string) preg_replace('/([a-z0-9])([A-Z])/', '$1 $2', $input);
$input = (string) preg_replace('/([A-Z]+)([A-Z][a-z])/', '$1 $2', $input);
$input = (string) preg_replace('/[^A-Za-z0-9]+/', ' ', $input);
$words = preg_split('/\s+/', trim($input)) ?: [];

return array_values(array_filter($words, static fn (string $word): bool => $word !== ''));
}

private function pascalWord(string $word): string {
$word = strtolower($word);

return ucfirst($word);
}
}
Loading
Loading