# Interactive Dart CLI template — ready-to-copy (short & complete)

Below is a minimal, practical template for a **continuous, interactive Dart CLI** with arrow-key menus and multi-step flows. Drop these files into a Dart console project (`dart create -t console mycli`) and adapt business logic (build steps) where noted.

---

# Quick usage

* Run in dev:

  ```
  dart run bin/mycli.dart         # starts interactive shell
  dart run bin/mycli.dart build   # run single command and exit
  ```
* Build native exe:

  ```
  dart compile exe bin/mycli.dart -o mycli
  ```

---

# Files

### `pubspec.yaml`

```yaml
name: mycli
description: A cross-platform interactive CLI for Flutter builds.
environment:
  sdk: '>=2.18.0 <4.0.0'

dependencies:
  args: ^2.4.2
dev_dependencies:
  pedantic: ^1.11.0
```

---

### `bin/mycli.dart` (entrypoint)

```dart
import 'dart:io';
import 'package:args/args.dart';
import 'package:mycli/command_router.dart';
import 'package:mycli/core/app_context.dart';
import 'package:mycli/ui/shell.dart';

Future<void> main(List<String> args) async {
  final parser = ArgParser()
    ..addFlag('shell', abbr: 's', help: 'Start interactive shell', negatable: false)
    ..addCommand('build')
    ..addCommand('clean')
    ..addCommand('gen-env');

  final result = parser.parse(args);
  final ctx = await AppContext.load();

  // Single-command invocation
  if (result.arguments.isNotEmpty && !result['shell']) {
    await CommandRouter.runFromArgs(result, ctx);
    exit(0);
  }

  // If explicit --shell or no args: start interactive shell
  final shell = Shell(ctx);
  await shell.run();
}
```

---

### `lib/core/app_context.dart`

```dart
import 'dart:io';
import 'dart:convert';

class AppContext {
  final Map<String, String> env;
  final String projectRoot;

  AppContext._(this.env, this.projectRoot);

  static Future<AppContext> load() async {
    final root = Directory.current.path;
    final envFile = File('$root/.buildenv');
    final map = <String, String>{};

    if (await envFile.exists()) {
      final lines = await envFile.readAsLines();
      for (var l in lines) {
        l = l.trim();
        if (l.isEmpty || l.startsWith('#')) continue;
        final idx = l.indexOf('=');
        if (idx > 0) {
          map[l.substring(0, idx).trim()] = l.substring(idx + 1).trim();
        }
      }
    }
    // Add a few defaults if missing
    map.putIfAbsent('OUTPUT_PATH', () => 'dist');
    return AppContext._(map, root);
  }

  String? operator [](String key) => env[key];
}
```

---

### `lib/command_router.dart`

```dart
import 'dart:io';
import 'package:args/args.dart';
import 'package:mycli/core/app_context.dart';
import 'package:mycli/commands/build_cmd.dart';
import 'package:mycli/commands/clean_cmd.dart';
import 'package:mycli/commands/genenv_cmd.dart';

class CommandRouter {
  static Future<void> runFromArgs(ArgResults parsed, AppContext ctx) async {
    final cmd = parsed.command?.name;
    if (cmd == null) {
      _printHelp();
      return;
    }
    switch (cmd) {
      case 'build':
        await BuildCmd.run(parsed.command!, ctx);
        break;
      case 'clean':
        await CleanCmd.run(parsed.command!, ctx);
        break;
      case 'gen-env':
        await GenEnvCmd.run(parsed.command!, ctx);
        break;
      default:
        print('Unknown command: $cmd');
    }
  }

  static void _printHelp() {
    print('Usage: mycli <command>');
    print('Commands: build, clean, gen-env');
  }
}
```

---

### `lib/commands/build_cmd.dart`

```dart
import 'dart:io';
import 'package:mycli/core/app_context.dart';
import 'package:mycli/flows/build_flow.dart';

class BuildCmd {
  static Future<void> run(ArgResults args, AppContext ctx) async {
    // simple non-interactive build: use defaults or args
    final flow = BuildFlow(ctx);
    await flow.execute();
  }
}
```

---

### `lib/commands/clean_cmd.dart`

```dart
import 'dart:io';
import 'package:mycli/core/app_context.dart';

class CleanCmd {
  static Future<void> run(ArgResults args, AppContext ctx) async {
    print('Running flutter clean ...');
    final r = await Process.run('flutter', ['clean']);
    stdout.write(r.stdout);
    stderr.write(r.stderr);
    if (r.exitCode != 0) {
      print('flutter clean failed (${r.exitCode})');
    } else {
      print('Clean done.');
    }
  }
}
```

---

### `lib/commands/genenv_cmd.dart`

```dart
import 'dart:io';
import 'package:mycli/core/app_context.dart';

class GenEnvCmd {
  static Future<void> run(ArgResults args, AppContext ctx) async {
    // stub: generate .buildenv from buildenv.base + detection
    print('Generating .buildenv (stub).');
    // implement detection logic here
  }
}
```

---

### `lib/flows/build_flow.dart`

```dart
import 'dart:io';
import 'package:mycli/core/app_context.dart';
import 'package:mycli/ui/menu.dart';
import 'package:mycli/utils/terminal.dart';

class BuildFlow {
  final AppContext ctx;
  BuildFlow(this.ctx);

  Future<void> execute() async {
    // Step 1: choose target
    final target = await Menu.select(
      title: 'Select build target',
      options: ['apk', 'aab', 'ipa', 'macos'],
      defaultIndex: 0,
    );
    // Step 2: choose flavor
    final flavor = await Menu.select(
      title: 'Select flavor',
      options: ['dev', 'staging', 'prod'],
      defaultIndex: 0,
    );
    // Step 3: confirm
    print('\nSummary:');
    print(' target: ${target}');
    print(' flavor: ${flavor}');
    stdout.write('Press Enter to confirm, or type "cancel": ');
    final confirm = stdin.readLineSync();
    if (confirm != null && confirm.trim().toLowerCase() == 'cancel') {
      print('Cancelled.');
      return;
    }

    // Step 4: execute build (simple wrapper)
    await _runFlutterBuild(target, flavor);
  }

  Future<void> _runFlutterBuild(String target, String flavor) async {
    final args = ['build', target];
    if (flavor.isNotEmpty) {
      args.addAll(['--flavor', flavor]);
    }
    print('Running: flutter ${args.join(' ')}');
    final proc = await Process.start('flutter', args);
    await stdout.addStream(proc.stdout);
    await stderr.addStream(proc.stderr);
    final code = await proc.exitCode;
    print('Build finished with exit code $code');
  }
}
```

---

### `lib/ui/shell.dart` (the REPL loop)

```dart
import 'dart:io';
import 'package:mycli/core/app_context.dart';
import 'package:mycli/flows/build_flow.dart';
import 'package:mycli/ui/menu.dart';
import 'package:mycli/command_router.dart';

class Shell {
  final AppContext ctx;
  Shell(this.ctx);

  Future<void> run() async {
    print('mycli interactive shell. Type "help" or "exit".');
    while (true) {
      stdout.write('mycli> ');
      final line = stdin.readLineSync();
      if (line == null) continue;
      final l = line.trim();
      if (l.isEmpty) continue;
      if (l == 'exit' || l == 'quit') {
        print('Goodbye.');
        break;
      }
      if (l == 'help') {
        print('Commands: build, clean, gen-env, exit');
        continue;
      }
      if (l == 'build') {
        final flow = BuildFlow(ctx);
        await flow.execute();
        continue;
      }
      // try to parse single command with args using simple split
      final parts = l.split(RegExp(r'\s+'));
      if (parts.isNotEmpty) {
        await _dispatch(parts);
      }
    }
  }

  Future<void> _dispatch(List<String> parts) async {
    final cmd = parts.first;
    final args = parts.length > 1 ? parts.sublist(1) : <String>[];
    // For demonstration we map simple ones
    switch (cmd) {
      case 'clean':
        await Process.run('flutter', ['clean']).then((r) {
          stdout.write(r.stdout);
          stderr.write(r.stderr);
        });
        break;
      case 'gen-env':
        print('gen-env (interactive stub)');
        break;
      case 'build':
        // allow inline flags e.g. build apk prod (target flavor)
        final target = args.isNotEmpty ? args[0] : 'apk';
        final flavor = args.length > 1 ? args[1] : 'dev';
        final flow = BuildFlow(ctx);
        await flow.execute();
        break;
      default:
        print('Unknown command: $cmd');
    }
  }
}
```

---

### `lib/ui/menu.dart` (arrow-key menu, cross-platform)

```dart
import 'dart:io';
import 'package:mycli/utils/terminal.dart';

class Menu {
  /// Returns selected option string.
  static Future<String> select({
    required String title,
    required List<String> options,
    int defaultIndex = 0,
  }) async {
    if (!_supportsRawMode()) {
      // fallback: print options and ask for number
      print(title);
      for (var i = 0; i < options.length; i++) {
        print('  ${i + 1}) ${options[i]}');
      }
      stdout.write('Choose [1-${options.length}]: ');
      final line = stdin.readLineSync();
      final idx = int.tryParse(line ?? '') ?? (defaultIndex + 1);
      final sel = options[(idx - 1).clamp(0, options.length - 1)];
      return sel;
    }

    // Raw mode menu with arrow navigation
    final selected = await _rawSelect(title, options, defaultIndex);
    return options[selected];
  }

  static bool _supportsRawMode() {
    try {
      // Windows: enableAnsi is required for colors; raw mode may throw if not supported
      stdin.lineMode = stdin.lineMode; // read to ensure available
      return true;
    } catch (_) {
      return false;
    }
  }

  static Future<int> _rawSelect(String title, List<String> options, int cur) async {
    stdin.lineMode = false;
    stdin.echoMode = false;
    final selected = cur;
    var index = cur;
    void redraw() {
      stdout.write('\x1B[2K\r'); // clear line
      stdout.writeln(title);
      for (var i = 0; i < options.length; i++) {
        if (i == index) {
          stdout.writeln('  > ${options[i]}');
        } else {
          stdout.writeln('    ${options[i]}');
        }
      }
      stdout.write('\x1B[${options.length + 1}A'); // move cursor up to keep menu in place
    }

    // initial draw
    redraw();
    final completer = Completer<int>();
    final sub = stdin.listen((data) {
      for (final b in data) {
        if (b == 13) { // Enter
          completer.complete(index);
        } else if (b == 27) {
          // Escape sequences start with 27, next are [ A/B for arrows
          // read next two bytes if available
        } else if (b == 91) {
          // bracket - ignore here (we will handle full sequences)
        } else if (b == 65) { // up
          index = (index - 1) % options.length;
          if (index < 0) index += options.length;
          redraw();
        } else if (b == 66) { // down
          index = (index + 1) % options.length;
          redraw();
        } else if (b == 3) { // Ctrl+C
          completer.completeError(ProcessException('Interrupted', []));
        }
      }
    });

    final result = await completer.future;
    await sub.cancel();
    // cleanup terminal
    stdin.lineMode = true;
    stdin.echoMode = true;
    stdout.writeln(); // newline after selection
    return result;
  }
}
```

> **Notes on the menu implementation:**
>
> * Terminal raw-mode handling differs on platforms. The provided `_rawSelect` is minimal and should be hardened (escape sequence buffering, Windows specifics). It gives a working, cross-platform-friendly starting point with a fallback to numeric input for environments where raw mode can't be used.
> * You can swap this with a mature package later (e.g., when available cross-platform TUI packages) or refine for complex UIs.

---

### `lib/utils/terminal.dart`

```dart
import 'dart:io';

void clearScreen() {
  if (Platform.isWindows) {
    stdout.write('\x1B[2J\x1B[0;0H');
  } else {
    stdout.write('\x1B[2J\x1B[H');
  }
}
```

---

# Implementation notes & next steps (short)

1. **Replace stubs**: implement `gen-env` logic (read `buildenv.base`, detect pubspec values).
2. **Harden menu**: properly parse multi-byte escape sequences (`27,91,65..`) and test on Windows `cmd`, PowerShell, and WSL. Consider using `win32` package if deep Windows console control is required.
3. **Add history & completion**: implement simple command history file and tab-complete logic if desired.
4. **Logging & JSON mode**: add `--json` flag for machine output and structured logs.
5. **Test on CI**: run integration tests that spawn the compiled binary and assert exit codes and artifacts.
6. **Compile & distribute**: `dart compile exe` for each platform; publish binaries in releases or create installer packages.

---

If you want, I can now:

* generate a **refined menu** with robust escape-sequence parsing, or
* produce a **ready-to-run repo zip** (all files filled) you can paste into your project.

Which next?
