Skip to content

Conversation

@christerswahn
Copy link
Collaborator

@christerswahn christerswahn commented Sep 27, 2025

To make it easy for command developers to distribute the shell completion scripts to their end users, this adds two sub-commands:

  • completion embed: Embeds a completion script in a dart source file for inclusion in the command itself.
  • completion install: Installs an embedded completion script in the user's environment.

Example

This PR also includes a complete example in the example folder of using the completion feature with embedding and installing.

Summary by CodeRabbit

  • New Features
    • Enable command-line completion via a new non-experimental flag and pass embedded completion scripts to the runner.
    • Add commands to generate, embed, and install completion scripts for multiple targets (Completely, Carapace), plus install workflow for end users.
  • Documentation
    • Major README overhaul with step-by-step installation, usage examples, generation/embed/install workflows, and an added completion example.

@coderabbitai
Copy link

coderabbitai bot commented Sep 27, 2025

📝 Walkthrough

Walkthrough

Replaces the experimentalCompletionCommand flag with enableCompletionCommand, adds embeddedCompletions support, exposes completion APIs, and implements embed/install/generate completion subcommands plus examples and expanded README documentation; updates tests to exercise embedding and installation flows. No behavioral changes outside the completion subsystem.

Changes

Cohort / File(s) Summary
Documentation / README
packages/cli_tools/README_completion.md
Major documentation expansion: renames flag semantics, adds example usage, detailed installation/distribution and embedding/installation workflows and one-liner generation examples.
Examples
packages/cli_tools/example/command_completion_example.dart, packages/cli_tools/example/completion_script_completely.dart, packages/cli_tools/example/completion_script_carapace.dart
New examples: runner example enabling completion and two embedded completion script resources (Completely + Carapace). Embedded scripts exposed as public constants.
Public API export
packages/cli_tools/lib/better_command_runner.dart, packages/cli_tools/lib/src/better_command_runner/completion/completion.dart
Added export of completion module and re-export of completion_target.dart to make completion types accessible from public API.
Core runner change
packages/cli_tools/lib/src/better_command_runner/better_command_runner.dart, packages/cli_tools/test/better_command_runner/completion_test.dart
Replace experimentalCompletionCommand with enableCompletionCommand and add embeddedCompletions parameter; tests updated to use new constructor and validate embedding/install paths.
Completion types
packages/cli_tools/lib/src/better_command_runner/completion/completion_target.dart
New typedef CompletionScript = ({CompletionTarget target, String script}); introduced to represent embedded scripts.
Completion command surface
packages/cli_tools/lib/src/better_command_runner/completion/completion_command.dart
Constructor now accepts embeddedCompletions; target option help extended for completely and carapace; always registers embed command and registers install when embedded scripts present.
Embed command
packages/cli_tools/lib/src/better_command_runner/completion/completion_embed_command.dart
New hidden embed command: reads script (file/stdin), generates Dart source with embedded script constant, writes to stdout or file; includes options for target, script filename, output filename/dir and path validation helpers.
Install command
packages/cli_tools/lib/src/better_command_runner/completion/completion_install_command.dart
New install command: installs embedded scripts to target-specific default or user-provided locations, chooses file names per target, validates presence of embedded script, logs verbose output, throws on missing runner or script.
Generate command tweaks
packages/cli_tools/lib/src/better_command_runner/completion/completion_generate_command.dart
Added stderr import and verbose logging when writing generated spec; replaced a generic Exception with StateError for missing runner.

Sequence Diagram(s)

sequenceDiagram
    autonumber
    participant Dev as Developer CLI
    participant Runner as BetterCommandRunner
    participant Completion as CompletionCommand
    participant Embed as CompletionEmbedCommand
    participant Install as CompletionInstallCommand
    participant FS as FileSystem

    Note over Dev,Runner: CLI constructed with enableCompletionCommand=true and embeddedCompletions
    Dev->>Runner: new BetterCommandRunner(enableCompletionCommand: true, embeddedCompletions: [...])
    Runner->>Completion: register CompletionCommand(embeddedCompletions)
    Completion->>Completion: register Embed command
    alt embeddedCompletions provided
        Completion->>Install: register Install command (with embedded scripts map)
    end

    %% Embed flow
    Dev->>Completion: run "completion embed --target=completely --script-file=..."
    Completion->>Embed: dispatch embed
    Embed->>FS: read script (file/stdin)
    Embed->>FS: write Dart file or stdout (generated constant)

    %% Install flow
    Dev->>Completion: run "completion install --target=carapace --exec-name=mycmd"
    Completion->>Install: dispatch install
    Install->>FS: select embedded script by target
    Install->>FS: write script to target-specific path
    Install-->>Dev: return/print installed path
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Suggested reviewers

  • SandPod
  • nielsenko

Pre-merge checks and finishing touches

✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title Check ✅ Passed The title precisely captures the primary enhancement introduced by the pull request—adding support for embedding and installing shell completion scripts—and aligns with the two new sub-commands (completion embed and completion install). It is concise, uses clear terminology without extraneous detail, and follows a conventional feat: prefix to denote a feature addition. This ensures that team members scanning the history can quickly understand the main change at a glance.
Docstring Coverage ✅ Passed No functions found in the changes. Docstring coverage check skipped.
✨ Finishing touches
🧪 Generate unit tests
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch completion

Tip

👮 Agentic pre-merge checks are now available in preview!

Pro plan users can now enable pre-merge checks in their settings to enforce checklists before merging PRs.

  • Built-in checks – Quickly apply ready-made checks to enforce title conventions, require pull request descriptions that follow templates, validate linked issues for compliance, and more.
  • Custom agentic checks – Define your own rules using CodeRabbit’s advanced agentic capabilities to enforce organization-specific policies and workflows. For example, you can instruct CodeRabbit’s agent to verify that API documentation is updated whenever API schema files are modified in a PR. Note: Upto 5 custom checks are currently allowed during the preview period. Pricing for this feature will be announced in a few weeks.

Please see the documentation for more information.

Example:

reviews:
  pre_merge_checks:
    custom_checks:
      - name: "Undocumented Breaking Changes"
        mode: "warning"
        instructions: |
          Pass/fail criteria: All breaking changes to public APIs, CLI flags, environment variables, configuration keys, database schemas, or HTTP/GraphQL endpoints must be documented in the "Breaking Change" section of the PR description and in CHANGELOG.md. Exclude purely internal or private changes (e.g., code not exported from package entry points or explicitly marked as internal).

Please share your feedback with us on this Discord post.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@christerswahn christerswahn force-pushed the completion branch 3 times, most recently from 8753e1c to 90c47b2 Compare September 28, 2025 03:06
@christerswahn christerswahn marked this pull request as ready for review September 29, 2025 07:23
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 10

🧹 Nitpick comments (15)
packages/cli_tools/lib/src/better_command_runner/completion/completion_generate_command.dart (2)

56-71: Ensure file sink is always closed on error

If generation throws after openWrite(), the sink isn’t closed. Wrap writes in try/finally and close only for file sinks; close() also flushes, so explicit flush isn’t needed.

-    final IOSink out = file?.openWrite() ?? stdout;
-
-    switch (target) {
+    final IOSink out = file?.openWrite() ?? stdout;
+    try {
+      switch (target) {
       case CompletionTarget.completely:
-        CompletelyYamlGenerator().generate(out, usage);
-        break;
+        CompletelyYamlGenerator().generate(out, usage);
+        break;
       case CompletionTarget.carapace:
-        CarapaceYamlGenerator().generate(out, usage);
-        break;
+        CarapaceYamlGenerator().generate(out, usage);
+        break;
-    }
-
-    if (file != null) {
-      await out.flush();
-      await out.close();
-    }
+      }
+    } finally {
+      if (file != null) {
+        await out.close();
+      }
+    }

72-77: Avoid shadowing ‘out’ with a different type

Using out for both IOSink (outer scope) and String (inner scope) hurts readability.

-      final out = file == null ? ' to stdout' : ': ${file.path}';
-      stderr.writeln('Generated specification$out');
+      final destination = file == null ? ' to stdout' : ': ${file.path}';
+      stderr.writeln('Generated specification$destination');
packages/cli_tools/lib/src/better_command_runner/completion/completion_target.dart (1)

1-7: Enum + record typedef are clean

Simple, clear surface. Consider brief doc comments for IDE help, but otherwise good.

packages/cli_tools/example/completion_script_carapace.dart (2)

4-4: Optionally import the typedef and annotate the constant

Annotating with CompletionScript helps catch shape mismatches at compile time.

-import 'package:cli_tools/better_command_runner.dart' show CompletionTarget;
+import 'package:cli_tools/better_command_runner.dart'
+    show CompletionTarget, CompletionScript;

61-65: Annotate embedded script constant for clarity

Type the record as CompletionScript to align with the public API.

-const completionScriptCarapace = (
+const CompletionScript completionScriptCarapace = (
   target: CompletionTarget.carapace,
   script: _completionScript,
 );
packages/cli_tools/example/completion_script_completely.dart (1)

136-139: Optional: annotate the record with the typedef for stronger checking.

If convenient, import CompletionScript and annotate the constant.

-import 'package:cli_tools/better_command_runner.dart' show CompletionTarget;
+import 'package:cli_tools/better_command_runner.dart' show CompletionTarget;
+// If you decide to annotate explicitly:
+// import 'package:cli_tools/src/better_command_runner/completion/completion_target.dart' show CompletionScript;

-const completionScriptCompletely = (
+// const CompletionScript completionScriptCompletely = (
   target: CompletionTarget.completely,
   script: _completionScript,
 );
packages/cli_tools/lib/src/better_command_runner/completion/completion_command.dart (1)

37-41: Guard against empty embeddedCompletions to avoid a dead ‘install’ command.

Only add the install subcommand when the iterable is non-empty.

-    if (embeddedCompletions != null) {
-      addSubcommand(CompletionInstallCommand(
-        embeddedCompletions: embeddedCompletions,
-      ));
-    }
+    if (embeddedCompletions?.isNotEmpty == true) {
+      addSubcommand(CompletionInstallCommand(
+        embeddedCompletions: embeddedCompletions!,
+      ));
+    }
packages/cli_tools/lib/src/better_command_runner/better_command_runner.dart (1)

152-154: Couple the new params and document them.

  • Add an assert to catch cases where embeddedCompletions is provided but the feature is disabled.
  • Add constructor doc bullets for enableCompletionCommand and embeddedCompletions.
   BetterCommandRunner(
@@
-    final bool enableCompletionCommand = false,
-    final Iterable<CompletionScript>? embeddedCompletions,
+    final bool enableCompletionCommand = false,
+    final Iterable<CompletionScript>? embeddedCompletions,
@@
-    ) {
+    ) {
+    assert(
+      !(!enableCompletionCommand && embeddedCompletions != null),
+      'embeddedCompletions provided but enableCompletionCommand is false',
+    );
@@
-    if (enableCompletionCommand) {
+    if (enableCompletionCommand) {
       addCommand(
         CompletionCommand<T>(embeddedCompletions: embeddedCompletions),
       );
     }

Add to the constructor docs (above):

  • [enableCompletionCommand] enables the “completion” command group.
  • [embeddedCompletions] supplies pre-embedded scripts; when non-empty, the “install” subcommand is exposed.

Also applies to: 179-183

packages/cli_tools/test/better_command_runner/completion_test.dart (2)

311-422: Install path tests look good; add an unknown-target failure test.

Add a test asserting a helpful error when passing an unsupported target to install.


200-308: Add negative test for install completion absence and update library expectation if removed

  • Add a test verifying that the install subcommand is not listed when embeddedCompletions is null or empty.
  • If you remove the standalone library; directive in the generator, update the stringContainsInOrder assertions to drop that line.
packages/cli_tools/README_completion.md (3)

57-61: Clarify “UserConfigDir” and platform paths

Rather than a pseudo variable, reference the actual defaults used by the install command (XDG_CONFIG_HOME on Linux, ~/Library/Application Support on macOS, %APPDATA% on Windows), and point readers to the new completion install subcommand so the tool writes to the correct location automatically.


218-224: Unify placeholder command name

Examples mix “my-command” and “example”. Pick one and use it consistently (e.g., my-command). Also fix the carapace example to match: source <(carapace my-command).

-source <(carapace example) # for bash; for others see https://carapace.sh/setup.html
+source <(carapace my-command) # for bash; for others see https://carapace.sh/setup.html

170-195: Avoid author-specific absolute path in help text

The default output directory shows a machine-specific path:
(defaults to "Directory: '/Users/christer/.../lib/src'"). Replace with a generic description, e.g. (defaults to "lib/src/ under the package root").

packages/cli_tools/lib/src/better_command_runner/completion/completion_embed_command.dart (2)

119-127: Triple-quote collision risk in embedded script

Embedding as r'''...''' will break if the script contains '''. Low probability, but protect by splitting the literal or using base64. Example minimal change: split on the sentinel.

-const String _completionScript = r'''
-$scriptContent
-''';
+const String _completionScript =
+  r'''$scriptContent'''; // If needed, replace occurrences of ''' in content with ''' + r"'" "' + '''

Alternatively, store base64 and decode at runtime (drops “const”), or use triple double-quotes.


57-64: Default output dir may not exist

lib/src won’t exist for some packages/examples. Either create it or relax validation to create when missing.

-  return Directory(p.join(dir.path, 'lib', 'src'));
+  final out = Directory(p.join(dir.path, 'lib', 'src'));
+  if (!out.existsSync()) out.createSync(recursive: true);
+  return out;

Also consider changing outDir option’s PathExistMode to create when absent.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 46c7e61 and 422b409.

📒 Files selected for processing (13)
  • packages/cli_tools/README_completion.md (3 hunks)
  • packages/cli_tools/example/command_completion_example.dart (1 hunks)
  • packages/cli_tools/example/completion_script_carapace.dart (1 hunks)
  • packages/cli_tools/example/completion_script_completely.dart (1 hunks)
  • packages/cli_tools/lib/better_command_runner.dart (1 hunks)
  • packages/cli_tools/lib/src/better_command_runner/better_command_runner.dart (3 hunks)
  • packages/cli_tools/lib/src/better_command_runner/completion/completion.dart (1 hunks)
  • packages/cli_tools/lib/src/better_command_runner/completion/completion_command.dart (3 hunks)
  • packages/cli_tools/lib/src/better_command_runner/completion/completion_embed_command.dart (1 hunks)
  • packages/cli_tools/lib/src/better_command_runner/completion/completion_generate_command.dart (3 hunks)
  • packages/cli_tools/lib/src/better_command_runner/completion/completion_install_command.dart (1 hunks)
  • packages/cli_tools/lib/src/better_command_runner/completion/completion_target.dart (1 hunks)
  • packages/cli_tools/test/better_command_runner/completion_test.dart (5 hunks)
🧰 Additional context used
🧠 Learnings (1)
📚 Learning: 2025-08-07T07:55:17.269Z
Learnt from: christerswahn
PR: serverpod/cli_tools#57
File: packages/config/test/better_command_runner/default_flags_test.dart:1-1
Timestamp: 2025-08-07T07:55:17.269Z
Learning: In the `config` package, `better_command_runner.dart` is intentionally kept as a separate import (`package:config/better_command_runner.dart`) rather than being re-exported through the main `packages/config/lib/config.dart` barrel file. This separation is by design according to the package maintainer christerswahn.

Applied to files:

  • packages/cli_tools/lib/src/better_command_runner/completion/completion.dart
  • packages/cli_tools/lib/src/better_command_runner/completion/completion_command.dart
  • packages/cli_tools/lib/src/better_command_runner/better_command_runner.dart
  • packages/cli_tools/lib/better_command_runner.dart
  • packages/cli_tools/lib/src/better_command_runner/completion/completion_generate_command.dart
🔇 Additional comments (10)
packages/cli_tools/lib/src/better_command_runner/completion/completion.dart (1)

1-1: Barrel export looks good

Re-exporting completion_target.dart here keeps the public surface tidy. No issues.

packages/cli_tools/lib/src/better_command_runner/completion/completion_generate_command.dart (2)

46-49: Use of StateError is appropriate

Throwing StateError when runner is missing fits the situation better than a generic Exception. LGTM.


78-79: No action needed: return null as T is safe
CompletionGenerateCommand is only ever instantiated without a concrete type (so T defaults to dynamic), making the null cast safe and consistent with the BetterCommand pattern of signaling “no meaningful return.”

Likely an incorrect or invalid review comment.

packages/cli_tools/lib/better_command_runner.dart (1)

3-3: Publicly exporting completion module is sensible

Makes CompletionTarget (and related typedefs) available via the primary entrypoint. Consistent with existing export pattern in this file. LGTM.

packages/cli_tools/lib/src/better_command_runner/completion/completion_command.dart (1)

18-21: Nice: clearer help for targets.

Adding allowedHelp entries for ‘completely’ and ‘carapace’ improves UX.

packages/cli_tools/test/better_command_runner/completion_test.dart (1)

32-44: Good toggle test coverage for enabling the completion feature.

Verifies presence/absence of the command cleanly.

packages/cli_tools/example/completion_script_completely.dart (1)

2-2: Invalid library; directive.

library; without a name isn’t valid Dart and will fail analysis/compile. Remove it or give it a name (e.g., library completion_script_completely;). Update the generator and tests accordingly.

-library;
+// (no library directive needed)
⛔ Skipped due to learnings
Learnt from: christerswahn
PR: serverpod/cli_tools#47
File: lib/src/config/options.dart:552-567
Timestamp: 2025-06-12T14:55:38.006Z
Learning: For this project, avoid review comments that simply repeat compile-time errors the Dart analyzer already reports; focus on issues not caught by the analyzer.
packages/cli_tools/README_completion.md (1)

13-15: Enable flag docs look good

Clear, actionable, and matches the new API surface (enableCompletionCommand).

packages/cli_tools/lib/src/better_command_runner/completion/completion_embed_command.dart (1)

82-95: Make this command void-returning and drop null-cast

Align with install command to avoid null as T.

-class CompletionEmbedCommand<T>
-    extends BetterCommand<CompletionEmbedOption, T> {
-  CompletionEmbedCommand() : super(options: CompletionEmbedOption.values);
+class CompletionEmbedCommand
+    extends BetterCommand<CompletionEmbedOption, void> {
+  CompletionEmbedCommand() : super(options: CompletionEmbedOption.values);
@@
-  Future<T> runWithConfig(
+  Future<void> runWithConfig(
@@
-    return null as T;
+    return;

Also applies to: 150-151

⛔ Skipped due to learnings
Learnt from: christerswahn
PR: serverpod/cli_tools#67
File: packages/cli_tools/lib/src/better_command_runner/completion/completion_command.dart:81-81
Timestamp: 2025-09-22T13:21:24.881Z
Learning: In the BetterCommand framework, using `return null as T` is a consistent pattern for commands that don't return meaningful values, rather than using void return types. The generic T parameter should be maintained even when the command doesn't produce a concrete return value.
packages/cli_tools/lib/src/better_command_runner/completion/completion_install_command.dart (1)

28-31: Retain the return null as T pattern; don’t convert to void. The BetterCommand framework intentionally uses Future<T> with return null as T for commands that produce no meaningful value—switching to void would break consistency with other commands (e.g. the embed and generate commands use the same pattern).

Likely an incorrect or invalid review comment.

Copy link
Contributor

@nielsenko nielsenko left a comment

Choose a reason for hiding this comment

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

I noticed that both embed and install are hidden subcommands. I can understand why embed is, but shouldn't install be visible, and should generate perhaps be hidden? Or even better have a separate cli for embed and generate.

As a user I think you should just run my_command completion install -t <shell or carapace> in the ideal case.

That said, lets not make perfect the enemy of good. This is a great addition.

@christerswahn
Copy link
Collaborator Author

I noticed that both embed and install are hidden subcommands. I can understand why embed is, but shouldn't install be visible, and should generate perhaps be hidden? Or even better have a separate cli for embed and generate.

install is not hidden, only embed is.

For the next version, will refactor the sub-commands into "business logic" and "commands", to allow for simpler command interface as well as enabling the developer to change the subcommand interface.

@christerswahn christerswahn merged commit 8ccd27c into main Sep 29, 2025
10 of 11 checks passed
@christerswahn christerswahn deleted the completion branch September 29, 2025 09:47
@christerswahn christerswahn restored the completion branch September 29, 2025 09:49
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🧹 Nitpick comments (5)
packages/cli_tools/lib/src/better_command_runner/completion/completion_install_command.dart (1)

88-93: Optional: avoid accidental overwrite or add --force

Currently overwrites without prompt. Consider a --force flag or skip when file exists, emitting a hint to use --force.

packages/cli_tools/lib/src/better_command_runner/completion/completion_embed_command.dart (4)

104-111: Use async file read for consistency and non-blocking I/O

Small polish: switch to await readAsString() instead of sync I/O inside an async method.

-    } else {
-      final scriptFile = File(scriptFileName);
-      scriptContent = scriptFile.readAsStringSync();
-    }
+    } else {
+      final scriptFile = File(scriptFileName);
+      scriptContent = await scriptFile.readAsString();
+    }

56-62: Default output dir may not exist; consider falling back when lib/src is missing

If a package root is found but lib/src doesn’t exist, DirOption(PathExistMode.mustExist) will fail. Either create it or fall back to Directory.current.

 Directory _defaultWriteDir() {
   final dir = _findPackageRoot();
   if (dir == null) {
     return Directory.current;
   }
-  return Directory(p.join(dir.path, 'lib', 'src'));
+  final candidate = Directory(p.join(dir.path, 'lib', 'src'));
+  return candidate.existsSync() ? candidate : Directory.current;
 }

141-147: Avoid shadowing out (IOSink) with a local string variable

Minor readability nit; rename the message variable.

-      final out =
-          embeddedFile == null ? ' to stdout' : ': ${embeddedFile.path}';
-      stderr.writeln('Wrote embedded script$out');
+      final destMsg =
+          embeddedFile == null ? ' to stdout' : ': ${embeddedFile.path}';
+      stderr.writeln('Wrote embedded script$destMsg');

122-126: Optional: add an explicit record type for the exported constant

Typing the constant improves IDE hints without importing internal typedefs.

-const completionScript${target.name.capitalize()} = (
+const ({CompletionTarget target, String script}) completionScript${target.name.capitalize()} = (
   target: $target,
   script: _completionScript,
 );
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 422b409 and 33a284b.

📒 Files selected for processing (3)
  • packages/cli_tools/example/command_completion_example.dart (1 hunks)
  • packages/cli_tools/lib/src/better_command_runner/completion/completion_embed_command.dart (1 hunks)
  • packages/cli_tools/lib/src/better_command_runner/completion/completion_install_command.dart (1 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
  • packages/cli_tools/example/command_completion_example.dart
🔇 Additional comments (4)
packages/cli_tools/lib/src/better_command_runner/completion/completion_install_command.dart (4)

59-63: Good fix: error message now goes to stderr and preserves non‑zero exit

This resolves the earlier stdout pollution concern.


88-90: Good fix: ensure target directory exists before writing

Creating the parent directory avoids openWrite() failures on default locations.


108-118: Good fix: resolve %APPDATA% via environment with sane fallbacks

Windows branch now returns a real path and handles missing APPDATA.


71-86: Confirm Windows behavior for CompletionTarget.completely

Default path for completely uses _getHomeDir() which throws if HOME is unset. If users attempt install --target completely on Windows (Git Bash/MSYS), this will crash. Either document “completely is non‑Windows only,” guard it, or add a Windows fallback.

If you want to guard quickly:

@@
-    final writeDirPath = writeDir?.path ??
+    final writeDirPath = writeDir?.path ??
         switch (target) {
-          CompletionTarget.completely => p.join(
+          CompletionTarget.completely => Platform.isWindows
+              ? throw ExitException.error()
+              : p.join(
                   _getHomeDir(),
                   '.local',
                   'share',
                   'bash-completion',
                   'completions',
                 ),

Comment on lines +112 to +127
final outputContent = """
/// This file is auto-generated.
library;
import 'package:cli_tools/better_command_runner.dart' show CompletionTarget;
const String _completionScript = r'''
$scriptContent
''';
/// Embedded script for command line completion for `${target.name}`.
const completionScript${target.name.capitalize()} = (
target: $target,
script: _completionScript,
);
""";
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

Make embedding robust when the script contains triple quotes

If the completion script includes ''', the generated Dart won’t compile. Prefer choosing a delimiter that doesn’t occur, with a fallback.

One pragmatic approach: pick between r''' and r""" based on content and build the output with a StringBuffer to avoid escaping inside a """...""" template:

-    final outputContent = """
-/// This file is auto-generated.
-...
-const String _completionScript = r'''
-$scriptContent
-''';
-...
-""";
+    final usesSingle = scriptContent.contains("'''");
+    final usesDouble = scriptContent.contains('"""');
+    final delim = usesSingle && !usesDouble ? '"""' : "'''";
+
+    final b = StringBuffer()
+      ..writeln('/// This file is auto-generated.')
+      ..writeln()
+      ..writeln('library;')
+      ..writeln()
+      ..writeln("import 'package:cli_tools/better_command_runner.dart' show CompletionTarget;")
+      ..writeln()
+      ..writeln('const String _completionScript = r$delim')
+      ..writeln(scriptContent)
+      ..writeln('$delim;')
+      ..writeln()
+      ..writeln('/// Embedded script for command line completion for `${target.name}`.')
+      ..writeln('const completionScript${target.name.capitalize()} = (')
+      ..writeln('  target: $target,')
+      ..writeln('  script: _completionScript,')
+      ..writeln(');');
+    final outputContent = b.toString();

If both ''' and """ appear (rare), consider a future fallback (e.g., base64) and drop const for _completionScript. Happy to draft that if you want.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
final outputContent = """
/// This file is auto-generated.
library;
import 'package:cli_tools/better_command_runner.dart' show CompletionTarget;
const String _completionScript = r'''
$scriptContent
''';
/// Embedded script for command line completion for `${target.name}`.
const completionScript${target.name.capitalize()} = (
target: $target,
script: _completionScript,
);
""";
final usesSingle = scriptContent.contains("'''");
final usesDouble = scriptContent.contains('"""');
final delim = usesSingle && !usesDouble ? '"""' : "'''";
final b = StringBuffer()
..writeln('/// This file is auto-generated.')
..writeln()
..writeln('library;')
..writeln()
..writeln("import 'package:cli_tools/better_command_runner.dart' show CompletionTarget;")
..writeln()
..writeln('const String _completionScript = r$delim')
..writeln(scriptContent)
..writeln('$delim;')
..writeln()
..writeln('/// Embedded script for command line completion for `${target.name}`.')
..writeln('const completionScript${target.name.capitalize()} = (')
..writeln(' target: $target,')
..writeln(' script: _completionScript,')
..writeln(');');
final outputContent = b.toString();
🤖 Prompt for AI Agents
In
packages/cli_tools/lib/src/better_command_runner/completion/completion_embed_command.dart
around lines 112-127, the generated Dart uses a fixed raw triple-single-quote
delimiter which will break if the embedded script contains '''. Change
generation to pick a safe delimiter: check the scriptContent for
triple-single-quotes and triple-double-quotes and use r''' if script does not
contain ''' otherwise use r""" if script does not contain """. If the script
contains both triple-single and triple-double sequences, fall back to encoding
the script (e.g., base64) and emit a non-const _completionScript that decodes at
runtime (or otherwise use a decoded StringBuffer build), so the file always
compiles. Ensure the generated constant name and surrounding code reflect the
chosen representation (raw string when possible, non-const decoded string when
using fallback).

Comment on lines +65 to +70
final executableName = execName ?? betterRunner.executableName;

final writeFileName = switch (target) {
CompletionTarget.completely => '$executableName.bash',
CompletionTarget.carapace => '$executableName.yaml',
};
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

Validate --exec-name to prevent path traversal and invalid filenames

A user‑supplied exec name can contain separators or .., enabling writes outside the intended dir when joined. Fail fast instead of silently sanitizing to match your stated preference.

Apply this minimal validation:

@@
-    final executableName = execName ?? betterRunner.executableName;
+    final executableName = execName ?? betterRunner.executableName;
+    _assertSafeFileName(executableName);
@@
   }
 
+  static void _assertSafeFileName(final String name) {
+    if (name.isEmpty) {
+      throw const FormatException('Executable name cannot be empty');
+    }
+    // Disallow path separators and traversal segments.
+    if (name.contains('/') || name.contains('\\') || name.contains('..')) {
+      throw FormatException(
+        'Invalid --exec-name "$name": must not contain path separators or ".."',
+      );
+    }
+  }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
final executableName = execName ?? betterRunner.executableName;
final writeFileName = switch (target) {
CompletionTarget.completely => '$executableName.bash',
CompletionTarget.carapace => '$executableName.yaml',
};
// Compute the executable name, then validate it before using.
final executableName = execName ?? betterRunner.executableName;
_assertSafeFileName(executableName);
final writeFileName = switch (target) {
CompletionTarget.completely => '$executableName.bash',
CompletionTarget.carapace => '$executableName.yaml',
};
// ...
// Add this static helper within the same class to enforce filename safety.
static void _assertSafeFileName(final String name) {
if (name.isEmpty) {
throw const FormatException('Executable name cannot be empty');
}
// Disallow path separators and traversal segments.
if (name.contains('/') || name.contains('\\') || name.contains('..')) {
throw FormatException(
'Invalid --exec-name "$name": must not contain path separators or ".."',
);
}
}
🤖 Prompt for AI Agents
In
packages/cli_tools/lib/src/better_command_runner/completion/completion_install_command.dart
around lines 65 to 70, the user-supplied execName (execName) is used directly to
form filenames which can include path separators or traversal segments; add
validation to reject any execName containing path separators (such as '/' or '\'
depending on platform) or the substring '..' and also reject empty or
whitespace-only names. If validation fails, throw/return a clear error (e.g.,
usage exception) indicating an invalid exec name so the command fails fast
rather than producing a potentially dangerous path; only after validation,
compute executableName and writeFileName as before.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants