Skip to content

Commit 1dd4946

Browse files
authored
feat(console): add native command completion for zsh and bash (#1851)
1 parent 0e8edb4 commit 1dd4946

File tree

13 files changed

+867
-84
lines changed

13 files changed

+867
-84
lines changed

docs/1-essentials/04-console-commands.md

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -238,6 +238,41 @@ Tempest console comes with a range of interactive components that can be used to
238238
Interactive components are only supported on Mac and Linux. On Windows, Tempest will fall back to non-interactive versions of these components.
239239
:::
240240

241+
## Shell completion
242+
243+
Tempest provides shell completion for Zsh and Bash. This allows you to press `Tab` to autocomplete command names and options.
244+
245+
### Installing completions
246+
247+
Run the install command and follow the prompts:
248+
249+
```console
250+
<dim>./</dim>tempest completion:install
251+
```
252+
253+
The installer will detect your current shell, copy the completion script to the appropriate location, and provide instructions for enabling it.
254+
255+
For Zsh, you'll need to ensure the completions directory is in your `fpath` and reload completions:
256+
257+
```zsh
258+
# Add to ~/.zshrc
259+
fpath=(~/.zsh/completions $fpath)
260+
autoload -Uz compinit && compinit
261+
```
262+
263+
For Bash, source the completion file in your `~/.bashrc`:
264+
265+
```bash
266+
source ~/.bash_completion.d/tempest.bash
267+
```
268+
269+
### Additional commands
270+
271+
You may also use these related commands:
272+
273+
- `completion:show` — Output the completion script to stdout (useful for custom installation)
274+
- `completion:uninstall` — Remove the installed completion script
275+
241276
## Middleware
242277

243278
Console middleware can be applied globally or on a per-command basis. Global console middleware will be discovered and applied automatically, by priority order.
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Tempest\Console\Actions;
6+
7+
use Tempest\Console\Console;
8+
use Tempest\Console\Enums\Shell;
9+
10+
final readonly class ResolveShell
11+
{
12+
public function __construct(
13+
private Console $console,
14+
) {}
15+
16+
public function __invoke(string $question = 'Which shell?'): ?Shell
17+
{
18+
$detected = Shell::detect();
19+
20+
if ($this->console->supportsPrompting()) {
21+
/** @var Shell */
22+
return $this->console->ask(
23+
question: $question,
24+
options: Shell::class,
25+
default: $detected,
26+
);
27+
}
28+
29+
return $detected;
30+
}
31+
}
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Tempest\Console\Commands;
6+
7+
use Symfony\Component\Filesystem\Path;
8+
use Tempest\Console\Actions\ResolveShell;
9+
use Tempest\Console\Console;
10+
use Tempest\Console\ConsoleArgument;
11+
use Tempest\Console\ConsoleCommand;
12+
use Tempest\Console\Enums\Shell;
13+
use Tempest\Console\ExitCode;
14+
use Tempest\Support\Filesystem;
15+
16+
use function Tempest\Support\path;
17+
18+
final readonly class CompletionInstallCommand
19+
{
20+
public function __construct(
21+
private Console $console,
22+
private ResolveShell $resolveShell,
23+
) {}
24+
25+
#[ConsoleCommand(
26+
name: 'completion:install',
27+
description: 'Install shell completion for Tempest',
28+
)]
29+
public function __invoke(
30+
#[ConsoleArgument(
31+
description: 'The shell to install completions for (zsh, bash)',
32+
aliases: ['-s'],
33+
)]
34+
?Shell $shell = null,
35+
#[ConsoleArgument(
36+
description: 'Skip confirmation prompts',
37+
aliases: ['-f'],
38+
)]
39+
bool $force = false,
40+
): ExitCode {
41+
$shell ??= ($this->resolveShell)('Which shell do you want to install completions for?');
42+
43+
if ($shell === null) {
44+
$this->console->error('Could not detect shell. Please specify one using the --shell option. Possible values are: zsh, bash.');
45+
46+
return ExitCode::ERROR;
47+
}
48+
49+
$sourcePath = $this->getSourcePath($shell);
50+
$targetDir = $shell->getCompletionsDirectory();
51+
$targetPath = $shell->getInstalledCompletionPath();
52+
53+
if (! Filesystem\is_file($sourcePath)) {
54+
$this->console->error("Completion script not found: {$sourcePath}");
55+
56+
return ExitCode::ERROR;
57+
}
58+
59+
if (! $force) {
60+
$this->console->info("Installing {$shell->value} completions");
61+
$this->console->keyValue('Source', $sourcePath);
62+
$this->console->keyValue('Target', $targetPath);
63+
$this->console->writeln();
64+
65+
if (! $this->console->confirm('Proceed with installation?', default: true)) {
66+
$this->console->warning('Installation cancelled.');
67+
68+
return ExitCode::CANCELLED;
69+
}
70+
}
71+
72+
Filesystem\ensure_directory_exists($targetDir);
73+
74+
if (Filesystem\is_file($targetPath)) {
75+
if (! $force && ! $this->console->confirm('Completion file already exists. Overwrite?', default: false)) {
76+
$this->console->warning('Installation cancelled.');
77+
78+
return ExitCode::CANCELLED;
79+
}
80+
}
81+
82+
Filesystem\copy_file($sourcePath, $targetPath, overwrite: true);
83+
$this->console->success("Installed completion script to: {$targetPath}");
84+
85+
$this->console->writeln();
86+
$this->console->info('Next steps:');
87+
$this->console->instructions($shell->getPostInstallInstructions());
88+
89+
return ExitCode::SUCCESS;
90+
}
91+
92+
private function getSourcePath(Shell $shell): string
93+
{
94+
return Path::canonicalize(
95+
path(__DIR__, '..', $shell->getSourceFilename())->toString(),
96+
);
97+
}
98+
}
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Tempest\Console\Commands;
6+
7+
use Symfony\Component\Filesystem\Path;
8+
use Tempest\Console\Actions\ResolveShell;
9+
use Tempest\Console\Console;
10+
use Tempest\Console\ConsoleArgument;
11+
use Tempest\Console\ConsoleCommand;
12+
use Tempest\Console\Enums\Shell;
13+
use Tempest\Console\ExitCode;
14+
use Tempest\Support\Filesystem;
15+
16+
use function Tempest\Support\path;
17+
18+
final readonly class CompletionShowCommand
19+
{
20+
public function __construct(
21+
private Console $console,
22+
private ResolveShell $resolveShell,
23+
) {}
24+
25+
#[ConsoleCommand(
26+
name: 'completion:show',
27+
description: 'Output the shell completion script to stdout',
28+
)]
29+
public function __invoke(
30+
#[ConsoleArgument(
31+
description: 'The shell to show completions for (zsh, bash)',
32+
aliases: ['-s'],
33+
)]
34+
?Shell $shell = null,
35+
): ExitCode {
36+
$shell ??= ($this->resolveShell)('Which shell completion script do you want to see?');
37+
38+
if ($shell === null) {
39+
$this->console->error('Could not detect shell. Please specify one using the --shell option. Possible values are: zsh, bash.');
40+
41+
return ExitCode::ERROR;
42+
}
43+
44+
$sourcePath = $this->getSourcePath($shell);
45+
46+
if (! Filesystem\is_file($sourcePath)) {
47+
$this->console->error("Completion script not found: {$sourcePath}");
48+
49+
return ExitCode::ERROR;
50+
}
51+
52+
$this->console->writeRaw(Filesystem\read_file($sourcePath));
53+
54+
return ExitCode::SUCCESS;
55+
}
56+
57+
private function getSourcePath(Shell $shell): string
58+
{
59+
return Path::canonicalize(
60+
path(__DIR__, '..', $shell->getSourceFilename())->toString(),
61+
);
62+
}
63+
}
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Tempest\Console\Commands;
6+
7+
use Tempest\Console\Actions\ResolveShell;
8+
use Tempest\Console\Console;
9+
use Tempest\Console\ConsoleArgument;
10+
use Tempest\Console\ConsoleCommand;
11+
use Tempest\Console\Enums\Shell;
12+
use Tempest\Console\ExitCode;
13+
use Tempest\Support\Filesystem;
14+
15+
final readonly class CompletionUninstallCommand
16+
{
17+
public function __construct(
18+
private Console $console,
19+
private ResolveShell $resolveShell,
20+
) {}
21+
22+
#[ConsoleCommand(
23+
name: 'completion:uninstall',
24+
description: 'Uninstall shell completion for Tempest',
25+
)]
26+
public function __invoke(
27+
#[ConsoleArgument(
28+
description: 'The shell to uninstall completions for (zsh, bash)',
29+
aliases: ['-s'],
30+
)]
31+
?Shell $shell = null,
32+
#[ConsoleArgument(
33+
description: 'Skip confirmation prompts',
34+
aliases: ['-f'],
35+
)]
36+
bool $force = false,
37+
): ExitCode {
38+
$shell ??= ($this->resolveShell)('Which shell completions do you want to uninstall?');
39+
40+
if ($shell === null) {
41+
$this->console->error('Could not detect shell. Please specify one using the --shell option. Possible values are: zsh, bash.');
42+
43+
return ExitCode::ERROR;
44+
}
45+
46+
$targetPath = $shell->getInstalledCompletionPath();
47+
48+
if (! Filesystem\is_file($targetPath)) {
49+
$this->console->warning("Completion file not found: {$targetPath}");
50+
$this->console->info('Nothing to uninstall.');
51+
52+
return ExitCode::SUCCESS;
53+
}
54+
55+
if (! $force) {
56+
$this->console->info("Uninstalling {$shell->value} completions");
57+
$this->console->keyValue('File', $targetPath);
58+
$this->console->writeln();
59+
60+
if (! $this->console->confirm('Proceed with uninstallation?', default: true)) {
61+
$this->console->warning('Uninstallation cancelled.');
62+
63+
return ExitCode::CANCELLED;
64+
}
65+
}
66+
67+
Filesystem\delete_file($targetPath);
68+
$this->console->success("Removed completion script: {$targetPath}");
69+
70+
$this->console->writeln();
71+
$this->console->info('Remember to remove any related lines from your shell configuration:');
72+
$this->console->keyValue('Config file', $shell->getRcFile());
73+
74+
return ExitCode::SUCCESS;
75+
}
76+
}

0 commit comments

Comments
 (0)