diff --git a/legacy/src/Command/Task/TaskExecuteCommand.php b/legacy/src/Command/Task/TaskExecuteCommand.php
new file mode 100644
index 00000000..62dc12e6
--- /dev/null
+++ b/legacy/src/Command/Task/TaskExecuteCommand.php
@@ -0,0 +1,110 @@
+addArgument('task', InputArgument::REQUIRED, 'The name of the task to execute')
+ ->addOption('variable', null, InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, 'A variable to set when running the task, in the format type:name=value');
+
+ $this->selector->addProjectOption($this->getDefinition());
+ $this->selector->addEnvironmentOption($this->getDefinition());
+ $this->addCompleter($this->selector);
+
+ $this->addExample('Execute the "migrate" task on the environment "main"', 'migrate --environment main');
+ $this->addExample('Execute the "migrate" task, setting environment variable FOO=bar', 'migrate -e main --variable env:FOO=bar');
+ }
+
+ protected function execute(InputInterface $input, OutputInterface $output): int
+ {
+ $selection = $this->selector->getSelection($input);
+ $environment = $selection->getEnvironment();
+
+ $taskName = $input->getArgument('task');
+ $variables = $this->parseVariables($input->getOption('variable'));
+
+ $this->stdErr->writeln(sprintf(
+ 'Executing task %s on the environment %s',
+ $taskName,
+ $this->api->getEnvironmentLabel($environment),
+ ));
+
+ $url = $environment->getUri() . '/tasks/' . rawurlencode($taskName) . '/run';
+ $response = $this->api->getHttpClient()->request('POST', $url, ['json' => ['variables' => (object) $variables]]);
+
+ $result = new Result(
+ (array) Utils::jsonDecode((string) $response->getBody(), true),
+ $environment->getUri(),
+ $this->api->getHttpClient(),
+ Activity::class,
+ );
+ $activities = $result->getActivities();
+
+ $this->stdErr->writeln('');
+ $this->stdErr->writeln('The task has been triggered.');
+
+ $executable = $this->config->getStr('application.executable');
+ if ($activities !== []) {
+ // Reference the exact activity ID so the log can be followed even
+ // when several activities are running in parallel.
+ $activity = reset($activities);
+ $this->stdErr->writeln(sprintf(
+ 'To follow its log, run: %s activity:log %s',
+ $executable,
+ $activity->id,
+ ));
+ } else {
+ $this->stdErr->writeln(sprintf(
+ 'To follow its log, run: %s activity:log --type environment.task -e %s',
+ $executable,
+ $environment->id,
+ ));
+ }
+
+ return 0;
+ }
+
+ /**
+ * Parses variables in the format type:name=value into a nested array.
+ *
+ * @param string[] $variables
+ *
+ * @return array>
+ */
+ private function parseVariables(array $variables): array
+ {
+ $map = [];
+ $variable = new Variable();
+ foreach ($variables as $var) {
+ [$type, $name, $value] = $variable->parse($var);
+ $map[$type][$name] = $value;
+ }
+
+ return $map;
+ }
+}
diff --git a/legacy/src/Command/Task/TaskListCommand.php b/legacy/src/Command/Task/TaskListCommand.php
new file mode 100644
index 00000000..40c94303
--- /dev/null
+++ b/legacy/src/Command/Task/TaskListCommand.php
@@ -0,0 +1,84 @@
+ */
+ private array $tableHeader = [
+ 'name' => 'Name',
+ 'type' => 'Type',
+ 'command' => 'Command',
+ 'timeout' => 'Timeout (s)',
+ ];
+
+ public function __construct(private readonly Api $api, private readonly Selector $selector, private readonly Table $table)
+ {
+ parent::__construct();
+ }
+
+ protected function configure(): void
+ {
+ Table::configureInput($this->getDefinition(), $this->tableHeader);
+ $this->selector->addProjectOption($this->getDefinition());
+ $this->selector->addEnvironmentOption($this->getDefinition());
+ $this->addCompleter($this->selector);
+ }
+
+ protected function execute(InputInterface $input, OutputInterface $output): int
+ {
+ $selection = $this->selector->getSelection($input);
+ $environment = $selection->getEnvironment();
+
+ try {
+ $response = $this->api->getHttpClient()->request('GET', $environment->getUri() . '/tasks');
+ } catch (BadResponseException $e) {
+ throw ApiResponseException::create($e->getRequest(), $e->getResponse(), $e);
+ }
+ $tasks = (array) Utils::jsonDecode((string) $response->getBody(), true);
+
+ if ($tasks === []) {
+ $this->stdErr->writeln(sprintf(
+ 'No tasks were found on the environment %s.',
+ $this->api->getEnvironmentLabel($environment),
+ ));
+
+ return 0;
+ }
+
+ $rows = [];
+ foreach ($tasks as $task) {
+ $rows[] = [
+ 'name' => $task['name'] ?? '',
+ 'type' => $task['type'] ?? '',
+ 'command' => isset($task['run']['command']) ? trim((string) $task['run']['command']) : '',
+ 'timeout' => $task['run']['timeout'] ?? '',
+ ];
+ }
+
+ if (!$this->table->formatIsMachineReadable()) {
+ $this->stdErr->writeln(sprintf(
+ 'Tasks on the environment %s:',
+ $this->api->getEnvironmentLabel($environment),
+ ));
+ }
+
+ $this->table->render($rows, $this->tableHeader);
+
+ return 0;
+ }
+}
diff --git a/legacy/src/Service/ActivityLoader.php b/legacy/src/Service/ActivityLoader.php
index 55d4d4b0..cfb2581a 100644
--- a/legacy/src/Service/ActivityLoader.php
+++ b/legacy/src/Service/ActivityLoader.php
@@ -201,6 +201,7 @@ public static function getAvailableTypes(): array
'environment.source-operation',
'environment.subscription.update',
'environment.synchronize',
+ 'environment.task',
'environment.update.http_access',
'environment.update.restrict_robots',
'environment.update.smtp',