Skip to content

Commit 4e4e1fb

Browse files
committed
feat: Update doc related to commands
Assisted-by: ClaudeCode:claude-opus-5 Signed-off-by: Carl Schwan <carl@carlschwan.eu>
1 parent 20a10ac commit 4e4e1fb

2 files changed

Lines changed: 190 additions & 81 deletions

File tree

developer_manual/app_development/commands.rst

Lines changed: 174 additions & 81 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,11 @@
44
occ commands
55
============
66

7-
Nextcloud apps can register custom `occ <https://docs.nextcloud.com/server/latest/admin_manual/occ_command.html>`_ commands that administrators can run from the command line. Commands extend ``OC\Core\Command\Base``, which wraps
8-
`Symfony Console <https://symfony.com/doc/current/console.html>`_ and adds bash completion support, so the full Symfony
9-
Console API is available.
7+
Nextcloud apps can register custom `occ <https://docs.nextcloud.com/server/latest/admin_manual/occ_command.html>`_
8+
commands that administrators can run from the command line. Commands are plain PHP classes annotated with the
9+
``#[AsCommand]`` attribute from ``OCP\Console``. Nextcloud wires the attribute on top of
10+
`Symfony Console <https://symfony.com/doc/current/console.html>`_ for you, so you get argument parsing, bash
11+
completion, and formatted output without extending a base class.
1012

1113

1214
Registering a command
@@ -31,11 +33,8 @@ injection container, so constructor injection works automatically.
3133
Creating a command class
3234
------------------------
3335

34-
Place command classes in ``lib/Command/``. Each class must extend
35-
``OC\Core\Command\Base`` and implement two methods:
36-
37-
- ``configure()`` — declare the name, description, arguments, and options.
38-
- ``execute()`` — run the command logic and return an exit code.
36+
Place command classes in ``lib/Command/``. Add a ``#[AsCommand]`` attribute to the class and implement a single
37+
``__invoke()`` method that runs the command:
3938

4039
.. code-block:: php
4140
:caption: lib/Command/Greet.php
@@ -46,60 +45,103 @@ Place command classes in ``lib/Command/``. Each class must extend
4645
4746
namespace OCA\MyApp\Command;
4847
49-
use OC\Core\Command\Base;
48+
use OCP\Console\Attribute\Argument;
49+
use OCP\Console\Attribute\AsCommand;
50+
use OCP\Console\Attribute\Option;
51+
use OCP\Console\ExitCode;
52+
use OCP\Console\IOutput;
5053
use OCP\IUserManager;
51-
use Symfony\Component\Console\Input\InputArgument;
52-
use Symfony\Component\Console\Input\InputInterface;
53-
use Symfony\Component\Console\Input\InputOption;
54-
use Symfony\Component\Console\Output\OutputInterface;
55-
56-
class Greet extends Base {
5754
55+
#[AsCommand(
56+
name: 'myapp:greet',
57+
// this short description is shown when running "occ list"
58+
description: 'Print a greeting for a Nextcloud user.',
59+
// this is shown when running the command with the "--help" option
60+
help: 'This command prints a greeting for the given Nextcloud user.',
61+
// this allows you to show one or more usage examples (no need to add the command name)
62+
usages: ['bob', 'alice --shout'],
63+
)]
64+
class Greet {
5865
public function __construct(
5966
private IUserManager $userManager,
6067
) {
61-
parent::__construct();
6268
}
6369
64-
#[\Override]
65-
protected function configure(): void {
66-
$this
67-
->setName('myapp:greet')
68-
->setDescription('Print a greeting for a Nextcloud user')
69-
->addArgument(
70-
'user-id',
71-
InputArgument::REQUIRED,
72-
'The user to greet',
73-
)
74-
->addOption(
75-
'shout',
76-
null,
77-
InputOption::VALUE_NONE,
78-
'Print the greeting in uppercase',
79-
);
80-
}
81-
82-
#[\Override]
83-
protected function execute(InputInterface $input, OutputInterface $output): int {
84-
$userId = $input->getArgument('user-id');
70+
public function __invoke(
71+
IOutput $output,
72+
#[Argument(description: 'The username of the user')]
73+
string $userId,
74+
#[Option(description: 'Print the greeting in uppercase')]
75+
bool $shout = false,
76+
): ExitCode {
8577
$user = $this->userManager->get($userId);
8678
8779
if ($user === null) {
8880
$output->writeln("<error>User \"$userId\" not found.</error>");
89-
return self::FAILURE;
81+
return ExitCode::Failure;
9082
}
9183
9284
$greeting = 'Hello, ' . $user->getDisplayName() . '!';
9385
94-
if ($input->getOption('shout')) {
86+
if ($shout) {
9587
$greeting = strtoupper($greeting);
9688
}
9789
9890
$output->writeln($greeting);
99-
return self::SUCCESS;
91+
return ExitCode::Success;
92+
}
93+
}
94+
95+
The class itself needs no constructor call and no parent class, the constructor is only used for dependency
96+
injection.
97+
98+
.. note::
99+
100+
If you need the full Symfony Console API — for example a custom ``configure()`` step or dynamic shell
101+
completion — you can still extend ``OC\Core\Command\Base`` and implement ``configure()``/``execute()``
102+
directly, as commands did before Nextcloud 35. The ``#[AsCommand]`` style above is recommended for new
103+
commands because it needs far less boilerplate.
104+
105+
Multiple commands in one class
106+
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
107+
108+
To group related commands that share dependencies, put ``#[AsCommand]`` on individual public methods instead of
109+
on the class. Nextcloud registers one occ command per attributed method:
110+
111+
.. code-block:: php
112+
:caption: lib/Command/UserCommands.php
113+
114+
class UserCommands {
115+
public function __construct(
116+
private IUserManager $userManager,
117+
) {
118+
}
119+
120+
#[AsCommand(name: 'myapp:user:create')]
121+
public function create(
122+
IOutput $output,
123+
#[Argument(description: 'The username of the user')]
124+
string $userId,
125+
): ExitCode {
126+
// ...
127+
return ExitCode::Success;
128+
}
129+
130+
#[AsCommand(name: 'myapp:user:delete')]
131+
public function delete(
132+
IOutput $output,
133+
#[Argument(description: 'The username of the user')]
134+
string $userId,
135+
): ExitCode {
136+
// ...
137+
return ExitCode::Success;
100138
}
101139
}
102140
141+
Only ``OCA\MyApp\Command\UserCommands`` needs to be listed in ``appinfo/info.xml``, both ``myapp:user:create``
142+
and ``myapp:user:delete`` are registered from it.
143+
144+
103145
Command naming
104146
--------------
105147

@@ -109,66 +151,117 @@ hyphens as word separators.
109151

110152

111153
Arguments and options
112-
---------------------
154+
----------------------
155+
156+
Arguments are positional and declared with the ``#[Argument]`` attribute on a parameter of ``__invoke()``.
157+
Options are prefixed with ``--`` and declared the same way with ``#[Option]``. Both attributes accept a
158+
``description`` (shown in ``--help``) and an optional ``name``; when ``name`` is omitted it defaults to the
159+
parameter's own name. ``#[Option]`` also accepts a single-letter ``shortcut``, for example ``shortcut: 'b'`` for
160+
``-b``.
161+
162+
The parameter's type and default value — not the attribute — decide whether the argument or option is required,
163+
repeatable, or a flag:
164+
165+
+-------------------------------+-----------------------------------------------------------------------------+
166+
| Parameter declaration | Behavior |
167+
+===============================+=============================================================================+
168+
| ``string $name`` | Required — the command fails if it is not provided. |
169+
+-------------------------------+-----------------------------------------------------------------------------+
170+
| ``string $name = 'foo'`` | Optional, defaults to ``'foo'``. |
171+
+-------------------------------+-----------------------------------------------------------------------------+
172+
| ``?string $name = null`` | Optional and nullable, defaults to ``null``. |
173+
+-------------------------------+-----------------------------------------------------------------------------+
174+
| ``array $name = []`` | Repeatable — the value can be passed multiple times and is collected |
175+
| | into an array. A variadic argument (``string ...$name``) behaves the same |
176+
| | way. |
177+
+-------------------------------+-----------------------------------------------------------------------------+
178+
| ``bool $name = false`` | Flag (option only) — absent means ``false``, present means ``true``. |
179+
+-------------------------------+-----------------------------------------------------------------------------+
180+
| ``bool $name = true`` | Flag (option only) that is on by default; pass ``--no-name`` to disable |
181+
| | it. |
182+
+-------------------------------+-----------------------------------------------------------------------------+
183+
184+
Since PHP parameter names are camelCase but Nextcloud's naming convention for options and arguments uses
185+
lowercase with hyphens, override the default name for such parameters, for example
186+
``#[Option(name: 'object-store')] ?string $objectStore = null``.
187+
188+
.. note::
189+
190+
A ``bool`` option cannot be nullable when it also has a non-``null`` default value — declare it as a plain
191+
non-nullable ``bool`` for flags.
192+
193+
The arguments and options handling in Nextcloud covers the common cases of the Symfony Console component.
194+
Consult `its documentation <https://symfony.com/doc/current/console/input.html>`_ for background on how
195+
Symfony itself models arguments and options.
196+
113197

114-
Arguments are positional. Options are prefixed with ``--``.
115-
116-
+-----------------------------------------+-------------------------------------------+
117-
| Constant | Meaning |
118-
+=========================================+===========================================+
119-
| ``InputArgument::REQUIRED`` | Argument must be provided |
120-
+-----------------------------------------+-------------------------------------------+
121-
| ``InputArgument::OPTIONAL`` | Argument may be omitted |
122-
+-----------------------------------------+-------------------------------------------+
123-
| ``InputArgument::IS_ARRAY`` | Argument accepts multiple values |
124-
+-----------------------------------------+-------------------------------------------+
125-
| ``InputOption::VALUE_NONE`` | Flag — present or absent, no value |
126-
+-----------------------------------------+-------------------------------------------+
127-
| ``InputOption::VALUE_REQUIRED`` | Option requires a value |
128-
+-----------------------------------------+-------------------------------------------+
129-
| ``InputOption::VALUE_OPTIONAL`` | Option value is optional |
130-
+-----------------------------------------+-------------------------------------------+
131-
| ``InputOption::VALUE_IS_ARRAY`` | Option can be repeated |
132-
+-----------------------------------------+-------------------------------------------+
133-
134-
See the `Symfony Console documentation <https://symfony.com/doc/current/console/input.html>`_
135-
for the full reference.
198+
Output, input, and other helpers
199+
---------------------------------
200+
201+
Besides ``#[Argument]`` and ``#[Option]`` parameters, ``__invoke()`` can request the following types by
202+
type-hint alone — no attribute needed, and the parameter order does not matter:
203+
204+
- ``OCP\Console\IOutput``: write output with ``write()``/``writeln()``, check the requested verbosity, or
205+
emit arrays and tables (see below).
206+
- ``OCP\Console\IInput``: read all given arguments and options with ``getArguments()``/``getOptions()``.
207+
- ``OCP\Console\IQuestionHelper``: prompt the user for confirmation or input, see :ref:`occ_commands_interactive`.
208+
- ``OCP\Console\OutputFormat``: only resolved when ``supportsOutputFormat: true`` is set on ``#[AsCommand]``;
209+
tells you whether the administrator requested plain text or JSON output.
210+
211+
.. tip::
212+
213+
Set ``supportsOutputFormat: true`` on ``#[AsCommand]`` to let administrators request machine-readable output
214+
with ``--output=json`` or ``--output=json_pretty``. Use ``IOutput::writeArrayInOutputFormat()`` or
215+
``IOutput::writeTableInOutputFormat()`` to emit data that automatically respects the requested format.
136216

137217

138218
Return codes
139219
------------
140220

141-
``execute()`` must return an integer. Use the constants defined by
142-
``OC\Core\Command\Base`` (inherited from Symfony):
221+
``__invoke()`` (or an attributed method) must return an ``OCP\Console\ExitCode`` case, or a plain integer:
222+
223+
- ``ExitCode::Success`` (``0``): command completed successfully.
224+
- ``ExitCode::Failure`` (``1``): command encountered an error.
225+
- ``ExitCode::Invalid`` (``2``): command was called with invalid input.
143226

144-
- ``self::SUCCESS`` (``0``) — command completed successfully.
145-
- ``self::FAILURE`` (``1``) — command encountered an error.
146-
- ``self::INVALID`` (``2``) — command was called with invalid input.
227+
Returning the enum case is recommended; declare the method's return type as ``ExitCode``.
147228

148229

230+
.. _occ_commands_interactive:
231+
149232
Interactive commands
150-
--------------------
233+
---------------------
151234

152235
Commands can ask for confirmation or prompt for values using Symfony's
153-
`question helper <https://symfony.com/doc/current/components/console/helpers/questionhelper.html>`_:
236+
`question helper <https://symfony.com/doc/current/components/console/helpers/questionhelper.html>`_. Request an
237+
``OCP\Console\IQuestionHelper`` as a parameter of ``__invoke()``, the same way as ``IOutput``:
154238

155239
.. code-block:: php
156240
157-
use Symfony\Component\Console\Helper\QuestionHelper;
241+
use OCP\Console\ExitCode;
242+
use OCP\Console\IOutput;
243+
use OCP\Console\IQuestionHelper;
158244
use Symfony\Component\Console\Question\ConfirmationQuestion;
159245
160-
// In execute():
161-
/** @var QuestionHelper $helper */
162-
$helper = $this->getHelper('question');
163-
$question = new ConfirmationQuestion('Are you sure? (y/n) ', false);
246+
class Greet {
247+
public function __invoke(
248+
IOutput $output,
249+
IQuestionHelper $questionHelper,
250+
): ExitCode {
251+
$question = new ConfirmationQuestion('Are you sure? (y/n) ', false);
164252
165-
if (!$helper->ask($input, $output, $question)) {
166-
$output->writeln('Aborted.');
167-
return self::FAILURE;
253+
if (!$questionHelper->ask($question)) {
254+
$output->writeln('Aborted.');
255+
return ExitCode::Failure;
256+
}
257+
258+
// ...
259+
return ExitCode::Success;
260+
}
168261
}
169262
170263
.. note::
171264

172-
Interactive prompts are skipped when occ is run non-interactively (e.g. from a
173-
cron job). Guard against this with ``$input->isInteractive()`` or use
174-
``--yes``/``--no`` options so administrators can automate the command.
265+
When occ runs non-interactively (e.g. from a cron job), the question helper returns the question's default
266+
value instead of prompting. Choose a safe default, or add ``--yes``/``--no`` options so administrators can
267+
automate the command.

developer_manual/release_notes/critical_changes.rst

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,22 @@ This is a breaking change, apps that rely on the library need to update to the n
4040
this includes a new namespace (``\phpseclib3``).
4141
Changes can be found on the `library's website <https://phpseclib.com/docs/why#phpseclib-30-vs-phspeclib-10--20>`__.
4242

43+
Symfony Console
44+
^^^^^^^^^^^^^^^
45+
46+
Symfony Console was updated from version 6 to version 7. This changes the signature of the
47+
``execute`` method, which now requires a return type declaration. If your commands still extend
48+
``OC\Core\Command\Base`` and implement ``configure()``/``execute()``, fix them by running:
49+
50+
.. code-block:: bash
51+
52+
find lib -iname '*.php' -exec sed -i 's/function execute(InputInterface $input, OutputInterface $output) {/function execute(InputInterface $input, OutputInterface $output): int {/g' {} \;
53+
54+
To insulate apps from breakage like this in the future, Nextcloud 35 also introduces a new,
55+
attribute-based interface for writing commands that does not require extending a Symfony base class.
56+
See :ref:`occ_commands` for the full documentation. Existing commands keep working unchanged (once
57+
fixed with the command above), migrating to the new interface is optional but recommanded.
58+
4359
Updated database requirements
4460
-----------------------------
4561

0 commit comments

Comments
 (0)