Skip to content
Merged
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
1 change: 1 addition & 0 deletions packages/admin-auth/src/Command/SyncPermissionsCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
name: 'admin-auth:permissions:sync',
description: 'Write the registered admin permissions to the database (--prune deletes unregistered ones)',
flags: ['prune', 'force'],
destructive: true,
)]
readonly class SyncPermissionsCommand implements CommandInterface
{
Expand Down
7 changes: 6 additions & 1 deletion packages/authentication/src/Command/ClearTokensCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,12 @@
use Marko\Core\Command\Output;

/** @noinspection PhpUnused */
#[Command(name: 'auth:clear-tokens', description: 'Clear expired remember me tokens', flags: ['force'])]
#[Command(
name: 'auth:clear-tokens',
description: 'Clear expired remember me tokens',
flags: ['force'],
destructive: true,
)]
readonly class ClearTokensCommand implements CommandInterface
{
public function __construct(
Expand Down
2 changes: 1 addition & 1 deletion packages/cache/src/Command/ClearCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
use Marko\Core\Command\Output;

/** @noinspection PhpUnused */
#[Command(name: 'cache:clear', description: 'Clear all cached items')]
#[Command(name: 'cache:clear', description: 'Clear all cached items', destructive: true)]
readonly class ClearCommand implements CommandInterface
{
public function __construct(
Expand Down
2 changes: 1 addition & 1 deletion packages/codeindexer/src/Commands/RebuildIndexCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
use Marko\Core\Command\Input;
use Marko\Core\Command\Output;

#[Command(name: 'indexer:rebuild', description: 'Rebuild the Marko code index cache')]
#[Command(name: 'indexer:rebuild', description: 'Rebuild the Marko code index cache', destructive: true)]
class RebuildIndexCommand implements CommandInterface
{
private const string CACHE_FILE = '.marko/index.cache';
Expand Down
4 changes: 4 additions & 0 deletions packages/core/src/Attributes/Command.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,15 @@
/**
* @param list<string> $aliases
* @param list<string> $flags Value-less options (e.g. `force`, `d`) that never consume the next token
* @param bool $destructive The command changes or deletes stored state (database rows, cache entries, queued
* jobs, log files...). Callers that run commands on someone else's behalf, such as the
* MCP run_console_command tool, refuse destructive commands unless explicitly allowed
*/
public function __construct(
public string $name,
public string $description = '',
public array $aliases = [],
public array $flags = [],
public bool $destructive = false,
) {}
}
1 change: 1 addition & 0 deletions packages/core/src/Command/CommandDefinition.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,5 +19,6 @@ public function __construct(
public string $description = '',
public array $aliases = [],
public array $flags = [],
public bool $destructive = false,
) {}
}
1 change: 1 addition & 0 deletions packages/core/src/Command/CommandDiscovery.php
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ private function discoverInDirectory(
description: $attribute->description,
aliases: $attribute->aliases,
flags: $attribute->flags,
destructive: $attribute->destructive,
);
}

Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/Commands/DiscoveryClearCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
use Marko\Core\Discovery\DiscoveryCache;

/** @noinspection PhpUnused */
#[Command(name: 'discovery:clear', description: 'Remove the discovery cache')]
#[Command(name: 'discovery:clear', description: 'Remove the discovery cache', destructive: true)]
readonly class DiscoveryClearCommand implements CommandInterface
{
public function __construct(
Expand Down
5 changes: 4 additions & 1 deletion packages/core/src/Discovery/DiscoveryCache.php
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@
*/
class DiscoveryCache
{
public const int CACHE_VERSION = 3;
public const int CACHE_VERSION = 4;

private const array REQUIRED_KEYS = [
'fingerprint',
Expand Down Expand Up @@ -161,6 +161,7 @@ public function write(
'description' => $d->description,
'aliases' => $d->aliases,
'flags' => $d->flags,
'destructive' => $d->destructive,
],
$payload['commands'],
),
Expand Down Expand Up @@ -561,13 +562,15 @@ private function hydrateCommands(
$this->assertStringField($path, $record, 'commands', $i, 'description');
$this->assertArrayField($path, $record, 'commands', $i, 'aliases');
$this->assertArrayField($path, $record, 'commands', $i, 'flags');
$this->assertBoolField($path, $record, 'commands', $i, 'destructive');

$result[] = new CommandDefinition(
commandClass: $record['commandClass'],
name: $record['name'],
description: $record['description'],
aliases: $record['aliases'],
flags: $record['flags'],
destructive: $record['destructive'],
);
}

Expand Down
12 changes: 12 additions & 0 deletions packages/core/tests/Command/CommandAttributeTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,18 @@ class DescribedCommand {}
expect($command->flags)->toBe([]);
});

it('accepts the destructive marker in Command attribute', function (): void {
$command = new Command(name: 'cache:clear', destructive: true);

expect($command->destructive)->toBeTrue();
});

it('defaults destructive to false when not provided', function (): void {
$command = new Command(name: 'test:cmd');

expect($command->destructive)->toBeFalse();
});

it('preserves existing Command attribute behavior without aliases', function (): void {
$reflection = new ReflectionClass(TestCommand::class);
$attributes = $reflection->getAttributes(Command::class);
Expand Down
19 changes: 19 additions & 0 deletions packages/core/tests/Command/CommandDefinitionTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,25 @@
expect($definition->flags)->toBe([]);
});

it('stores the destructive marker in CommandDefinition', function (): void {
$definition = new CommandDefinition(
commandClass: 'App\Command\TestCommand',
name: 'cache:clear',
destructive: true,
);

expect($definition->destructive)->toBeTrue();
});

it('defaults the destructive marker to false', function (): void {
$definition = new CommandDefinition(
commandClass: 'App\Command\TestCommand',
name: 'test:command',
);

expect($definition->destructive)->toBeFalse();
});

it('marks CommandDefinition as readonly', function (): void {
$reflection = new ReflectionClass(CommandDefinition::class);

Expand Down
77 changes: 77 additions & 0 deletions packages/core/tests/Command/CommandDiscoveryTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -432,6 +432,83 @@ public function execute(
rmdir($tempDir);
});

it('discovers the destructive marker from the Command attribute', function (): void {
$tempDir = sys_get_temp_dir() . '/marko_test_' . bin2hex(random_bytes(8));
mkdir($tempDir . '/src', 0755, true);

$destructiveCode = <<<'PHP'
<?php

declare(strict_types=1);

namespace TestCommandModuleDestructive1;

use Marko\Core\Attributes\Command;
use Marko\Core\Command\CommandInterface;
use Marko\Core\Command\Input;
use Marko\Core\Command\Output;

#[Command(name: 'app:wipe', description: 'Wipes state', destructive: true)]
class WipeCommand implements CommandInterface
{
public function execute(
Input $input,
Output $output,
): int {
return 0;
}
}
PHP;
$safeCode = <<<'PHP'
<?php

declare(strict_types=1);

namespace TestCommandModuleDestructive1;

use Marko\Core\Attributes\Command;
use Marko\Core\Command\CommandInterface;
use Marko\Core\Command\Input;
use Marko\Core\Command\Output;

#[Command(name: 'app:status', description: 'Reads state')]
class StatusCommand implements CommandInterface
{
public function execute(
Input $input,
Output $output,
): int {
return 0;
}
}
PHP;
file_put_contents($tempDir . '/src/WipeCommand.php', $destructiveCode);
file_put_contents($tempDir . '/src/StatusCommand.php', $safeCode);

$manifest = new ModuleManifest(
name: 'test/module',
version: '1.0.0',
path: $tempDir,
);

$discovery = new CommandDiscovery(new ClassFileParser());
$byName = [];

foreach ($discovery->discover([$manifest]) as $definition) {
$byName[$definition->name] = $definition;
}

expect($byName)->toHaveCount(2)
->and($byName['app:wipe']->destructive)->toBeTrue()
->and($byName['app:status']->destructive)->toBeFalse();

// Cleanup
unlink($tempDir . '/src/WipeCommand.php');
unlink($tempDir . '/src/StatusCommand.php');
rmdir($tempDir . '/src');
rmdir($tempDir);
});

it('creates CommandDefinition with empty aliases when none specified', function (): void {
$tempDir = sys_get_temp_dir() . '/marko_test_' . bin2hex(random_bytes(8));
mkdir($tempDir . '/src', 0755, true);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -175,7 +175,10 @@ function (): void {
"<?php return ['version' => 2, 'preferences' => [], 'plugins' => [], 'observers' => [], 'commands' => []];",
);

expect(fn () => $this->cache->load())->toThrow(DiscoveryCacheException::class, 'found 2, expected 3');
expect(fn () => $this->cache->load())->toThrow(
DiscoveryCacheException::class,
'found 2, expected ' . DiscoveryCache::CACHE_VERSION,
);
});

it('throws malformed when a module record or section is invalid', function (): void {
Expand Down
18 changes: 17 additions & 1 deletion packages/core/tests/Unit/Discovery/DiscoveryCacheTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ function samplePayload(): array
description: 'Clears the cache',
aliases: ['cc'],
flags: ['force'],
destructive: true,
),
],
];
Expand Down Expand Up @@ -194,7 +195,8 @@ function (): void {
->and($loaded['commands'][0]->name)->toBe('cache:clear')
->and($loaded['commands'][0]->description)->toBe('Clears the cache')
->and($loaded['commands'][0]->aliases)->toBe(['cc'])
->and($loaded['commands'][0]->flags)->toBe(['force']);
->and($loaded['commands'][0]->flags)->toBe(['force'])
->and($loaded['commands'][0]->destructive)->toBeTrue();
},
);

Expand Down Expand Up @@ -367,6 +369,20 @@ function (): void {
);
expect(fn () => $cache->load())->toThrow(DiscoveryCacheException::class);

// Command destructive missing (cache written before the destructive marker existed)
file_put_contents(
$path,
"<?php return ['version' => $version, 'preferences' => [], 'plugins' => [], 'observers' => [], 'commands' => [['commandClass' => 'App\\\\Cmd', 'name' => 'cmd', 'description' => '', 'aliases' => [], 'flags' => []]]];",
);
expect(fn () => $cache->load())->toThrow(DiscoveryCacheException::class);

// Command destructive as string instead of bool
file_put_contents(
$path,
"<?php return ['version' => $version, 'preferences' => [], 'plugins' => [], 'observers' => [], 'commands' => [['commandClass' => 'App\\\\Cmd', 'name' => 'cmd', 'description' => '', 'aliases' => [], 'flags' => [], 'destructive' => 'yes']]];",
);
expect(fn () => $cache->load())->toThrow(DiscoveryCacheException::class);

// Plugin beforeMethods as string
file_put_contents(
$path,
Expand Down
5 changes: 5 additions & 0 deletions packages/database/src/Command/DestructiveCommandGuard.php
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,11 @@
* per call with allowInProduction: production is then treated like staging, so it still needs
* --force and still asks when someone can answer. confirmInDevelopment makes development and
* testing ask for confirmation too when someone can answer.
*
* --force is the terminal confirmation only. A command that calls this guard must also be marked
* #[Command(destructive: true)]: callers that run commands for someone else (the MCP run_console_command
* tool) read that marker, not the presence of a force flag, and a repository test fails when a
* shipped command that depends on this guard is unmarked.
*/
readonly class DestructiveCommandGuard
{
Expand Down
1 change: 1 addition & 0 deletions packages/database/src/Command/MigrateCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@
name: 'db:migrate',
description: 'Apply database migrations',
flags: ['generate', 'no-generate', 'force', 'verbose', 'v'],
destructive: true,
)]
readonly class MigrateCommand implements CommandInterface
{
Expand Down
7 changes: 6 additions & 1 deletion packages/database/src/Command/RebuildCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,12 @@
use Marko\Database\Migration\Migrator;

/** @noinspection PhpUnused */
#[Command(name: 'db:rebuild', description: 'Reset and re-run all migrations (clean slate)', flags: ['force'])]
#[Command(
name: 'db:rebuild',
description: 'Reset and re-run all migrations (clean slate)',
flags: ['force'],
destructive: true,
)]
readonly class RebuildCommand implements CommandInterface
{
public function __construct(
Expand Down
2 changes: 1 addition & 1 deletion packages/database/src/Command/ResetCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
use Marko\Database\Migration\Migrator;

/** @noinspection PhpUnused */
#[Command(name: 'db:reset', description: 'Rollback all database migrations', flags: ['force'])]
#[Command(name: 'db:reset', description: 'Rollback all database migrations', flags: ['force'], destructive: true)]
readonly class ResetCommand implements CommandInterface
{
public function __construct(
Expand Down
7 changes: 6 additions & 1 deletion packages/database/src/Command/RollbackCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,12 @@
use Marko\Database\Migration\Migrator;

/** @noinspection PhpUnused */
#[Command(name: 'db:rollback', description: 'Rollback the last batch of migrations', flags: ['force'])]
#[Command(
name: 'db:rollback',
description: 'Rollback the last batch of migrations',
flags: ['force'],
destructive: true,
)]
readonly class RollbackCommand implements CommandInterface
{
public function __construct(
Expand Down
2 changes: 1 addition & 1 deletion packages/database/src/Command/SeedCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
use Marko\Database\Seed\SeederRunner;

/** @noinspection PhpUnused */
#[Command(name: 'db:seed', description: 'Run database seeders', flags: ['force'])]
#[Command(name: 'db:seed', description: 'Run database seeders', flags: ['force'], destructive: true)]
readonly class SeedCommand implements CommandInterface
{
public function __construct(
Expand Down
1 change: 1 addition & 0 deletions packages/devai/src/Commands/InstallCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
name: 'devai:install',
description: 'Install Marko AI development tooling for selected agents',
flags: ['force', 'update-gitignore', 'skip-lsp-deps', 'yes'],
destructive: true,
)]
readonly class InstallCommand implements CommandInterface
{
Expand Down
17 changes: 17 additions & 0 deletions packages/docs-markdown/docs/packages/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -194,9 +194,26 @@ use Marko\Core\Attributes\Command;
description: 'What it does',
aliases: ['alias'], // Optional alternative names
flags: ['force', 'f'], // Optional value-less options that never consume the next token
destructive: true, // Optional: the command changes or deletes stored state (default false)
)]
```

### Destructive Commands

Mark a command that changes or deletes stored state (database rows or schema, cache entries, queued jobs, log files, sessions, tokens) with `destructive: true`:

```php
use Marko\Core\Attributes\Command;

#[Command(name: 'report:purge', description: 'Delete old reports', flags: ['force'], destructive: true)]
```

The marker is how callers that run commands on someone else's behalf tell a state-changing command from a read-only one, without guessing from its name or options. The [MCP `run_console_command` tool](/docs/packages/mcp/#run_console_command-safety) refuses every destructive command unless the server operator sets `mcp.console.allow_destructive`. The marker is copied onto the command's `CommandDefinition` as `$definition->destructive`, so your own tooling can read it from the `CommandRegistry`.

The marker does not change how the command runs from a terminal. Asking for `--force` or a confirmation is still the command's job: database commands use [`DestructiveCommandGuard`](/docs/packages/database/#using-the-policy-in-your-own-commands), and any command can use [`ConfirmationPrompterInterface`](/docs/packages/core/#asking-for-confirmation).

Every shipped command that changes or deletes stored state carries the marker: `db:migrate`, `db:reset`, `db:rollback`, `db:rebuild`, `db:seed`, `cache:clear`, `page-cache:clear`, `page-cache:purge`, `queue:clear`, `log:clear`, `session:gc`, `auth:clear-tokens`, `discovery:clear`, `indexer:rebuild`, `admin-auth:permissions:sync` and `devai:install`. A repository test fails when a shipped command named `*:clear`, `*:reset`, `*:rollback`, `*:rebuild`, `*:fresh`, `*:seed`, `*:truncate`, `*:purge` or `*:clear-tokens`, or one that uses `DestructiveCommandGuard`, is missing it.

### Exceptions

The CLI provides rich error messages with context and suggestions via `CliException` and its subclasses:
Expand Down
Loading
Loading