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
6 changes: 6 additions & 0 deletions packages/cli_tools/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,12 @@ These tools were developed for the Serverpod CLI but can be used in any Dart pro

The `config` library has been moved into its own package, published on pub.dev as [config](https://pub.dev/packages/config).

## Command line completion

`BetterCommandRunner` has support for command-line completion, which is currently
an experimental feature and requires installing an additional tool. See:
[README_completion.md](README_completion.md)

## Contributing to the Project

We are happy to accept contributions. To contribute, please do the following:
Expand Down
134 changes: 119 additions & 15 deletions packages/cli_tools/README_completion.md
Original file line number Diff line number Diff line change
@@ -1,14 +1,94 @@
## Command line completion for scloud
## Command line completion

There is now an experimental feature to generate and install command line
completion in bash for commands using `BetterCommandRunner`.
`BetterCommandRunner` can generate and install command line completion
in bash and some other shells for all its subcommands and options.

As command developer you will need to install an additional tool that generates
the completion shell script. Two tools are currently supported.

The shell script needs to be installed by end users to enable completion.

### Enable experimental feature

Enable this experimental feature by constructing `BetterCommandRunner`
with the flag `experimentalCompletionCommand` set to `true`.

## Using the tool `carapace`

Carapace supports a lot of shells, including Bash, ZSH, Fish, Elvish,
Powershell, Cmd, and more.
However end users need to install the `carapace` tool as well as the generated
script for the command.

https://carapace.sh/

### Prerequisites

This requires the tool `completely`, which also requires ruby to be installed.
This requires the tool `carapace`.

```sh
brew install carapace
```
For installing in other environments, see:
https://carapace-sh.github.io/carapace-bin/install.html

### Install completion for the command

When the `carapace` tool is installed, generate the completion for the command.

Note that the YAML file must have the same name as the command executable
before the `.yaml` extension.

```sh
my-command completion generate -f my-command.yaml -t carapace
cp my-command.yaml "${UserConfigDir}/carapace/specs/"
```

If you need to specify a specific command name to use in the generated YAML
file, use the `-e` option.

> Note: ${UserConfigDir} refers to the platform-specific user configuration
directory. On MacOS this is `~/Library/Application Support/` (even though many
other Bash commands use `~/.local/share/`), and on Windows this is `%APPDATA%`.
This can be overridden with the env var `XDG_CONFIG_HOME`, but be aware this
affects lots of applications.

Run the following once for the current shell,
or add to your shell startup script:

Bash:
```bash
source <(carapace my-command)
```

Zsh:
```zsh
zstyle ':completion:*' format $'\e[2;37mCompleting %d\e[m'
source <(carapace my-command)
```

For more information and installing in other shells, see:
https://carapace-sh.github.io/carapace-bin/setup.html


### Distribution

End users will need to install `carapace` and copy the Yaml file to the proper
location, even if the Yaml file is distributed with the command.


## Using the tool `completely`

Completely supports Bash and ZSH. It's benefit is that it doesn't require the
end user to install any tool besides the generated shell completion script.

https://github.com/bashly-framework/completely

### Prerequisites

This requires the command developer to install the tool `completely`,
which also requires ruby to be installed.

```sh
gem install completely
```
Expand All @@ -20,30 +100,54 @@ brew install brew-gem
brew gem install completely
```

### Activate
### Generate completion for the command

Construct `BetterCommandRunner` with the flag `experimentalCompletionCommand`
set to `true`.
When the `completely` tool is installed, run commands similar to the following
to generate the bash completion script for the command.

### Install completion
```sh
my-command completion generate -f my-command.yaml -t completely
completely generate my-command.yaml my-command.bash
```

When the `completely` tool is installed, run e.g:
To install the completions in the bash shell, run:

```sh
my-command completion -f completely.yaml
completely generate completely.yaml completely.bash
mkdir -p ~/.local/share/bash-completion/completions
cp completely.bash ~/.local/share/bash-completion/completions/my-command.bash
cp my-command.bash ~/.local/share/bash-completion/completions/
```

This will write the completions script to `~/.local/share/bash-completion/completions/`,
where bash picks it up automatically on start.
Completions scripts in `~/.local/share/bash-completion/completions/`
are automatically picked up by bash on start. Note that they must be named
the same as the command, except for the `.bash` suffix.

In order to update the completions in the current bash shell, run:

```sh
exec bash
```

If the completions directory already exists, you can update the completion
script with this one-liner which doesn't create the intermediate files.
(This approach requires that your command does not generate any other output
to `stdout` when run this way.)

```sh
my-command completion generate -t completely | completely generate - >~/.local/share/bash-completion/completions/my-command.bash
```

### ZSH

If you are using Oh-My-Zsh, bash completions should already be enabled.
Otherwise you should enable completion by adding this to your ~/.zshrc
(if is it not already there):

```sh
autoload -Uz +X compinit && compinit
autoload -Uz +X bashcompinit && bashcompinit
```

### Distribution

For end users, the generated bash script can be distributed as a file for them
to install directly.
to install directly in their `~/.local/share/bash-completion/completions/`.
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import 'package:args/args.dart';
import 'package:args/command_runner.dart';
import 'package:config/config.dart';

import 'completion_command.dart';
import 'completion/completion_command.dart';

/// A function type for executing code before running a command.
typedef OnBeforeRunCommand = Future<void> Function(BetterCommandRunner runner);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
import 'dart:io' show IOSink;

import 'package:config/config.dart';

import 'usage_representation.dart';

/// Generates usage representation for a command in the YAML format
/// of the `carapace` tool.
/// https://github.com/carapace-sh/carapace
class CarapaceYamlGenerator implements UsageRepresentationGenerator {
@override
void generate(
final IOSink out,
final CommandUsage usage,
) {
out.writeln(
r'# yaml-language-server: $schema=https://carapace.sh/schemas/command.json');

_generateForCommand(out, usage, 0);
}

void _generateForCommand(
final IOSink out,
final CommandUsage usage,
int indentLevel,
) {
if (indentLevel == 0) {
_writeWithIndent(out, 'name: ${usage.commandSequence.last}', indentLevel);
} else {
_writeWithIndent(
out, '- name: ${usage.commandSequence.last}', indentLevel);
indentLevel += 1;
}

if (usage.persistentOptions.isNotEmpty) {
_writeWithIndent(out, 'persistentFlags:', indentLevel);
_declareOptions(out, usage.persistentOptions, indentLevel + 1);
}

if (usage.options.isNotEmpty) {
_writeWithIndent(out, 'flags:', indentLevel);
_declareOptions(out, usage.options, indentLevel + 1);
}

final allOptions = [...usage.persistentOptions, ...usage.options];

final exclusiveGroups = _generateExclusiveOptionGroups(allOptions);
if (exclusiveGroups.isNotEmpty) {
_writeWithIndent(out, 'exclusiveFlags:', indentLevel);
for (final group in exclusiveGroups) {
_writeWithIndent(out, '- [${group.join(', ')}]', indentLevel + 1);
}
}

final specs = _generateOptionCompletionSpecs(allOptions);
if (specs.isNotEmpty) {
_writeWithIndent(out, 'completion:', indentLevel);
_writeWithIndent(out, 'flag:', indentLevel + 1);
for (final spec in specs) {
_writeWithIndent(out, spec, indentLevel + 2);
}
}

out.writeln();

if (usage.subcommands.isNotEmpty) {
_writeWithIndent(out, 'commands:', indentLevel);
for (final subcommand in usage.subcommands) {
_generateForCommand(out, subcommand, indentLevel + 1);
}
}
}

static void _writeWithIndent(
final IOSink out,
final String text,
final int indentLevel,
) {
out.writeln('${_getIndent(indentLevel)}$text');
}

static String _getIndent(final int indentLevel) => ' ' * indentLevel;

static void _declareOptions(
final IOSink out,
final List<OptionDefinition> options,
final int indentLevel,
) {
// options
for (final option in options) {
_declareOption(out, option.option, indentLevel);
}
}

static void _declareOption(
final IOSink out,
final ConfigOptionBase option,
final int indentLevel,
) {
final names = [
if (option.argAbbrev != null) '-${option.argAbbrev}',
if (option.argName != null) '--${option.argName}',
];

String attributes = '';
if (option is! FlagOption) {
attributes += '=';
}
if (option is MultiOption) {
attributes += '*';
}
if (option.mandatory && option is! FlagOption) {
attributes += '!';
}
Comment on lines +105 to +114
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Quote help text and support required flags ('!') too

  • Help text is written unquoted; colons, hashes, or brackets in help can break YAML.
  • Required flags (FlagOption.mandatory) aren’t marked with '!'. Carapace supports the '!' suffix for required flags as well, not only options. Based on learnings.

Apply this diff:

@@
-import 'dart:io' show IOSink;
+import 'dart:io' show IOSink;
+import 'dart:convert' show jsonEncode;
@@
   static void _declareOption(
@@
-    String attributes = '';
-    if (option is! FlagOption) {
-      attributes += '=';
-    }
-    if (option is MultiOption) {
-      attributes += '*';
-    }
-    if (option.mandatory && option is! FlagOption) {
-      attributes += '!';
-    }
+    String attributes = '';
+    if (option is! FlagOption) attributes += '=';
+    if (option is MultiOption) attributes += '*';
+    if (option.mandatory) attributes += '!';
@@
-      '${names.join(', ')}$attributes: ${option.helpText ?? ''}',
+      '${names.join(', ')}$attributes: ${_yamlQuote(option.helpText)}',
       indentLevel,
     );
@@
-          '--no-${option.argName}$attributes: ${option.helpText ?? ''}',
+          '--no-${option.argName}$attributes: ${_yamlQuote(option.helpText)}',
           indentLevel,
         );
@@
   }
+
+  static String _yamlQuote(final String? s) => jsonEncode(s ?? '');

Also applies to: 116-121, 122-130

🤖 Prompt for AI Agents
In
packages/cli_tools/lib/src/better_command_runner/completion/carapace_generator.dart
around lines 105-114 (and similarly apply the same changes to 116-121 and
122-130), the generated help text is emitted unquoted (which can break YAML when
it contains colons, hashes, or brackets) and required flags are only marked with
'!' for non-FlagOption types; update the generator to always quote/escape the
help text (e.g., wrap in quotes and escape internal quotes/newlines) when
emitting YAML so special characters are safe, and change the attribute-building
logic so that the '!' suffix is added whenever option.mandatory is true
regardless of whether the option is a FlagOption, while preserving the existing
'=' and '*' behavior for non-FlagOption and MultiOption respectively.


_writeWithIndent(
out,
'${names.join(', ')}$attributes: ${option.helpText ?? ''}',
indentLevel,
);

if (option case final FlagOption flagOption) {
if (flagOption.negatable && !flagOption.hideNegatedUsage) {
_writeWithIndent(
out,
'--no-${option.argName}$attributes: ${option.helpText ?? ''}',
indentLevel,
);
}
}
}

static List<List<String>> _generateExclusiveOptionGroups(
final List<OptionDefinition> options,
) {
final groups = <List<String>>[];
for (final option in options) {
if (option.option case final FlagOption flagOption) {
final argName = flagOption.argName;
if (argName != null &&
flagOption.negatable &&
!flagOption.hideNegatedUsage) {
groups.add([argName, 'no-$argName']);
}
}
}
return groups;
}

static List<String> _generateOptionCompletionSpecs(
final List<OptionDefinition> options,
) {
final specs = <String>[];
for (final option in options) {
final name = option.option.argName ?? option.option.argAbbrev;
if (name == null) {
continue;
}

final values = _getOptionValues(option.option);
if (values.isNotEmpty) {
final valueSpec = values.map((final v) => '"$v"').join(', ');
specs.add('$name: [$valueSpec]');
}
}
return specs;
}

static List<String> _getOptionValues(
final ConfigOptionBase option,
) {
if (option case final MultiOption multiOption) {
if (multiOption.allowedElementValues case final List allowedValues) {
return allowedValues
.map<String>(multiOption.multiParser.elementParser.format)
.toList();
}
} else if (option.allowedValues case final List allowedValues) {
return allowedValues.map(option.valueParser.format).toList();
}

switch (option.option) {
case EnumOption():
final enumParser = option.option.valueParser as EnumParser;
return enumParser.enumValues.map(enumParser.format).toList();
case FileOption():
return [r'$files'];
case DirOption():
return [r'$directories'];
default:
return [];
}
}
}
Loading