From 82ec35cde32b8f8a523fafa6faa4adb890e11115 Mon Sep 17 00:00:00 2001 From: greymag Date: Tue, 25 Aug 2026 14:57:24 +0200 Subject: [PATCH 01/13] chore(git): ignore Claude Code local data The `.claude/worktrees/` directory holds nested git worktrees, so an accidental `git add -A` would vendor a whole second checkout of the repository. `.claude` itself is left tracked, so shared agent config can be committed later. Co-Authored-By: Claude Opus 5 (1M context) --- .gitignore | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 656ab4c..5d06118 100644 --- a/.gitignore +++ b/.gitignore @@ -21,4 +21,8 @@ pubspec.lock app_data/ # FVM Version Cache -.fvm/ \ No newline at end of file +.fvm/ + +# Claude Code local data +.claude/worktrees/ +.claude/settings.local.json From 2ba8b7ccc736455da4d1f1a3d95f3417b0751f29 Mon Sep 17 00:00:00 2001 From: greymag Date: Tue, 25 Aug 2026 14:57:31 +0200 Subject: [PATCH 02/13] docs: add roadmap of the agent support improvements Plan of making alex a first-class tool for AI coding agents: the backlog with priorities and reasons, the JSON output conventions and the order of work. Co-Authored-By: Claude Opus 5 (1M context) --- docs/agent-support-roadmap.md | 182 ++++++++++++++++++++++++++++++++++ 1 file changed, 182 insertions(+) create mode 100644 docs/agent-support-roadmap.md diff --git a/docs/agent-support-roadmap.md b/docs/agent-support-roadmap.md new file mode 100644 index 0000000..70f06a3 --- /dev/null +++ b/docs/agent-support-roadmap.md @@ -0,0 +1,182 @@ +# Agent support roadmap + +Plan for making `alex` a first-class tool for AI coding agents (the `flutter-dev`, +`flutter-reviewer`, `flutter-tester`, `flutter-qa`, `flutter-designer` role agents and +their skills: `gates`, `qa-screens`, `agent-profile-init`). + +Status legend: `[ ]` not started · `[~]` in progress · `[x]` done + +## Why + +Agents currently keep project knowledge in three places that drift apart: + +- `CLAUDE.md` / `AGENTS.md` — conventions as prose; +- `.claude/profile/*.md` — per-role facts (locales, doc paths, gate command); +- bash scripts in global skills (`gates.sh`, `qa_*.sh`) — which re-derive things + `alex` already knows (repo root, `fvm` vs plain `flutter`, output noise filtering, + the project's locale list). + +Meanwhile `alex.yaml` is already the single source of truth for locales, l10n paths, +branch names, build naming and workspace mode, and `FlutterCmd` already resolves `fvm`. + +Two systemic blockers on top of that: + +1. **No machine-readable output.** Nothing supports `--format=json`, so agents parse + human-oriented text — expensive in tokens and unreliable. +2. **Interactive prompts.** `feature finish`, `pubspec update`, `custom add` read from + stdin (`readLineSync`), so an agent either hangs or fails on them. + +The goal of this roadmap: everything an agent must verify becomes a deterministic +`alex` command with a stable exit code and a JSON report, instead of prose rules the +agent re-derives with greps each run. + +--- + +## Backlog + +### Tier 1 — highest leverage + +#### 1. `[x] alex code check` — the quality gates command + +Replaces the global `gates.sh` skill script. **Done** (see `CHANGELOG.md`). + +- Runs analyze + test (+ platform debug build with `--build`) with the project's runner + (`fvm flutter` via `FlutterCmd`; plain `dart` for a non-Flutter package). +- De-noises output (update banners, dependency chatter, third-party deprecations, + SwiftPM chatter, box-drawing borders); extendable via `code.check.noise`. +- Structured results: analyze issues are parsed into `{severity, file, line, column, + message, rule}`; tests run with `--reporter=json` so failures come with the test name, + suite and (trimmed) message — including suite compilation failures. +- Exit codes: `10` analyze, `11` test, `12` build. +- Flags: `--analyze-only`, `--test-only`, `--build`, `--build-target`, `--fail-fast`, + `--format=json`, args after `--` passed to the test command. + +Left for later: `--changed` (only tests affected by the working-tree diff); running the +gates per package in a pub workspace (now only the root is checked). + +#### 2. `[~] --format=json` for everything agents read + +Done for `code check` (JSON-native from day one) — the option, the stdout/stderr +contract and the envelope are in place (`--format` in `AlexCommand`, messages routed to +stderr in `alex_command_runner`). + +Remaining: `l10n check_translations`, `changelog`, `pubspec`. + +#### 3. `[ ] alex lint` — house conventions as code + +The checks `flutter-reviewer` currently performs by grep: + +- `bloc.add(` / `context.read<` / `BlocProvider.of` at call sites (must go through + context + bloc extension methods); +- day arithmetic via `.inDays` / `Duration.add|subtract` instead of DTU; +- hardcoded `Color(...)` / `Colors.*` (except `transparent`) instead of theme tokens; +- raw route strings instead of `AppRoutes` extensions; +- hardcoded UI strings instead of `context.l`; +- edits to generated files (`*.g.dart`, `*.freezed.dart`, generated l10n). + +Rules configured in `alex.yaml` (enable/disable, severity, path excludes). JSON output ++ non-zero exit on blockers. This is the item that turns half of code review from +probabilistic into deterministic — and shrinks the reviewer's prompt. + +#### 4. `[ ] Non-interactive mode` + +Every `readLineSync` gets a corresponding flag: + +- `feature finish --issue=N --changelog-section=… --entry="…"`; +- `pubspec update `; +- `custom add` (full definition from flags or a file). + +Plus a global `--non-interactive` that fails with an explicit message instead of +blocking on stdin. Without this agents cannot use `feature` / `release` at all. + +### Tier 2 — meaningful simplification + +#### 5. `[ ] alex info --format=json` + +One call instead of reading five files at the start of every agent run: locales, l10n +paths (ARB/XML/source), current version, branch names, fvm version, workspace package +list, whether generated code is out of sync. + +Companion: `alex agents profile` — generate/validate `.claude/profile/shared.md` from +`alex.yaml` so the profile cannot drift from reality (today `agent-profile-init` +infers those facts). + +#### 6. `[ ] alex l10n sync` + +One pipeline instead of "extract → generate → to_xml in the right order", with a report +of what changed. Agents should not have to remember the order. + +#### 7. `[ ] ICU plural completeness validator` + +`lib/src/l10n/validators/` currently holds only `require_latin_validator`. Add a +validator asserting every locale has all CLDR plural forms it requires (RU `few`/`many`, +SL dual `two`) — a hard rule repeated in three agent prompts today. Wire it into +`check_translations`. + +#### 8. `[ ] Codegen sync gate` (`alex code check --gen`) + +"Is generated code in sync with sources?" — run build_runner and assert the diff stays +clean. Closes the classic failure: model changed, regeneration forgotten, tester hits a +confusing error. Fits as a fourth gate of `alex code check` (exit code `13`). + +#### 9. `[ ] alex changelog add "…" --section=Added` + +Agents edit `CHANGELOG.md` by hand and put entries in the wrong place; the "new entry +goes into the top `## Next` section" rule lives only as prose in `CLAUDE.md`. + +### Tier 3 — to discuss + +#### 10. `[ ] alex db check-migration` + +From the git diff: `getCreateTablesSql` changed without a `version` bump and an additive +`upgrade` migration → blocker. The most expensive project rule (irreplaceable user +data), currently checked by grep. + +#### 11. `[ ] alex qa screens` + +Port `qa_sim.sh` / `qa_run.sh` / `qa_batch.sh` (scenario × locale × appearance matrix, +`SHOT:` markers) into alex, taking the locale list from config. Downside: heavily +iOS/simulator-specific, pulls a macOS dependency into a cross-platform CLI — may be +better left as a skill. + +#### 12. `[ ] Locks on shared resources` + +Parallel dev + tester agents collide on simultaneous `build_runner` / `pub get`. A +simple lock file inside `alex code gen` / `alex pubspec get`. + +--- + +## JSON output conventions (settled while implementing #1) + +- Opt-in via `--format=json` (`addFormatOption()` on the command's `argParser`); + the default stays `text` — the human output of existing commands never changes. +- JSON goes to **stdout only**: exactly one object, nothing else. Every other message — + progress, warnings, the update banner — goes to stderr. This is enforced centrally: + `AlexCommandRunner` detects the option (at any nesting level) and calls + `print.setMessagesOutputToStdErr()`; the result is printed with `print.result()`. +- Exit code stays the contract for success/failure; JSON carries the detail. +- Envelope: + +```json +{ + "alex": "1.15.0", + "command": "code check", + "ok": false, + "exitCode": 11, + "summary": "analyze: no issues | test: 61 passed, 1 failed | build: skipped", + "gates": [{ "name": "test", "status": "failed", "summary": "…", "durationMs": 25350 }] +} +``` + +- `alex` (version), `command`, `ok`, `exitCode`, `summary` are common for every command; + the payload key (`gates` here) is command-specific. +- Long free-form text (test failure messages, raw output of a failed command) is trimmed + before it gets into the report — an agent's context is a resource. + +## Order of work + +1. ~~`alex code check` (#1)~~ — done. +2. `--format=json` for `l10n check_translations` (#2). +3. Non-interactive mode (#4). +4. `alex lint` (#3) — largest, needs a decision on how configurable the rules are. +5. Then Tier 2 in listed order. From 5e5c2d9f189ab6c9edf68bac4c9906fee5b06727 Mon Sep 17 00:00:00 2001 From: greymag Date: Tue, 25 Aug 2026 14:57:43 +0200 Subject: [PATCH 03/13] feat(code): new `check` command with quality gates Runs analyze, tests and (optionally, with `--build`) a debug build of the platform target, so the whole "is it ready" check is a single command with a stable contract. - analyze output is parsed into structured issues, both `flutter analyze` and `dart analyze` formats are supported; - tests are run with the JSON reporter, so a failure is reported with the test name, the suite and a trimmed message - including a suite compilation error; - output is filtered from a noise (update banners, dependency resolution chatter, third-party deprecations), extra patterns can be added with the `code.check.noise` config option; - runner is resolved by the project type: `flutter` (with FVM support) or `dart` for a package without the Flutter dependency; - exit codes: 10 - analyze failed, 11 - tests failed, 12 - build failed. Also adds the common `--format=json` option: a command prints a single JSON object with a machine readable report in stdout, while all other messages - including the update banner - go to stderr. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 2 + README.md | 57 +++ alex.yaml | 10 + lib/commands/code/check_command.dart | 407 ++++++++++++++++++ lib/commands/code/code_command.dart | 2 + lib/internal/print.dart | 24 +- lib/runner/alex_command.dart | 20 + lib/runner/alex_command_runner.dart | 9 + lib/src/check/analyze_output_parser.dart | 99 +++++ lib/src/check/analyze_result.dart | 94 ++++ lib/src/check/check_report.dart | 116 +++++ lib/src/check/output_filter.dart | 86 ++++ lib/src/check/test_output_parser.dart | 138 ++++++ lib/src/check/test_run_result.dart | 70 +++ lib/src/config.dart | 41 +- lib/src/const.dart | 9 + .../src/check/analyze_output_parser_test.dart | 109 +++++ test/src/check/output_filter_test.dart | 85 ++++ test/src/check/test_output_parser_test.dart | 134 ++++++ 19 files changed, 1508 insertions(+), 4 deletions(-) create mode 100644 lib/commands/code/check_command.dart create mode 100644 lib/src/check/analyze_output_parser.dart create mode 100644 lib/src/check/analyze_result.dart create mode 100644 lib/src/check/check_report.dart create mode 100644 lib/src/check/output_filter.dart create mode 100644 lib/src/check/test_output_parser.dart create mode 100644 lib/src/check/test_run_result.dart create mode 100644 test/src/check/analyze_output_parser_test.dart create mode 100644 test/src/check/output_filter_test.dart create mode 100644 test/src/check/test_output_parser_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 445abb2..80935c5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ * [Release] `start`: fix the length limit of the release notes in the form. The limit is enforced by alex now, instead of the `maxlength` attribute: a browser counts a newline as CRLF (2 characters), so a note with N newlines was cut N characters earlier than the store allows, and it was impossible to type anything while the counter still showed a value below the limit. The counter shows the number of characters as the store counts them, and it's highlighted if the value is longer than the limit. * **Fixed**: alex hangs if a command it runs asks for an input. For example, `alex code gen` stopped with no output when `fvm` asked to install a missing Flutter SDK. Such a question can't be answered anyway, because the command gets a pipe instead of the terminal, so its input is closed right after the start now, and the command fails with its own message instead of hanging. * **Fixed**: the output of a command could be lost, so a failed command could be reported without its error message. Now alex waits for the output to be fully read before it handles the result. +* [Code] New command `check`: runs the quality gates - analyze, tests and (optionally, with the `--build` flag) a debug build of the platform target. Output of the commands is filtered from a noise, a short verdict is printed for each gate. Exit code is `0` if all gates passed, `10` if analyze failed, `11` if tests failed, `12` if build failed. Flags `--analyze-only`, `--test-only` and `--fail-fast` are supported, arguments after `--` are passed to the test command. Build target and additional noise patterns can be defined with the new `code.check` config section. +* [Common] New `--format=json` option (supported by `code check` for now): a command prints a single JSON object with a machine readable report in the standard output, all other messages - including the update banner - go to the error output. ## 1.14.0 diff --git a/README.md b/README.md index b71edf3..c0a3845 100644 --- a/README.md +++ b/README.md @@ -382,6 +382,63 @@ Generate `JsonSerializable` and other. $ alex code gen ``` +#### Check code quality + +Run the quality gates: analyze, tests and (optionally) a debug build of the platform target. +Output of the commands is filtered from a noise (update banners, dependency resolution +chatter, deprecation notices of third-party plugins), a short verdict is printed for each gate. + +```bash +$ alex code check +``` + +```bash +$ alex code check --analyze-only # fast inner loop check +$ alex code check --build # also compile the platform target +$ alex code check --fail-fast # stop on the first failed gate +$ alex code check -- test/some_test.dart # args after `--` are passed to the test command +``` + +Exit code is `0` if all gates passed, `10` if analyze failed, `11` if tests failed, +`12` if build failed. Other exit codes are used for errors. + +For a machine readable report (useful for CI and for AI agents) use `--format=json`: +a single JSON object is printed in the standard output, all other messages go to the +error output. + +```bash +$ alex code check --format=json +``` + +```json +{ + "alex": "1.15.0", + "command": "code check", + "ok": false, + "exitCode": 11, + "summary": "analyze: no issues | test: 61 passed, 1 failed | build: skipped", + "gates": [ + {"name": "analyze", "status": "passed", "summary": "no issues", "durationMs": 1109, + "count": 0, "errors": 0, "warnings": 0, "infos": 0, "issues": []}, + {"name": "test", "status": "failed", "summary": "61 passed, 1 failed", "durationMs": 25350, + "passed": 61, "failed": 1, "skipped": 0, "total": 62, "completed": true, + "failures": [{"name": "should work", "suite": "test/a_test.dart", "message": "Expected: ..."}]}, + {"name": "build", "status": "skipped", "summary": "skipped"} + ] +} +``` + +Build target and additional noise patterns can be defined in the config: + +```yaml +code: + check: + # ios (default on macOS) | apk (default on other platforms) | appbundle | web | macos + build_target: ios + # additional regular expressions of the output lines to hide + noise: [ 'some_noisy_plugin' ] +``` + ### Pubspec Work with pubspec and dependencies. diff --git a/alex.yaml b/alex.yaml index 4c62135..b3bab89 100644 --- a/alex.yaml +++ b/alex.yaml @@ -27,6 +27,16 @@ code: # `--workspace` flag instead of per-package. Set to false to disable. use_workspace: true + # Quality gates (alex code check) + check: + # Target of the build gate (used with the --build flag): + # ios | apk | appbundle | web | macos. + # If not defined - ios is used on macOS and apk on other platforms. + build_target: + # Additional patterns (regular expressions) of the output lines + # that should be hidden as a noise. + noise: [] + # Application build settings (alex release start --local) build: # Name for build artifact files. diff --git a/lib/commands/code/check_command.dart b/lib/commands/code/check_command.dart new file mode 100644 index 0000000..9ec4057 --- /dev/null +++ b/lib/commands/code/check_command.dart @@ -0,0 +1,407 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:alex/internal/print.dart' as print; +import 'package:alex/runner/alex_command.dart'; +import 'package:alex/src/check/analyze_output_parser.dart'; +import 'package:alex/src/check/check_report.dart'; +import 'package:alex/src/check/output_filter.dart'; +import 'package:alex/src/check/test_output_parser.dart'; +import 'package:alex/src/check/test_run_result.dart'; +import 'package:alex/src/config.dart'; +import 'package:alex/src/fs/fs.dart'; +import 'package:alex/src/pub_spec.dart'; +import 'package:alex/src/version.dart'; + +import 'src/code_command_base.dart'; + +const _kBuildTargets = ['ios', 'apk', 'appbundle', 'web', 'macos']; + +/// Maximum count of the analyzer issues to print in the text output. +const _kMaxPrintedIssues = 50; + +/// Maximum count of the failed tests to print in the text output. +const _kMaxPrintedFailures = 20; + +/// Maximum count of lines of a single test failure message to print. +const _kMaxPrintedFailureMessageLines = 12; + +/// Command to run quality gates: analyze, tests and (optionally) build. +class CheckCommand extends CodeCommandBase { + static const _argAnalyzeOnly = 'analyze-only'; + static const _argTestOnly = 'test-only'; + static const _argBuild = 'build'; + static const _argBuildTarget = 'build-target'; + static const _argFailFast = 'fail-fast'; + + CheckCommand() + : super( + 'check', + 'Run quality gates: analyze, tests and (optionally) a debug build.\n\n' + 'Output of the commands is filtered from a noise, ' + 'a short verdict is printed for each gate.\n\n' + 'Exit code is 0 if all gates passed, ' + '$kExitCodeAnalyzeFailed if analyze failed, ' + '$kExitCodeTestFailed if tests failed, ' + '$kExitCodeBuildFailed if build failed. ' + 'Other exit codes are used for errors.\n\n' + 'Arguments after `--` are passed to the test command, ' + 'for example: alex code check -- test/some_test.dart', + ) { + argParser + ..addFlag( + _argAnalyzeOnly, + help: 'Run the analyze gate only.', + negatable: false, + ) + ..addFlag( + _argTestOnly, + help: 'Run the test gate only.', + negatable: false, + ) + ..addFlag( + _argBuild, + help: 'Also run a debug build of the platform target.', + negatable: false, + ) + ..addOption( + _argBuildTarget, + help: 'Target for the build gate. ' + 'By default the value of the `code.check.build_target` ' + 'config option is used, or `ios` on macOS and `apk` on ' + 'other platforms.', + allowed: _kBuildTargets, + ) + ..addFlag( + _argFailFast, + help: 'Stop on the first failed gate.', + negatable: false, + ) + ..addFormatOption() + ..addVerboseFlutterCmdFlag(); + } + + late OutputFilter _filter; + late bool _isFlutterProject; + + @override + Future doRun() async { + final config = findConfigAndSetWorkingDir(); + final checkConfig = config.code.check; + final args = argResults!; + final isJson = args.isJsonFormat(); + + final analyzeOnly = args[_argAnalyzeOnly] as bool; + final testOnly = args[_argTestOnly] as bool; + + if (analyzeOnly && testOnly) { + return error(2, + message: 'You can not use --$_argAnalyzeOnly ' + 'and --$_argTestOnly together.'); + } + + final needAnalyze = !testOnly; + final needTest = !analyzeOnly; + final needBuild = (args[_argBuild] as bool) && !analyzeOnly && !testOnly; + final failFast = args[_argFailFast] as bool; + + _filter = OutputFilter(extraPatterns: checkConfig.noise); + _isFlutterProject = await _checkIsFlutterProject(); + printVerbose('Project type: ${_isFlutterProject ? 'flutter' : 'dart'}'); + + final gates = []; + + Future runGate( + CheckGate gate, + Future Function() run, { + required bool need, + String skipReason = 'skipped', + }) async { + if (!need || (failFast && gates.any((g) => g.isFailed))) { + gates.add(CheckGateResult.skipped(gate, summary: skipReason)); + return; + } + + printInfo('Running ${gate.key}...'); + final result = await run(); + if (!isJson) _printGate(result); + gates.add(result); + } + + await runGate(CheckGate.analyze, _runAnalyze, need: needAnalyze); + await runGate(CheckGate.test, _runTest, need: needTest); + await runGate( + CheckGate.build, + () => _runBuild( + args[_argBuildTarget] as String? ?? _defaultBuildTarget(checkConfig), + ), + need: needBuild, + ); + + final report = CheckReport(alexVersion: packageVersion, gates: gates); + + if (isJson) { + print.result(jsonEncode(report.toJson())); + } else { + printInfo(''); + printInfo('=== alex code check: ${report.ok ? 'PASS' : 'FAIL'} ==='); + printInfo(report.summary); + } + + return report.exitCode; + } + + // region Gates + + Future _runAnalyze() async { + final stopwatch = Stopwatch()..start(); + final res = await _run(const ['analyze']); + stopwatch.stop(); + + final output = _mergeOutput(res); + final result = AnalyzeOutputParser.parse(output); + final isFailed = result.hasIssues || res.exitCode != 0; + + // If the command failed, but no issues were recognized - + // it's a failure of the command itself, so the output should be shown. + final hasUnexplainedFailure = isFailed && !result.hasIssues; + + return CheckGateResult( + gate: CheckGate.analyze, + status: isFailed ? CheckGateStatus.failed : CheckGateStatus.passed, + durationMs: stopwatch.elapsedMilliseconds, + summary: result.hasIssues + ? '${result.count} issue(s)' + : (isFailed ? 'failed' : 'no issues'), + details: result.toJson(), + output: hasUnexplainedFailure ? _filter.filterTail(output) : null, + ); + } + + Future _runTest() async { + if (!Directory('test').existsSync()) { + return const CheckGateResult.skipped(CheckGate.test, + summary: 'skipped (no test directory)'); + } + + final rest = argResults!.rest; + final hasCustomReporter = rest.any((a) => a.startsWith('--reporter')); + + final stopwatch = Stopwatch()..start(); + final res = await _run([ + 'test', + if (!hasCustomReporter) '--reporter=json', + ...rest, + ]); + stopwatch.stop(); + + final output = _mergeOutput(res); + final result = hasCustomReporter + ? const TestRunResult.unknown() + : TestOutputParser.parse(res.stdout.toString()); + // If the output was not parsed - only the exit code can be relied on. + final isFailed = + res.exitCode != 0 || (!hasCustomReporter && !result.isSuccess); + + final String summary; + if (!result.completed) { + summary = isFailed ? 'failed (see output)' : 'done'; + } else { + summary = [ + '${result.passed} passed', + if (result.failed > 0) '${result.failed} failed', + if (result.skipped > 0) '${result.skipped} skipped', + ].join(', '); + } + + return CheckGateResult( + gate: CheckGate.test, + status: isFailed ? CheckGateStatus.failed : CheckGateStatus.passed, + durationMs: stopwatch.elapsedMilliseconds, + summary: summary, + details: result.toJson(), + output: isFailed && result.failures.isEmpty + ? _filter.filterTail(output) + : null, + ); + } + + Future _runBuild(String target) async { + if (!_isFlutterProject) { + return const CheckGateResult.skipped(CheckGate.build, + summary: 'skipped (not a flutter project)'); + } + + final stopwatch = Stopwatch()..start(); + final res = await _run(_buildArguments(target)); + stopwatch.stop(); + + final isFailed = res.exitCode != 0; + final output = _mergeOutput(res); + + return CheckGateResult( + gate: CheckGate.build, + status: isFailed ? CheckGateStatus.failed : CheckGateStatus.passed, + durationMs: stopwatch.elapsedMilliseconds, + summary: isFailed ? 'failed ($target)' : 'ok ($target)', + details: {'target': target}, + output: isFailed ? _filter.filterTail(output) : null, + ); + } + + List _buildArguments(String target) { + switch (target) { + case 'ios': + return const ['build', 'ios', '--debug', '--no-codesign']; + case 'apk': + return const ['build', 'apk', '--debug']; + case 'appbundle': + return const ['build', 'appbundle', '--debug']; + case 'web': + return const ['build', 'web']; + case 'macos': + return const ['build', 'macos', '--debug']; + default: + throw ArgumentError.value(target, 'target', 'Unknown build target'); + } + } + + String _defaultBuildTarget(CodeCheckConfig config) => + config.buildTarget ?? (Platform.isMacOS ? 'ios' : 'apk'); + + // endregion Gates + + // region Run + + Future _run(List arguments) async { + printVerbose('Run: ${arguments.join(' ')}'); + + final ProcessResult res; + if (_isFlutterProject) { + res = await flutter.run( + arguments.first, + arguments: arguments.sublist(1), + immediatePrintStd: false, + immediatePrintErr: false, + ); + } else { + res = await cmd.run( + 'dart', + arguments: arguments, + immediatePrintStd: false, + immediatePrintErr: false, + ); + } + + if (isVerbose) { + printVerbose('Exit code: ${res.exitCode}'); + final output = _mergeOutput(res); + if (output.trim().isNotEmpty) printVerbose(output); + } + + return res; + } + + String _mergeOutput(ProcessResult res) { + final out = res.stdout?.toString() ?? ''; + final err = res.stderr?.toString() ?? ''; + if (err.trim().isEmpty) return out; + if (out.trim().isEmpty) return err; + return '$out\n$err'; + } + + Future _checkIsFlutterProject() async { + try { + final spec = await Spec.pub(const IOFileSystem()); + return spec.dependsOn('flutter'); + } on Object catch (e) { + printVerbose("Can't detect a project type: $e"); + return true; + } + } + + // endregion Run + + // region Text output + + void _printGate(CheckGateResult gate) { + final duration = gate.durationMs != null + ? ' [${(gate.durationMs! / 1000).toStringAsFixed(1)}s]' + : ''; + printInfo('${gate.gate.key}: ${_statusLabel(gate.status)} - ' + '${gate.summary}$duration'); + + switch (gate.gate) { + case CheckGate.analyze: + _printAnalyzeIssues(gate); + break; + case CheckGate.test: + _printTestFailures(gate); + break; + case CheckGate.build: + break; + } + + final output = gate.output; + if (output != null && output.trim().isNotEmpty) printInfo(output); + } + + String _statusLabel(CheckGateStatus status) { + switch (status) { + case CheckGateStatus.passed: + return 'OK'; + case CheckGateStatus.failed: + return 'FAILED'; + case CheckGateStatus.skipped: + return 'SKIPPED'; + } + } + + void _printAnalyzeIssues(CheckGateResult gate) { + final issues = gate.details?['issues'] as List? ?? const []; + if (issues.isEmpty) return; + + final printed = issues.take(_kMaxPrintedIssues); + for (final issue in printed) { + final data = issue as Map; + final rule = data['rule'] as String?; + printInfo(' ${data['severity']} - ' + '${data['file']}:${data['line']}:${data['column']} - ' + '${data['message']}' + '${rule != null ? ' - $rule' : ''}'); + } + + final rest = issues.length - printed.length; + if (rest > 0) printInfo(' ... and $rest more issue(s)'); + } + + void _printTestFailures(CheckGateResult gate) { + final failures = gate.details?['failures'] as List? ?? const []; + if (failures.isEmpty) return; + + final printed = failures.take(_kMaxPrintedFailures); + for (final failure in printed) { + final data = failure as Map; + final suite = data['suite'] as String?; + printInfo(' FAILED: ${data['name']}' + '${suite != null ? ' ($suite)' : ''}'); + + final message = data['message'] as String?; + if (message != null && message.trim().isNotEmpty) { + final lines = message.trim().split('\n'); + final visible = lines.take(_kMaxPrintedFailureMessageLines); + for (final line in visible) { + printInfo(' $line'); + } + if (lines.length > visible.length) { + printInfo(' ... ${lines.length - visible.length} line(s) more'); + } + } + } + + final rest = failures.length - printed.length; + if (rest > 0) printInfo(' ... and $rest more failed test(s)'); + } + +// endregion Text output +} diff --git a/lib/commands/code/code_command.dart b/lib/commands/code/code_command.dart index acf1156..aac88aa 100644 --- a/lib/commands/code/code_command.dart +++ b/lib/commands/code/code_command.dart @@ -1,11 +1,13 @@ import 'package:alex/runner/alex_command.dart'; +import 'check_command.dart'; import 'generate_command.dart'; /// Command to work with code. class CodeCommand extends AlexCommand { CodeCommand() : super('code', 'Work with code') { addSubcommand(GenerateCommand()); + addSubcommand(CheckCommand()); } @override diff --git a/lib/internal/print.dart b/lib/internal/print.dart index 0f4143f..9a22847 100644 --- a/lib/internal/print.dart +++ b/lib/internal/print.dart @@ -5,8 +5,20 @@ import 'package:logging/logging.dart'; const _kVerbosePrefix = '[verbose] '; final _verboseIndent = ' ' * _kVerbosePrefix.length; +bool _useStdErrForMessages = false; + +/// Redirects all messages (info and verbose) to the error output. +/// +/// Should be used when the standard output is reserved for a machine readable +/// result of a command (see `--format=json`), so any other message +/// can't be mixed with it. +void setMessagesOutputToStdErr({bool value = true}) => + _useStdErrForMessages = value; + +IOSink get _messagesSink => _useStdErrForMessages ? stderr : stdout; + /// Prints some info message in output. -void info(String message) => stdout.writeln(message); +void info(String message) => _messagesSink.writeln(message); /// Prints error message in error output. void error(String message) => stderr.writeln(message); @@ -17,15 +29,21 @@ void exception(Object message, [StackTrace? stackTrace]) => /// Prints some verbose message in output. void verbose(String message) { + final sink = _messagesSink; final lines = message.split('\n'); for (var i = 0; i < lines.length; i++) { final line = lines[i]; final prefix = i == 0 ? _kVerbosePrefix : _verboseIndent; - // ignore: avoid_print - print('$prefix$line'); + sink.writeln('$prefix$line'); } } +/// Prints a result of a command in the standard output. +/// +/// Should be used only for a machine readable result, +/// see [setMessagesOutputToStdErr]. +void result(String value) => stdout.writeln(value); + void setupRootLogger({bool isVerbose = false}) { setRootLoggerLevel(isVerbose: isVerbose); _setupLogger(Logger.root); diff --git a/lib/runner/alex_command.dart b/lib/runner/alex_command.dart index 8e59fec..2253c76 100644 --- a/lib/runner/alex_command.dart +++ b/lib/runner/alex_command.dart @@ -222,6 +222,22 @@ extension CmdArgArgParserExtension on ArgParser { void addVerboseFlag() => addFlag(kVerbose, help: 'Show additional diagnostic info'); + /// Adds the `--format` option. + /// + /// In the [kFormatJson] mode a command prints a single JSON object + /// in the standard output, all other messages go to the error output. + void addFormatOption() => addOption( + kFormat, + help: 'Output format.', + allowed: const [kFormatText, kFormatJson], + allowedHelp: const { + kFormatText: 'Human readable output.', + kFormatJson: 'Single JSON object in stdout ' + '(all other messages go to stderr).', + }, + defaultsTo: kFormatText, + ); + void addVerboseFlutterCmdFlag() => addFlagArg( _kArgVerboseFlutterCmd, help: 'All flutter commands will be run with verbose flag', @@ -234,6 +250,10 @@ extension CmdArgArgResultsExtension on ArgResults { bool isVerbose() => this[kVerbose] as bool; + /// Returns `true` if the machine readable output is requested. + bool isJsonFormat() => + options.contains(kFormat) && this[kFormat] == kFormatJson; + int? getInt(CmdArg arg) { final val = this[arg.name] as String?; return val == null ? null : int.tryParse(val); diff --git a/lib/runner/alex_command_runner.dart b/lib/runner/alex_command_runner.dart index 98b402a..6a51bac 100644 --- a/lib/runner/alex_command_runner.dart +++ b/lib/runner/alex_command_runner.dart @@ -96,6 +96,10 @@ class AlexCommandRunner extends CommandRunner { final version = topLevelResults[_argVersion] as bool; final isVerbose = _hasVerbose(topLevelResults); + // In the machine readable mode stdout is reserved for the result, + // so all messages - including the update banner - go to stderr. + if (_hasJsonFormat(topLevelResults)) print.setMessagesOutputToStdErr(); + print.setupRootLogger(isVerbose: isVerbose); // do not execute check for "update" command and its subcommands @@ -132,4 +136,9 @@ class AlexCommandRunner extends CommandRunner { return results != null && (results.isVerbose() || _hasVerbose(results.command)); } + + bool _hasJsonFormat(ArgResults? results) { + return results != null && + (results.isJsonFormat() || _hasJsonFormat(results.command)); + } } diff --git a/lib/src/check/analyze_output_parser.dart b/lib/src/check/analyze_output_parser.dart new file mode 100644 index 0000000..82994d2 --- /dev/null +++ b/lib/src/check/analyze_output_parser.dart @@ -0,0 +1,99 @@ +import 'package:alex/src/check/analyze_result.dart'; + +/// Parser of the `flutter analyze` / `dart analyze` output. +/// +/// Supports both formats: +/// - `flutter analyze`: `severity • message • file:line:column • rule` +/// - `dart analyze`: `severity - file:line:column - message - rule` +class AnalyzeOutputParser { + /// `severity • message • file:line:column • rule`. + static final _flutterIssue = RegExp( + r'^\s*(\w+)\s+•\s+(.+?)\s+•\s+(\S+):(\d+):(\d+)\s*(?:•\s+(\S+))?\s*$'); + + /// `severity - file:line:column - message - rule`. + static final _dartIssue = RegExp( + r'^\s*(\w+)\s+-\s+(\S+):(\d+):(\d+)\s+-\s+(.+?)\s*(?:-\s+(\S+))?\s*$'); + + /// `N issues found.` / `1 issue found.` / `No issues found!`. + static final _totalCount = RegExp(r'^\s*(\d+)\s+issues?\s+found'); + static final _noIssues = RegExp(r'^\s*No issues found'); + + const AnalyzeOutputParser._(); + + /// Parses the analyzer [output]. + static AnalyzeResult parse(String output) { + final issues = []; + int? reportedCount; + + for (final line in output.split('\n')) { + if (_noIssues.hasMatch(line)) { + reportedCount = 0; + continue; + } + + final total = _totalCount.firstMatch(line); + if (total != null) { + reportedCount = int.tryParse(total.group(1)!); + continue; + } + + final issue = _parseIssue(line); + if (issue != null) issues.add(issue); + } + + return AnalyzeResult(issues: issues, reportedCount: reportedCount); + } + + static AnalyzeIssue? _parseIssue(String line) { + final flutterMatch = _flutterIssue.firstMatch(line); + if (flutterMatch != null) { + return _issue( + severity: flutterMatch.group(1)!, + message: flutterMatch.group(2)!, + file: flutterMatch.group(3)!, + line: flutterMatch.group(4)!, + column: flutterMatch.group(5)!, + rule: flutterMatch.group(6), + ); + } + + final dartMatch = _dartIssue.firstMatch(line); + if (dartMatch != null) { + return _issue( + severity: dartMatch.group(1)!, + message: dartMatch.group(5)!, + file: dartMatch.group(2)!, + line: dartMatch.group(3)!, + column: dartMatch.group(4)!, + rule: dartMatch.group(6), + ); + } + + return null; + } + + static AnalyzeIssue? _issue({ + required String severity, + required String message, + required String file, + required String line, + required String column, + String? rule, + }) { + final parsedSeverity = AnalyzeIssueSeverity.byName(severity); + if (parsedSeverity == null) return null; + + final parsedLine = int.tryParse(line); + final parsedColumn = int.tryParse(column); + if (parsedLine == null || parsedColumn == null) return null; + + return AnalyzeIssue( + severity: parsedSeverity, + message: message.trim(), + file: file, + line: parsedLine, + column: parsedColumn, + rule: rule?.trim().isNotEmpty == true ? rule!.trim() : null, + ); + } +} diff --git a/lib/src/check/analyze_result.dart b/lib/src/check/analyze_result.dart new file mode 100644 index 0000000..11369bd --- /dev/null +++ b/lib/src/check/analyze_result.dart @@ -0,0 +1,94 @@ +/// Severity of an issue reported by `flutter analyze` / `dart analyze`. +enum AnalyzeIssueSeverity { + info, + warning, + error; + + /// Returns severity by its name in the analyzer output + /// or `null` if the name is unknown. + static AnalyzeIssueSeverity? byName(String value) { + switch (value.toLowerCase()) { + case 'info': + case 'hint': + case 'lint': + return AnalyzeIssueSeverity.info; + case 'warning': + return AnalyzeIssueSeverity.warning; + case 'error': + return AnalyzeIssueSeverity.error; + default: + return null; + } + } +} + +/// Single issue from the analyzer output. +class AnalyzeIssue { + final AnalyzeIssueSeverity severity; + final String message; + final String file; + final int line; + final int column; + final String? rule; + + const AnalyzeIssue({ + required this.severity, + required this.message, + required this.file, + required this.line, + required this.column, + this.rule, + }); + + Map toJson() => { + 'severity': severity.name, + 'message': message, + 'file': file, + 'line': line, + 'column': column, + if (rule != null) 'rule': rule, + }; + + @override + String toString() => '$file:$line:$column • ${severity.name} • $message' + '${rule != null ? ' • $rule' : ''}'; +} + +/// Result of the analyzer run. +class AnalyzeResult { + /// Issues parsed from the output. + final List issues; + + /// Total count of issues reported by the analyzer itself + /// (from the `N issues found.` line), if it was found in the output. + /// + /// May differ from [issues] length if some lines were not recognized. + final int? reportedCount; + + const AnalyzeResult({ + required this.issues, + this.reportedCount, + }); + + /// Count of issues to report: the analyzer's own count if it's available + /// and greater than the count of the parsed ones, otherwise the parsed one. + int get count { + final parsed = issues.length; + final reported = reportedCount; + if (reported == null) return parsed; + return reported > parsed ? reported : parsed; + } + + bool get hasIssues => count > 0; + + int countBySeverity(AnalyzeIssueSeverity severity) => + issues.where((i) => i.severity == severity).length; + + Map toJson() => { + 'count': count, + 'errors': countBySeverity(AnalyzeIssueSeverity.error), + 'warnings': countBySeverity(AnalyzeIssueSeverity.warning), + 'infos': countBySeverity(AnalyzeIssueSeverity.info), + 'issues': issues.map((i) => i.toJson()).toList(), + }; +} diff --git a/lib/src/check/check_report.dart b/lib/src/check/check_report.dart new file mode 100644 index 0000000..33a8960 --- /dev/null +++ b/lib/src/check/check_report.dart @@ -0,0 +1,116 @@ +/// Exit code if the analyze gate failed. +const kExitCodeAnalyzeFailed = 10; + +/// Exit code if the test gate failed. +const kExitCodeTestFailed = 11; + +/// Exit code if the build gate failed. +const kExitCodeBuildFailed = 12; + +/// Quality gate of the `alex code check` command. +enum CheckGate { + analyze('analyze', kExitCodeAnalyzeFailed), + test('test', kExitCodeTestFailed), + build('build', kExitCodeBuildFailed); + + final String key; + + /// Exit code of the command if this gate failed. + final int failureExitCode; + + const CheckGate(this.key, this.failureExitCode); +} + +/// Status of a quality gate. +enum CheckGateStatus { + passed, + failed, + skipped; + + bool get isFailed => this == CheckGateStatus.failed; +} + +/// Result of a single quality gate. +class CheckGateResult { + final CheckGate gate; + final CheckGateStatus status; + + /// Duration of the gate run in milliseconds, `null` if it was skipped. + final int? durationMs; + + /// Short human readable summary, for example `2 issues`. + final String summary; + + /// Gate specific data. + final Map? details; + + /// Output of the failed command (filtered from noise). + /// + /// Provided only if there is no structured [details] to explain a failure. + final String? output; + + const CheckGateResult({ + required this.gate, + required this.status, + required this.summary, + this.durationMs, + this.details, + this.output, + }); + + const CheckGateResult.skipped(this.gate, {this.summary = 'skipped'}) + : status = CheckGateStatus.skipped, + durationMs = null, + details = null, + output = null; + + bool get isFailed => status.isFailed; + + Map toJson() => { + 'name': gate.key, + 'status': status.name, + 'summary': summary, + if (durationMs != null) 'durationMs': durationMs, + if (details != null) ...details!, + if (output != null) 'output': output, + }; +} + +/// Report of the `alex code check` run. +class CheckReport { + /// Version of alex. + final String alexVersion; + + /// Results of the gates in the order of the run. + final List gates; + + const CheckReport({ + required this.alexVersion, + required this.gates, + }); + + /// `true` if no gate failed. + bool get ok => gates.every((g) => !g.isFailed); + + /// Exit code of the command: a code of the first failed gate or `0`. + int get exitCode { + for (final gate in gates) { + if (gate.isFailed) return gate.gate.failureExitCode; + } + return 0; + } + + /// Short summary of all gates, for example + /// `analyze: 2 issues | test: 40 passed | build: skipped`. + String get summary => + gates.map((g) => '${g.gate.key}: ${g.summary}').join(' | '); + + Map toJson() => { + 'alex': alexVersion, + 'command': 'code check', + 'ok': ok, + 'exitCode': exitCode, + 'summary': summary, + 'gates': gates.map((g) => g.toJson()).toList(), + }; +} diff --git a/lib/src/check/output_filter.dart b/lib/src/check/output_filter.dart new file mode 100644 index 0000000..47a2f5a --- /dev/null +++ b/lib/src/check/output_filter.dart @@ -0,0 +1,86 @@ +/// Filter for a noisy output of `flutter`/`dart` commands. +/// +/// Removes lines that are not related to the result of a check: +/// update banners, box-drawing borders, dependency resolution chatter, +/// deprecation notices of third-party plugins, progress indicators. +class OutputFilter { + /// Patterns of the lines that are considered as a noise by default. + static const defaultNoisePatterns = [ + // Box drawing borders of flutter banners. + r'^\s*[╔╚║╭╰│┌└├┤┬┴┼─═╞╡]', + // Update banners. + 'A new version of Flutter is available', + r'^\s*To update,? run:', + r'^\s*flutter upgrade\s*$', + 'Changelog: https://', + // Dependency resolution chatter. + r'^\s*Running "flutter pub get"', + r'^\s*Resolving dependencies', + r'^\s*Got dependencies', + r'^\s*Downloading ', + // Third-party deprecations - not an issue of the project itself. + 'uses a deprecated version of the Android embedding', + r'^\s*Note: .* uses (or overrides )?a deprecated API', + r'^\s*Note: Recompile with -Xlint', + r'^\s*warning: .* is deprecated', + // Swift Package Manager chatter. + 'Swift Package Manager', + r'^\s*swift-', + // Locks and progress. + 'Waiting for another flutter command to release the startup lock', + r'^\s*\d+% ', + ]; + + final List _patterns; + + OutputFilter({Iterable extraPatterns = const []}) + : _patterns = [ + ...defaultNoisePatterns, + ...extraPatterns, + ].map(RegExp.new).toList(growable: false); + + /// Returns `true` if the [line] should be hidden. + bool isNoise(String line) => _patterns.any((p) => p.hasMatch(line)); + + /// Returns lines of the [output] without noise. + /// + /// Blank lines are collapsed: leading and trailing ones are removed, + /// a sequence of blank lines is replaced with a single one. + List filterLines(String output) { + final res = []; + var pendingBlank = false; + + for (final line in output.split('\n')) { + final value = line.trimRight(); + + if (isNoise(value)) continue; + + if (value.trim().isEmpty) { + if (res.isNotEmpty) pendingBlank = true; + continue; + } + + if (pendingBlank) { + res.add(''); + pendingBlank = false; + } + + res.add(value); + } + + return res; + } + + /// Returns the [output] without noise. + String filter(String output) => filterLines(output).join('\n'); + + /// Returns not more than [maxLines] last lines of the [output] without noise. + String filterTail(String output, {int maxLines = 30}) { + final lines = filterLines(output); + if (lines.length <= maxLines) return lines.join('\n'); + return [ + '... ${lines.length - maxLines} line(s) skipped ...', + ...lines.sublist(lines.length - maxLines), + ].join('\n'); + } +} diff --git a/lib/src/check/test_output_parser.dart b/lib/src/check/test_output_parser.dart new file mode 100644 index 0000000..0ba9abf --- /dev/null +++ b/lib/src/check/test_output_parser.dart @@ -0,0 +1,138 @@ +import 'dart:convert'; + +import 'package:alex/src/check/test_run_result.dart'; + +/// Parser of the `flutter test --reporter=json` output. +/// +/// See the format description in the `package:test` documentation: +/// each line of the output is a JSON object with an event. +class TestOutputParser { + /// Maximum count of lines of a failure message to keep. + /// + /// Messages of the failed tests can be huge (a diff, a long stack trace), + /// and the report is expected to be readable - both for a human + /// and for a machine. + static const maxMessageLines = 30; + + const TestOutputParser._(); + + /// Parses the test runner [output]. + static TestRunResult parse(String output) { + final suites = {}; + final tests = {}; + final errors = {}; + + var passed = 0; + var failed = 0; + var skipped = 0; + var completed = false; + var hasEvents = false; + + final failures = []; + + for (final line in output.split('\n')) { + final event = _decode(line); + if (event == null) continue; + hasEvents = true; + + switch (event['type'] as String?) { + case 'suite': + final suite = event['suite'] as Map?; + final id = suite?['id'] as int?; + final path = suite?['path'] as String?; + if (id != null && path != null) suites[id] = path; + break; + case 'testStart': + final test = event['test'] as Map?; + final id = test?['id'] as int?; + if (id == null) break; + tests[id] = _TestInfo( + name: test?['name'] as String? ?? 'unknown', + suiteId: test?['suiteID'] as int?, + ); + break; + case 'error': + final id = event['testID'] as int?; + if (id == null) break; + final error = (event['error'] as String?)?.trim(); + if (error != null && error.isNotEmpty) { + errors.putIfAbsent(id, () => _trimMessage(error)); + } + break; + case 'testDone': + final id = event['testID'] as int?; + if (id == null) break; + + final isHidden = event['hidden'] as bool? ?? false; + final isSkipped = event['skipped'] as bool? ?? false; + final result = event['result'] as String? ?? 'success'; + final isSuccess = result == 'success'; + + if (isSuccess) { + // Hidden tests are internal (suite loading), don't count them. + if (isHidden) break; + if (isSkipped) { + skipped++; + } else { + passed++; + } + break; + } + + failed++; + + final info = tests[id]; + final suiteId = info?.suiteId; + failures.add(TestFailure( + name: info?.name ?? 'unknown', + suite: suiteId != null ? suites[suiteId] : null, + message: errors[id], + isLoadFailure: isHidden, + )); + break; + case 'done': + completed = true; + break; + } + } + + if (!hasEvents) return const TestRunResult.unknown(); + + return TestRunResult( + passed: passed, + failed: failed, + skipped: skipped, + failures: failures, + completed: completed, + ); + } + + static String _trimMessage(String message) { + final lines = message.split('\n'); + if (lines.length <= maxMessageLines) return message; + + return [ + ...lines.take(maxMessageLines), + '... ${lines.length - maxMessageLines} line(s) more', + ].join('\n'); + } + + static Map? _decode(String line) { + final value = line.trim(); + if (!value.startsWith('{') || !value.endsWith('}')) return null; + + try { + final decoded = jsonDecode(value); + return decoded is Map ? decoded : null; + } on FormatException catch (_) { + return null; + } + } +} + +class _TestInfo { + final String name; + final int? suiteId; + + const _TestInfo({required this.name, this.suiteId}); +} diff --git a/lib/src/check/test_run_result.dart b/lib/src/check/test_run_result.dart new file mode 100644 index 0000000..aa3711f --- /dev/null +++ b/lib/src/check/test_run_result.dart @@ -0,0 +1,70 @@ +/// Failed test. +class TestFailure { + final String name; + final String? suite; + final String? message; + + /// `true` if it's a failure of the suite loading (compilation error) + /// and not of a test itself. + final bool isLoadFailure; + + const TestFailure({ + required this.name, + this.suite, + this.message, + this.isLoadFailure = false, + }); + + Map toJson() => { + 'name': name, + if (suite != null) 'suite': suite, + if (message != null) 'message': message, + if (isLoadFailure) 'loadFailure': true, + }; + + @override + String toString() => suite != null ? '$name ($suite)' : name; +} + +/// Result of the tests run. +class TestRunResult { + final int passed; + final int failed; + final int skipped; + final List failures; + + /// `true` if the run was completed (the `done` event was received). + /// + /// `false` means that the test runner has crashed or its output + /// was not recognized. + final bool completed; + + const TestRunResult({ + required this.passed, + required this.failed, + required this.skipped, + this.failures = const [], + this.completed = true, + }); + + /// Result for a run without any recognized output. + const TestRunResult.unknown() + : passed = 0, + failed = 0, + skipped = 0, + failures = const [], + completed = false; + + int get total => passed + failed + skipped; + + bool get isSuccess => completed && failed == 0; + + Map toJson() => { + 'passed': passed, + 'failed': failed, + 'skipped': skipped, + 'total': total, + 'completed': completed, + 'failures': failures.map((f) => f.toJson()).toList(), + }; +} diff --git a/lib/src/config.dart b/lib/src/config.dart index a8c2f47..893de85 100644 --- a/lib/src/config.dart +++ b/lib/src/config.dart @@ -195,19 +195,58 @@ class CodeConfig { /// workspace root with the `--workspace` flag instead of per-package. final bool useWorkspace; + /// Configuration for the quality gates (`alex code check`). + final CodeCheckConfig check; + const CodeConfig({ this.useWorkspace = defaultUseWorkspace, + this.check = const CodeCheckConfig(), }); factory CodeConfig.fromYaml(YamlMap data) { + final checkData = data['check'] as YamlMap?; return CodeConfig( useWorkspace: data['use_workspace'] as bool? ?? defaultUseWorkspace, + check: checkData != null + ? CodeCheckConfig.fromYaml(checkData) + : const CodeCheckConfig(), + ); + } + + @override + String toString() { + return 'CodeConfig{useWorkspace: $useWorkspace, check: $check}'; + } +} + +/// Configuration for the quality gates (`alex code check`). +class CodeCheckConfig { + /// Target of the build gate: `ios`, `apk`, `appbundle`, `web` or `macos`. + /// + /// If not defined - `ios` is used on macOS and `apk` on other platforms. + final String? buildTarget; + + /// Additional patterns (regular expressions) of the output lines + /// that should be hidden as a noise. + final List noise; + + const CodeCheckConfig({ + this.buildTarget, + this.noise = const [], + }); + + factory CodeCheckConfig.fromYaml(YamlMap data) { + final target = data['build_target'] as String?; + final noise = data['noise'] as YamlList?; + return CodeCheckConfig( + buildTarget: target != null && target.isNotEmpty ? target : null, + noise: noise?.whereType().toList(growable: false) ?? const [], ); } @override String toString() { - return 'CodeConfig{useWorkspace: $useWorkspace}'; + return 'CodeCheckConfig{buildTarget: $buildTarget, noise: $noise}'; } } diff --git a/lib/src/const.dart b/lib/src/const.dart index 9361459..ba03d03 100644 --- a/lib/src/const.dart +++ b/lib/src/const.dart @@ -4,3 +4,12 @@ const bool isDebug = !isProduction; const packageName = 'alex'; const kVerbose = 'verbose'; + +/// Name of the option to define an output format of a command. +const kFormat = 'format'; + +/// Value of the [kFormat] option for a human readable output. +const kFormatText = 'text'; + +/// Value of the [kFormat] option for a machine readable output. +const kFormatJson = 'json'; diff --git a/test/src/check/analyze_output_parser_test.dart b/test/src/check/analyze_output_parser_test.dart new file mode 100644 index 0000000..ab47180 --- /dev/null +++ b/test/src/check/analyze_output_parser_test.dart @@ -0,0 +1,109 @@ +import 'package:alex/src/check/analyze_output_parser.dart'; +import 'package:alex/src/check/analyze_result.dart'; +import 'package:test/test.dart'; + +void main() { + group('parse()', () { + test('should return empty result for a clean flutter analyze output', () { + const output = ''' +Analyzing some_app... +No issues found! (ran in 5.3s) +'''; + + final res = AnalyzeOutputParser.parse(output); + + expect(res.issues, isEmpty); + expect(res.count, 0); + expect(res.hasIssues, false); + expect(res.reportedCount, 0); + }); + + test('should parse issues of the flutter analyze output', () { + const output = ''' +Analyzing some_app... + + info • Unused import: 'dart:io' • lib/src/a.dart:1:8 • unused_import + error • The name 'Foo' isn't defined • lib/src/b.dart:10:3 • undefined_identifier +warning • Unnecessary cast • lib/src/c.dart:5:12 • unnecessary_cast + +3 issues found. (ran in 6.1s) +'''; + + final res = AnalyzeOutputParser.parse(output); + + expect(res.count, 3); + expect(res.issues.length, 3); + expect(res.reportedCount, 3); + expect(res.countBySeverity(AnalyzeIssueSeverity.error), 1); + expect(res.countBySeverity(AnalyzeIssueSeverity.warning), 1); + expect(res.countBySeverity(AnalyzeIssueSeverity.info), 1); + + final first = res.issues.first; + expect(first.severity, AnalyzeIssueSeverity.info); + expect(first.message, "Unused import: 'dart:io'"); + expect(first.file, 'lib/src/a.dart'); + expect(first.line, 1); + expect(first.column, 8); + expect(first.rule, 'unused_import'); + }); + + test('should parse issues of the dart analyze output', () { + const output = ''' +Analyzing alex... + + info - lib/src/a.dart:1:8 - Unused import: 'dart:io'. Try removing the import directive. - unused_import + error - lib/src/b.dart:10:3 - The name 'Foo' isn't defined. - undefined_identifier + +2 issues found. +'''; + + final res = AnalyzeOutputParser.parse(output); + + expect(res.count, 2); + expect(res.reportedCount, 2); + + final error = res.issues.last; + expect(error.severity, AnalyzeIssueSeverity.error); + expect(error.file, 'lib/src/b.dart'); + expect(error.line, 10); + expect(error.column, 3); + expect(error.message, "The name 'Foo' isn't defined."); + expect(error.rule, 'undefined_identifier'); + }); + + test('should use the reported count if some issues were not recognized', + () { + const output = ''' + info • Unused import • lib/src/a.dart:1:8 • unused_import + some unrecognized issue line + +5 issues found. +'''; + + final res = AnalyzeOutputParser.parse(output); + + expect(res.issues.length, 1); + expect(res.count, 5); + expect(res.hasIssues, true); + }); + + test('should parse issues without a rule', () { + const output = + ' error • Target of URI does not exist • lib/src/a.dart:3:8'; + + final res = AnalyzeOutputParser.parse(output); + + expect(res.issues.length, 1); + expect(res.issues.single.rule, isNull); + expect(res.issues.single.severity, AnalyzeIssueSeverity.error); + }); + + test('should return empty result for an output without issues info', () { + final res = AnalyzeOutputParser.parse('Analyzing some_app...'); + + expect(res.issues, isEmpty); + expect(res.reportedCount, isNull); + expect(res.count, 0); + }); + }); +} diff --git a/test/src/check/output_filter_test.dart b/test/src/check/output_filter_test.dart new file mode 100644 index 0000000..7cad2f6 --- /dev/null +++ b/test/src/check/output_filter_test.dart @@ -0,0 +1,85 @@ +import 'package:alex/src/check/output_filter.dart'; +import 'package:test/test.dart'; + +void main() { + group('filter()', () { + test('should remove the flutter update banner', () { + const output = ''' +╔════════════════════════════════════════════════════════════════════════════╗ +║ A new version of Flutter is available! ║ +║ To update, run: flutter upgrade ║ +╚════════════════════════════════════════════════════════════════════════════╝ +Analyzing app... +No issues found! +'''; + + final res = OutputFilter().filter(output); + + expect(res, 'Analyzing app...\nNo issues found!'); + }); + + test('should keep meaningful lines', () { + const output = ''' +Analyzing app... + error • The name 'Foo' isn't defined • lib/a.dart:1:1 • undefined_identifier +1 issue found. +'''; + + final res = OutputFilter().filter(output); + + expect(res.split('\n').length, 3); + expect(res, contains("The name 'Foo' isn't defined")); + }); + + test('should collapse blank lines', () { + const output = '\n\nfirst\n\n\n\nsecond\n\n\n'; + + final res = OutputFilter().filter(output); + + expect(res, 'first\n\nsecond'); + }); + + test('should remove lines by extra patterns', () { + const output = 'keep me\nsome_plugin: noisy line\nkeep me too'; + + final res = + OutputFilter(extraPatterns: const ['some_plugin']).filter(output); + + expect(res, 'keep me\nkeep me too'); + }); + + test('should remove dependency resolution chatter', () { + const output = ''' +Resolving dependencies... +Got dependencies! +Running "flutter pub get" in app... +Build succeeded +'''; + + final res = OutputFilter().filter(output); + + expect(res, 'Build succeeded'); + }); + }); + + group('filterTail()', () { + test('should return not more than maxLines lines', () { + final output = List.generate(10, (i) => 'line $i').join('\n'); + + final res = OutputFilter().filterTail(output, maxLines: 3); + final lines = res.split('\n'); + + expect(lines.length, 4); + expect(lines.first, contains('7 line(s) skipped')); + expect(lines.last, 'line 9'); + }); + + test('should return all lines if there are less than maxLines', () { + const output = 'line 1\nline 2'; + + final res = OutputFilter().filterTail(output, maxLines: 10); + + expect(res, output); + }); + }); +} diff --git a/test/src/check/test_output_parser_test.dart b/test/src/check/test_output_parser_test.dart new file mode 100644 index 0000000..6e9a013 --- /dev/null +++ b/test/src/check/test_output_parser_test.dart @@ -0,0 +1,134 @@ +import 'package:alex/src/check/test_output_parser.dart'; +import 'package:test/test.dart'; + +void main() { + group('parse()', () { + test('should count passed tests', () { + final output = _events([ + '{"type":"suite","suite":{"id":0,"path":"test/a_test.dart"}}', + '{"type":"testStart","test":{"id":1,"name":"loading test/a_test.dart","suiteID":0}}', + '{"type":"testDone","testID":1,"result":"success","hidden":true}', + '{"type":"testStart","test":{"id":2,"name":"should work","suiteID":0}}', + '{"type":"testDone","testID":2,"result":"success","hidden":false}', + '{"type":"testStart","test":{"id":3,"name":"should work too","suiteID":0}}', + '{"type":"testDone","testID":3,"result":"success","hidden":false}', + '{"type":"done","success":true}', + ]); + + final res = TestOutputParser.parse(output); + + expect(res.passed, 2); + expect(res.failed, 0); + expect(res.skipped, 0); + expect(res.total, 2); + expect(res.completed, true); + expect(res.isSuccess, true); + expect(res.failures, isEmpty); + }); + + test('should collect failed tests with a message and a suite', () { + final output = _events([ + '{"type":"suite","suite":{"id":0,"path":"test/a_test.dart"}}', + '{"type":"testStart","test":{"id":2,"name":"should fail","suiteID":0}}', + r'{"type":"error","testID":2,"error":"Expected: 1\n Actual: 2","stackTrace":"..."}', + '{"type":"testDone","testID":2,"result":"failure","hidden":false}', + '{"type":"done","success":false}', + ]); + + final res = TestOutputParser.parse(output); + + expect(res.passed, 0); + expect(res.failed, 1); + expect(res.isSuccess, false); + + final failure = res.failures.single; + expect(failure.name, 'should fail'); + expect(failure.suite, 'test/a_test.dart'); + expect(failure.message, 'Expected: 1\n Actual: 2'); + expect(failure.isLoadFailure, false); + }); + + test('should treat a hidden failed test as a load failure', () { + final output = _events([ + '{"type":"suite","suite":{"id":0,"path":"test/a_test.dart"}}', + '{"type":"testStart","test":{"id":1,"name":"loading test/a_test.dart","suiteID":0}}', + '{"type":"error","testID":1,"error":"Compilation failed","stackTrace":""}', + '{"type":"testDone","testID":1,"result":"error","hidden":true}', + '{"type":"done","success":false}', + ]); + + final res = TestOutputParser.parse(output); + + expect(res.failed, 1); + expect(res.failures.single.isLoadFailure, true); + expect(res.failures.single.message, 'Compilation failed'); + }); + + test('should count skipped tests', () { + final output = _events([ + '{"type":"testStart","test":{"id":2,"name":"skipped one","suiteID":0}}', + '{"type":"testDone","testID":2,"result":"success","hidden":false,"skipped":true}', + '{"type":"done","success":true}', + ]); + + final res = TestOutputParser.parse(output); + + expect(res.passed, 0); + expect(res.skipped, 1); + expect(res.isSuccess, true); + }); + + test('should mark a run without the done event as not completed', () { + final output = _events([ + '{"type":"testStart","test":{"id":2,"name":"should work","suiteID":0}}', + '{"type":"testDone","testID":2,"result":"success","hidden":false}', + ]); + + final res = TestOutputParser.parse(output); + + expect(res.passed, 1); + expect(res.completed, false); + expect(res.isSuccess, false); + }); + + test('should return unknown result for an output without events', () { + final res = TestOutputParser.parse('Some plain text output\n'); + + expect(res.completed, false); + expect(res.total, 0); + expect(res.isSuccess, false); + }); + + test('should trim a too long failure message', () { + final longMessage = List.generate(50, (i) => 'line $i').join(r'\n'); + final output = _events([ + '{"type":"testStart","test":{"id":2,"name":"should fail","suiteID":0}}', + '{"type":"error","testID":2,"error":"$longMessage","stackTrace":"..."}', + '{"type":"testDone","testID":2,"result":"failure","hidden":false}', + '{"type":"done","success":false}', + ]); + + final res = TestOutputParser.parse(output); + + final lines = res.failures.single.message!.split('\n'); + expect(lines.length, TestOutputParser.maxMessageLines + 1); + expect(lines.last, contains('line(s) more')); + }); + + test('should ignore non json lines', () { + final output = _events([ + 'Some warning from the tool', + '{"type":"testStart","test":{"id":2,"name":"should work","suiteID":0}}', + '{"type":"testDone","testID":2,"result":"success","hidden":false}', + '{"type":"done","success":true}', + ]); + + final res = TestOutputParser.parse(output); + + expect(res.passed, 1); + expect(res.isSuccess, true); + }); + }); +} + +String _events(List lines) => '${lines.join('\n')}\n'; From 882bb5177d4712ce7ae60203ad2dd09ddb8621a6 Mon Sep 17 00:00:00 2001 From: greymag Date: Tue, 25 Aug 2026 15:28:31 +0200 Subject: [PATCH 04/13] feat(agents): new `agents guide` command An agent working in a project has no reason to call a CLI it doesn't know about, and a hand written description of a CLI goes stale on the first new flag. So the guide is generated from the commands tree of the installed version and can't get outdated. It prints what alex is, the rules an agent needs (config discovery, the `--format=json` contract, exit codes, which commands are still interactive) and all commands with their options and exit codes. A command path argument prints a part of it (`alex agents guide l10n`), `--format=json` returns a structured index. To feed the guide, `AlexCommand` gets two optional members: `exitCodes` with the meaning of the command specific codes and `isInteractive` for the commands that ask questions in stdin. Both are filled for the commands that have them today. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 1 + README.md | 28 +++ docs/agent-support-roadmap.md | 45 +++- lib/commands/agents/agents_command.dart | 17 ++ lib/commands/agents/guide_command.dart | 65 +++++ lib/commands/code/check_command.dart | 7 + lib/commands/custom/add_custom_command.dart | 3 + lib/commands/feature/finish_command.dart | 3 + .../l10n/check_translation_command.dart | 7 + lib/commands/pubspec/update_command.dart | 3 + lib/runner/alex_command.dart | 16 ++ lib/runner/alex_command_runner.dart | 2 + lib/src/agents/command_index.dart | 236 ++++++++++++++++++ lib/src/agents/guide_renderer.dart | 132 ++++++++++ test/src/agents/command_index_test.dart | 210 ++++++++++++++++ 15 files changed, 765 insertions(+), 10 deletions(-) create mode 100644 lib/commands/agents/agents_command.dart create mode 100644 lib/commands/agents/guide_command.dart create mode 100644 lib/src/agents/command_index.dart create mode 100644 lib/src/agents/guide_renderer.dart create mode 100644 test/src/agents/command_index_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 80935c5..fd2cae0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,6 @@ ## Next +* [Agents] New command group `agents` with the `guide` subcommand: prints a short guide of alex for an AI agent or a script - the rules (config discovery, machine readable output, exit codes, interactive commands) and all commands with their options and exit codes. The guide is generated from the commands tree of the installed version, so it can't get outdated. Supports a command path argument to print a part of it (`alex agents guide l10n`) and `--format=json` for a structured index. * New command `alex pubspec version ` (alias `alex pub version`) — increments the version in `pubspec.yaml`. By default only the version is changed and a build number is kept as is (usually it's already prepared for the next build); pass `--build` (`-b`) to increment the build number too (#12). * [Release] `start`: new `--increment` (`-i`) option (`patch`, `minor` or `major`) to increment the version **of this release** before it's started, keeping the build number: `1.2.3+4` is released as `1.3.0+4` with `-i minor`. The release branch, `CHANGELOG.md`, the tag and build artifacts use the new version. Without the option nothing changes — the current version is released as is. After the release a patch and a build number are incremented in `develop` as always, and the new version is printed in the end (#12). * [Release] `start`: fixed a failure of a local iOS build (`--local`), when alex reported that the artifact was placed in an unexpected directory, though the `.ipa` was built correctly. `flutter build ipa` prints two `✓ Built` lines — for the Xcode archive and for the IPA itself — and alex checked the first one. Now the line with a path in the build output directory is used. diff --git a/README.md b/README.md index c0a3845..7a2ac7f 100644 --- a/README.md +++ b/README.md @@ -859,6 +859,34 @@ This will show: See `alex_custom_commands.yaml.example` in the repository for more examples. +### Agents + +Support of AI agents and scripts. + +#### Guide for an agent + +Print a short guide of alex: what it is, the rules (config discovery, machine readable +output, exit codes, which commands are interactive) and all commands with their options +and exit codes. + +```bash +$ alex agents guide +``` + +The guide is generated from the commands tree of the installed version, so it always +matches the tool and can't get outdated. Pass a command path to print the guide for one +command or a group only, and `--format=json` to get a structured index instead of +Markdown: + +```bash +$ alex agents guide l10n +$ alex agents guide code check --format=json +``` + +The recommended way to use it with an AI agent is to mention it in the `CLAUDE.md` / +`AGENTS.md` of the project, so the agent runs `alex agents guide` instead of guessing +the commands from a hand written and possibly outdated description. + ## Problem solving ### Command not found diff --git a/docs/agent-support-roadmap.md b/docs/agent-support-roadmap.md index 70f06a3..dbc5b64 100644 --- a/docs/agent-support-roadmap.md +++ b/docs/agent-support-roadmap.md @@ -89,17 +89,39 @@ Every `readLineSync` gets a corresponding flag: Plus a global `--non-interactive` that fails with an explicit message instead of blocking on stdin. Without this agents cannot use `feature` / `release` at all. -### Tier 2 — meaningful simplification - -#### 5. `[ ] alex info --format=json` +#### 5. `[~] Agent onboarding: how an agent learns that alex exists` + +The blocker before every other item: an agent working in a consumer project has no +reason to call a CLI it doesn't know about. A static `llms.txt` in this repository does +not solve it — the canonical `llms.txt` lives at the root of a documentation site (ours +is pub.dev, which we don't control), and a local agent doesn't browse the web anyway: +it has the installed binary and the project's files. + +The real risk is drift: hand-written prose about a CLI goes stale on the first new flag, +and an agent follows it literally. So the source of truth must be the `ArgParser` tree +plus the exit-code table, with everything else generated from it. + +One generator, three outputs: + +- `[x] alex agents guide` — a compact digest built from the command tree: what alex is, + the rules (config discovery, `--format=json` contract, exit codes, which commands are + still interactive), then per command: invocation, one-line summary, options, exit + codes. `--format=json` for a structured index; a command path argument + (`alex agents guide l10n`) narrows it. Must stay short — an agent's context is a + resource. +- `[ ] llms.txt` in the repository — the same digest written to a file, for web-capable + agents and for humans copying it into their project. Generated (like + `lib/src/version.dart`), with a CI check that it's up to date. +- `[ ] alex agents init` — writes the alex section into the consumer project's + `CLAUDE.md` / `AGENTS.md` and the project facts into `.claude/profile/shared.md` + (locales, l10n paths, gate command, branches) from `alex.yaml` instead of the guesses + `agent-profile-init` makes today. This is the strongest channel: role agents read + those files FIRST, before anything else. +- `[ ] alex info --format=json` — project facts in one call instead of reading five + files: locales, l10n paths (ARB/XML/source), current version, branch names, fvm + version, workspace packages, whether generated code is out of sync. -One call instead of reading five files at the start of every agent run: locales, l10n -paths (ARB/XML/source), current version, branch names, fvm version, workspace package -list, whether generated code is out of sync. - -Companion: `alex agents profile` — generate/validate `.claude/profile/shared.md` from -`alex.yaml` so the profile cannot drift from reality (today `agent-profile-init` -infers those facts). +### Tier 2 — meaningful simplification #### 6. `[ ] alex l10n sync` @@ -177,6 +199,9 @@ simple lock file inside `alex code gen` / `alex pubspec get`. 1. ~~`alex code check` (#1)~~ — done. 2. `--format=json` for `l10n check_translations` (#2). +2b. Agent onboarding (#5) — `agents guide` done; `llms.txt` and `agents init` next. + Raised above the rest: without a delivery channel agents never notice the other + improvements. 3. Non-interactive mode (#4). 4. `alex lint` (#3) — largest, needs a decision on how configurable the rules are. 5. Then Tier 2 in listed order. diff --git a/lib/commands/agents/agents_command.dart b/lib/commands/agents/agents_command.dart new file mode 100644 index 0000000..1a3df3a --- /dev/null +++ b/lib/commands/agents/agents_command.dart @@ -0,0 +1,17 @@ +import 'package:alex/runner/alex_command.dart'; + +import 'guide_command.dart'; + +/// Command to work with AI agents support. +class AgentsCommand extends AlexCommand { + AgentsCommand() + : super('agents', 'Support of AI agents and scripts.', ['agent']) { + addSubcommand(GuideCommand()); + } + + @override + Future doRun() async { + printUsage(); + return 0; + } +} diff --git a/lib/commands/agents/guide_command.dart b/lib/commands/agents/guide_command.dart new file mode 100644 index 0000000..42381c1 --- /dev/null +++ b/lib/commands/agents/guide_command.dart @@ -0,0 +1,65 @@ +import 'dart:convert'; + +import 'package:alex/internal/print.dart' as print; +import 'package:alex/runner/alex_command.dart'; +import 'package:alex/src/agents/command_index.dart'; +import 'package:alex/src/agents/guide_renderer.dart'; +import 'package:alex/src/version.dart'; + +/// Exit code if a command from the arguments is not found. +const _kExitCodeUnknownCommand = 10; + +/// Command to print a short guide of the tool for an AI agent or a script. +class GuideCommand extends AlexCommand { + GuideCommand() + : super( + 'guide', + 'Print a short guide of alex for an AI agent or a script.\n\n' + 'The guide is generated from the commands tree of the installed ' + 'version, so it always matches the tool: ' + 'the list of commands, their options, ' + 'which of them support a machine readable output ' + 'and which are interactive, and the exit codes.\n\n' + 'Pass a command path to print the guide for it only, ' + 'for example: alex agents guide l10n.\n\n' + 'Exit code is 0 if the guide is printed, ' + '$_kExitCodeUnknownCommand if there is no command ' + 'with the specified path.', + ) { + argParser.addFormatOption(); + } + + @override + Map get exitCodes => const { + _kExitCodeUnknownCommand: 'unknown command', + }; + + @override + Future doRun() async { + final args = argResults!; + final path = args.rest; + + final fullIndex = CommandIndex.build(runner!); + final index = fullIndex.filter(path); + + if (index == null) { + return error(_kExitCodeUnknownCommand, + message: 'There is no command "${path.join(' ')}". ' + 'Run "alex agents guide" to see all commands.'); + } + + if (args.isJsonFormat()) { + print.result(jsonEncode({ + 'alex': packageVersion, + 'command': 'agents guide', + 'ok': true, + 'summary': '${index.runnable.length} command(s)', + 'commands': index.toJson(), + })); + } else { + print.info(GuideRenderer.render(index, version: packageVersion)); + } + + return 0; + } +} diff --git a/lib/commands/code/check_command.dart b/lib/commands/code/check_command.dart index 9ec4057..8e1582f 100644 --- a/lib/commands/code/check_command.dart +++ b/lib/commands/code/check_command.dart @@ -81,6 +81,13 @@ class CheckCommand extends CodeCommandBase { ..addVerboseFlutterCmdFlag(); } + @override + Map get exitCodes => const { + kExitCodeAnalyzeFailed: 'analyze failed', + kExitCodeTestFailed: 'tests failed', + kExitCodeBuildFailed: 'build failed', + }; + late OutputFilter _filter; late bool _isFlutterProject; diff --git a/lib/commands/custom/add_custom_command.dart b/lib/commands/custom/add_custom_command.dart index 9f0675c..c4aabb4 100644 --- a/lib/commands/custom/add_custom_command.dart +++ b/lib/commands/custom/add_custom_command.dart @@ -13,6 +13,9 @@ class AddCustomCommand extends AlexCommand { AddCustomCommand() : super('add', 'Add a new custom command.'); + @override + bool get isInteractive => true; + @override Future doRun() async { final config = CustomCommandsConfig.instance; diff --git a/lib/commands/feature/finish_command.dart b/lib/commands/feature/finish_command.dart index ae91719..4ddd869 100644 --- a/lib/commands/feature/finish_command.dart +++ b/lib/commands/feature/finish_command.dart @@ -47,6 +47,9 @@ class FinishCommand extends FeatureCommandBase { ); } + @override + bool get isInteractive => true; + @override Future doRun() async { final args = argResults!; diff --git a/lib/commands/l10n/check_translation_command.dart b/lib/commands/l10n/check_translation_command.dart index bbe2687..0cc7e1b 100644 --- a/lib/commands/l10n/check_translation_command.dart +++ b/lib/commands/l10n/check_translation_command.dart @@ -67,6 +67,13 @@ class CheckTranslationsCommand extends L10nCommandBase { ..addVerboseFlutterCmdFlag(); } + @override + Map get exitCodes => const { + _kExitCodeCheckFailed: 'some checks failed', + _kExitCodeChangedAfterPubGet: 'some files were changed after pub get ' + '(with --fail-on-changed-files)', + }; + @override Future doRun() async { final config = findConfigAndSetWorkingDir(); diff --git a/lib/commands/pubspec/update_command.dart b/lib/commands/pubspec/update_command.dart index e0516bb..eb04545 100644 --- a/lib/commands/pubspec/update_command.dart +++ b/lib/commands/pubspec/update_command.dart @@ -25,6 +25,9 @@ class UpdateCommand extends PubspecCommandBase { ); } + @override + bool get isInteractive => true; + @override Future doRun() async { final args = argResults!; diff --git a/lib/runner/alex_command.dart b/lib/runner/alex_command.dart index 2253c76..77456e8 100644 --- a/lib/runner/alex_command.dart +++ b/lib/runner/alex_command.dart @@ -49,6 +49,22 @@ abstract class AlexCommand extends Command { @override List get aliases => _aliases; + /// Exit codes of the command with their meaning, + /// except the common ones (`0` - success, `2` - error). + /// + /// Used in a machine readable index of the commands + /// (see `alex agents guide`). + Map get exitCodes => const {}; + + /// Whether the command can ask a question in the standard input. + /// + /// Such a command can't be used by a script or an agent + /// without the options that provide all the answers. + /// + /// Used in a machine readable index of the commands + /// (see `alex agents guide`). + bool get isInteractive => false; + @protected Console get console => _console ??= const StdConsole(); diff --git a/lib/runner/alex_command_runner.dart b/lib/runner/alex_command_runner.dart index 6a51bac..cbfbd77 100644 --- a/lib/runner/alex_command_runner.dart +++ b/lib/runner/alex_command_runner.dart @@ -1,5 +1,6 @@ import 'dart:async'; +import 'package:alex/commands/agents/agents_command.dart'; import 'package:alex/commands/changelog/changelog_command.dart'; import 'package:alex/commands/code/code_command.dart'; import 'package:alex/commands/custom/custom_command.dart'; @@ -43,6 +44,7 @@ class AlexCommandRunner extends CommandRunner { SettingsCommand(), UpdateCommand(), CustomCommand(), + AgentsCommand(), ].forEach(addCommand); // Load and register custom commands diff --git a/lib/src/agents/command_index.dart b/lib/src/agents/command_index.dart new file mode 100644 index 0000000..dfae0d1 --- /dev/null +++ b/lib/src/agents/command_index.dart @@ -0,0 +1,236 @@ +import 'package:alex/runner/alex_command.dart'; +import 'package:alex/src/const.dart'; +import 'package:args/args.dart'; +import 'package:args/command_runner.dart'; + +/// Names of the options that are common for all commands, +/// so they are described once in the guide and skipped in the index. +const _kCommonOptions = { + kVerbose, + kFormat, + 'help', + 'verboseFlutterCmd', +}; + +/// Maximum length of an option help in the index. +const _kMaxHelpLength = 200; + +/// Option of a command. +class CommandOptionInfo { + final String name; + final String? abbr; + final bool isFlag; + final String? help; + final List allowed; + final String? defaultValue; + final bool isMandatory; + + const CommandOptionInfo({ + required this.name, + required this.isFlag, + this.abbr, + this.help, + this.allowed = const [], + this.defaultValue, + this.isMandatory = false, + }); + + factory CommandOptionInfo.fromOption(Option option) { + final defaultValue = option.defaultsTo; + // `false` for a flag is the obvious default, no need to mention it. + final hasDefault = + defaultValue != null && !(option.isFlag && defaultValue == false); + return CommandOptionInfo( + name: option.name, + abbr: option.abbr, + isFlag: option.isFlag, + help: _shorten(option.help), + allowed: option.allowed?.toList(growable: false) ?? const [], + defaultValue: hasDefault ? '$defaultValue' : null, + isMandatory: option.mandatory, + ); + } + + /// Usage of the option, for example `--locale=`. + String get usage => isFlag ? '--$name' : '--$name='; + + Map toJson() => { + 'name': name, + 'type': isFlag ? 'flag' : 'option', + if (abbr != null) 'abbr': abbr, + if (help != null) 'help': help, + if (allowed.isNotEmpty) 'allowed': allowed, + if (defaultValue != null) 'default': defaultValue, + if (isMandatory) 'mandatory': true, + }; +} + +/// Command of the tool. +class CommandInfo { + /// Full path of the command, for example `code check`. + final String path; + + final List aliases; + final String summary; + + /// Invocation string, for example `alex code check [arguments]`. + final String invocation; + + /// Whether the command can ask a question in the standard input. + final bool isInteractive; + + /// Whether the command supports the `--format=json` option. + final bool supportsJson; + + final List options; + + /// Exit codes with their meaning, except the common ones. + final Map exitCodes; + + final List subcommands; + + const CommandInfo({ + required this.path, + required this.summary, + required this.invocation, + this.aliases = const [], + this.isInteractive = false, + this.supportsJson = false, + this.options = const [], + this.exitCodes = const {}, + this.subcommands = const [], + }); + + /// Whether the command is just a group of subcommands. + bool get isGroup => subcommands.isNotEmpty; + + /// All commands that can be run: this one (if it's not a group) + /// and all its subcommands, recursively. + List get runnable => isGroup + ? subcommands.expand((c) => c.runnable).toList(growable: false) + : [this]; + + Map toJson() => { + 'path': path, + 'summary': summary, + 'invocation': invocation, + if (aliases.isNotEmpty) 'aliases': aliases, + if (isInteractive) 'interactive': true, + if (supportsJson) 'json': true, + if (options.isNotEmpty) + 'options': options.map((o) => o.toJson()).toList(), + if (exitCodes.isNotEmpty) + 'exitCodes': exitCodes.map((k, v) => MapEntry('$k', v)), + if (subcommands.isNotEmpty) + 'subcommands': subcommands.map((c) => c.toJson()).toList(), + }; +} + +/// Index of all commands of the tool, built from the commands tree. +/// +/// The tree is the only source of truth, so the index can't get outdated. +class CommandIndex { + final List commands; + + const CommandIndex(this.commands); + + /// Builds the index by the [runner] commands. + factory CommandIndex.build(CommandRunner runner) { + return CommandIndex(_children(runner.commands, const [])); + } + + /// Returns the index with the only command by the [path] + /// or `null` if there is no such command. + /// + /// Path can be partial, for example `['l10n']` returns the whole + /// `l10n` subtree. + CommandIndex? filter(List path) { + if (path.isEmpty) return this; + + var current = commands; + CommandInfo? found; + + for (final name in path) { + found = current.firstWhereOrNull(name); + if (found == null) return null; + current = found.subcommands; + } + + return CommandIndex([found!]); + } + + /// All commands that can be run. + List get runnable => + commands.expand((c) => c.runnable).toList(growable: false); + + List> toJson() => + commands.map((c) => c.toJson()).toList(); + + static List _children( + Map> commands, List parentPath) { + final res = []; + + commands.forEach((key, command) { + // Aliases are in the map too, so skip them to avoid duplicates. + if (key != command.name) return; + if (command.hidden) return; + // Built-in help command is not a part of the API. + if (parentPath.isEmpty && command.name == 'help') return; + + res.add(_command(command, parentPath)); + }); + + return res; + } + + static CommandInfo _command( + Command command, List parentPath) { + final path = [...parentPath, command.name]; + final alex = command is AlexCommand ? command : null; + + return CommandInfo( + path: path.join(' '), + aliases: command.aliases.toList(growable: false), + summary: _shorten(command.summary) ?? command.name, + invocation: command.invocation, + isInteractive: alex?.isInteractive ?? false, + supportsJson: command.argParser.options.containsKey(kFormat), + options: _options(command.argParser), + exitCodes: alex?.exitCodes ?? const {}, + subcommands: _children(command.subcommands, path), + ); + } + + static List _options(ArgParser parser) { + final res = []; + + parser.options.forEach((name, option) { + if (option.hide) return; + if (_kCommonOptions.contains(name)) return; + + res.add(CommandOptionInfo.fromOption(option)); + }); + + return res; + } +} + +String? _shorten(String? value) { + if (value == null) return null; + + final line = value.split('\n').first.trim(); + if (line.isEmpty) return null; + if (line.length <= _kMaxHelpLength) return line; + + return '${line.substring(0, _kMaxHelpLength).trimRight()}...'; +} + +extension _CommandInfoListExtension on List { + CommandInfo? firstWhereOrNull(String name) { + for (final command in this) { + if (command.path.split(' ').last == name) return command; + if (command.aliases.contains(name)) return command; + } + return null; + } +} diff --git a/lib/src/agents/guide_renderer.dart b/lib/src/agents/guide_renderer.dart new file mode 100644 index 0000000..9676156 --- /dev/null +++ b/lib/src/agents/guide_renderer.dart @@ -0,0 +1,132 @@ +import 'package:alex/src/agents/command_index.dart'; + +/// Renders a short guide of the tool for an AI agent or a script. +/// +/// The guide is built from the commands index, so it can't get outdated. +class GuideRenderer { + const GuideRenderer._(); + + /// Renders the guide in Markdown. + static String render(CommandIndex index, {required String version}) { + final sb = StringBuffer() + ..writeln('# alex $version') + ..writeln() + ..writeln(_intro) + ..writeln() + ..writeln('## Rules') + ..writeln(); + + for (final rule in _rules) { + sb.writeln('- $rule'); + } + + sb + ..writeln() + ..writeln('## Commands') + ..writeln(); + + for (final command in index.runnable) { + _writeCommand(sb, command); + } + + return sb.toString().trimRight(); + } + + static void _writeCommand(StringBuffer sb, CommandInfo command) { + final marks = [ + if (command.supportsJson) 'JSON', + if (command.isInteractive) 'INTERACTIVE', + ]; + + sb + ..writeln('### alex ${command.path}' + '${marks.isNotEmpty ? ' [${marks.join(', ')}]' : ''}') + ..writeln() + ..writeln(command.summary); + + if (command.aliases.isNotEmpty) { + sb.writeln('Aliases: ${command.aliases.map((a) => '`$a`').join(', ')}.'); + } + + if (command.isInteractive) { + sb.writeln('Asks questions in stdin: pass all the options below ' + 'or do not call it from a script.'); + } + + sb + ..writeln() + ..writeln('`${command.invocation}`') + ..writeln(); + + if (command.options.isNotEmpty) { + for (final option in command.options) { + final details = [ + if (option.allowed.isNotEmpty) 'one of: ${option.allowed.join(', ')}', + if (option.defaultValue != null) 'default: ${option.defaultValue}', + if (option.isMandatory) 'required', + ]; + + sb.writeln('- `${option.usage}`' + '${option.help != null ? ' - ${option.help}' : ''}' + '${details.isNotEmpty ? ' (${details.join('; ')})' : ''}'); + } + sb.writeln(); + } + + if (command.exitCodes.isNotEmpty) { + final codes = command.exitCodes.entries + .map((e) => '`${e.key}` - ${e.value}') + .join('; '); + sb + ..writeln('Exit codes: $codes.') + ..writeln(); + } + } + + static const _intro = + 'A command line tool for working with Flutter projects: releases ' + '(gitflow), localization (ARB / XML / iOS strings), code generation, ' + 'dependencies and quality gates.\n' + '\n' + 'Prefer these commands over hand written shell for the tasks below: ' + 'they already know the project configuration - locales and l10n paths, ' + 'branch names, FVM, pub workspaces and subprojects.'; + + static const _ruleConfig = + 'Run it from any directory inside the project: alex looks for ' + '`alex.yaml` (or the `alex:` section of `pubspec.yaml`) up the tree and ' + 'sets the working directory to the folder of the found config.'; + + static const _ruleGlobalOptions = + 'Global options: `--verbose` for diagnostics, `--version` for the ' + 'version of the tool.'; + + static const _ruleJson = + 'Commands marked `[JSON]` support `--format=json`: stdout contains a ' + 'single JSON object with the result, every other message - including ' + 'the update banner - goes to stderr. Parse stdout, ignore stderr.'; + + static const _ruleExitCodes = + 'Common exit codes: `0` - success, `2` - error, `64` - wrong usage. ' + 'Command specific codes are listed below. Always check the exit code, ' + 'do not parse the human readable output.'; + + static const _ruleInteractive = + 'Commands marked `[INTERACTIVE]` can ask a question in stdin. Pass the ' + 'options that answer the questions, otherwise the command will hang.'; + + static const _ruleGuide = + 'This guide is generated from the commands tree of the installed ' + 'version, so it always matches the tool. Use ' + '`alex agents guide ` to print it for a single command or a ' + 'group only, and `--format=json` for a structured index.'; + + static const _rules = [ + _ruleConfig, + _ruleGlobalOptions, + _ruleJson, + _ruleExitCodes, + _ruleInteractive, + _ruleGuide, + ]; +} diff --git a/test/src/agents/command_index_test.dart b/test/src/agents/command_index_test.dart new file mode 100644 index 0000000..7e8677d --- /dev/null +++ b/test/src/agents/command_index_test.dart @@ -0,0 +1,210 @@ +import 'package:alex/runner/alex_command.dart'; +import 'package:alex/src/agents/command_index.dart'; +import 'package:alex/src/agents/guide_renderer.dart'; +import 'package:args/command_runner.dart'; +import 'package:test/test.dart'; + +void main() { + group('build()', () { + test('should build a tree of commands', () { + final index = CommandIndex.build(_runner([ + _Group('code', 'Work with code', [ + _Cmd('gen', 'Run code generation.'), + ]), + ])); + + expect(index.commands.length, 1); + + final group = index.commands.single; + expect(group.path, 'code'); + expect(group.isGroup, true); + expect(group.subcommands.single.path, 'code gen'); + expect(index.runnable.map((c) => c.path), ['code gen']); + }); + + test('should not duplicate a command by its aliases', () { + final index = CommandIndex.build(_runner([ + _Cmd('check', 'Check something.', aliases: ['c', 'validate']), + ])); + + expect(index.commands.length, 1); + expect(index.commands.single.aliases, ['c', 'validate']); + }); + + test('should skip the built-in help command', () { + final index = CommandIndex.build(_runner([_Cmd('gen', 'Generate.')])); + + expect(index.commands.map((c) => c.path), ['gen']); + }); + + test('should use the first line of the description as a summary', () { + final index = CommandIndex.build(_runner([ + _Cmd('gen', 'Generate code.\n\nSome long details.\nAnd more.'), + ])); + + expect(index.commands.single.summary, 'Generate code.'); + }); + + test('should skip common and hidden options', () { + final command = _Cmd('gen', 'Generate.'); + command.argParser + ..addFlag('secret', hide: true) + ..addFlag('force', help: 'Force it.'); + + final index = CommandIndex.build(_runner([command])); + + expect(index.commands.single.options.map((o) => o.name), ['force']); + }); + + test('should mark a command with the format option as json', () { + final withJson = _Cmd('gen', 'Generate.')..argParser.addFormatOption(); + + final index = + CommandIndex.build(_runner([withJson, _Cmd('other', 'Other.')])); + + expect(index.commands.first.supportsJson, true); + expect(index.commands.last.supportsJson, false); + }); + + test('should keep the interactive flag and the exit codes', () { + final index = CommandIndex.build(_runner([ + _Cmd('finish', 'Finish.', + interactive: true, codes: const {10: 'no issue id'}), + ])); + + final command = index.commands.single; + expect(command.isInteractive, true); + expect(command.exitCodes, const {10: 'no issue id'}); + }); + + test('should not report false as a default value of a flag', () { + final command = _Cmd('gen', 'Generate.'); + command.argParser + ..addFlag('force', help: 'Force it.') + ..addFlag('safe', help: 'Be safe.', defaultsTo: true); + + final index = CommandIndex.build(_runner([command])); + final options = index.commands.single.options; + + expect(options.first.defaultValue, isNull); + expect(options.last.defaultValue, 'true'); + }); + }); + + group('filter()', () { + test('should return the subtree by a path', () { + final index = CommandIndex.build(_runner([ + _Group('l10n', 'Localization', [ + _Cmd('extract', 'Extract.'), + _Cmd('generate', 'Generate.'), + ]), + _Cmd('gen', 'Generate code.'), + ])); + + final filtered = index.filter(['l10n'])!; + + expect(filtered.runnable.map((c) => c.path), + ['l10n extract', 'l10n generate']); + }); + + test('should return a single command by a full path', () { + final index = CommandIndex.build(_runner([ + _Group('l10n', 'Localization', [_Cmd('extract', 'Extract.')]), + ])); + + final filtered = index.filter(['l10n', 'extract'])!; + + expect(filtered.runnable.single.path, 'l10n extract'); + }); + + test('should find a command by an alias', () { + final index = CommandIndex.build(_runner([ + _Cmd('check', 'Check.', aliases: ['validate']), + ])); + + expect(index.filter(['validate'])!.runnable.single.path, 'check'); + }); + + test('should return null for an unknown path', () { + final index = CommandIndex.build(_runner([_Cmd('gen', 'Generate.')])); + + expect(index.filter(['unknown']), isNull); + expect(index.filter(['gen', 'unknown']), isNull); + }); + + test('should return the same index for an empty path', () { + final index = CommandIndex.build(_runner([_Cmd('gen', 'Generate.')])); + + expect(index.filter(const [])!.runnable.length, 1); + }); + }); + + group('GuideRenderer.render()', () { + test('should render commands with marks and exit codes', () { + final withJson = + _Cmd('check', 'Check something.', codes: const {10: 'checks failed'}) + ..argParser.addFormatOption(); + + final index = CommandIndex.build(_runner([ + withJson, + _Cmd('finish', 'Finish something.', interactive: true), + ])); + + final res = GuideRenderer.render(index, version: '1.2.3'); + + expect(res, startsWith('# alex 1.2.3')); + expect(res, contains('### alex check [JSON]')); + expect(res, contains('Exit codes: `10` - checks failed.')); + expect(res, contains('### alex finish [INTERACTIVE]')); + }); + + test('should not render a group as a command', () { + final index = CommandIndex.build(_runner([ + _Group('code', 'Work with code', [_Cmd('gen', 'Generate.')]), + ])); + + final res = GuideRenderer.render(index, version: '1.0.0'); + + expect(res, contains('### alex code gen')); + expect(res, isNot(contains('### alex code\n'))); + }); + }); +} + +CommandRunner _runner(List commands) { + final runner = CommandRunner('alex', 'Test runner.'); + commands.forEach(runner.addCommand); + return runner; +} + +class _Cmd extends AlexCommand { + final bool interactive; + final Map codes; + + _Cmd( + String name, + String description, { + List aliases = const [], + this.interactive = false, + this.codes = const {}, + }) : super(name, description, aliases); + + @override + bool get isInteractive => interactive; + + @override + Map get exitCodes => codes; + + @override + Future doRun() async => 0; +} + +class _Group extends AlexCommand { + _Group(String name, String description, List subcommands) + : super(name, description) { + subcommands.forEach(addSubcommand); + } + + @override + Future doRun() async => 0; +} From 868b2ca33b1942d1620af22f11aa618d20fef810 Mon Sep 17 00:00:00 2001 From: greymag Date: Tue, 25 Aug 2026 15:44:04 +0200 Subject: [PATCH 05/13] fix(agents): document the generic exit code 1 in the guide `error(1)` is the usual "could not do the job" path in about twenty places (`pubspec update`, `changelog update-issue-links`, several `l10n` and `release` commands), but the guide listed only `0`, `2` and `64`, so an agent would treat a normal failure as an undocumented one. Co-Authored-By: Claude Opus 5 (1M context) --- lib/src/agents/guide_renderer.dart | 9 ++++++--- test/src/agents/command_index_test.dart | 12 ++++++++++++ 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/lib/src/agents/guide_renderer.dart b/lib/src/agents/guide_renderer.dart index 9676156..75e237f 100644 --- a/lib/src/agents/guide_renderer.dart +++ b/lib/src/agents/guide_renderer.dart @@ -107,9 +107,12 @@ class GuideRenderer { 'the update banner - goes to stderr. Parse stdout, ignore stderr.'; static const _ruleExitCodes = - 'Common exit codes: `0` - success, `2` - error, `64` - wrong usage. ' - 'Command specific codes are listed below. Always check the exit code, ' - 'do not parse the human readable output.'; + 'Exit code is the result: `0` - success, any other value - failure. ' + 'Generic codes are `1` - the command could not do its job (wrong ' + 'arguments, wrong state of the project, a file is missing), `2` - an ' + 'unexpected error, `64` - wrong usage of the command line. Command ' + 'specific codes are listed below. Always check the exit code, do not ' + 'parse the human readable output.'; static const _ruleInteractive = 'Commands marked `[INTERACTIVE]` can ask a question in stdin. Pass the ' diff --git a/test/src/agents/command_index_test.dart b/test/src/agents/command_index_test.dart index 7e8677d..6946b7e 100644 --- a/test/src/agents/command_index_test.dart +++ b/test/src/agents/command_index_test.dart @@ -158,6 +158,18 @@ void main() { expect(res, contains('### alex finish [INTERACTIVE]')); }); + test('should document the generic exit codes', () { + final index = CommandIndex.build(_runner([_Cmd('gen', 'Generate.')])); + + final res = GuideRenderer.render(index, version: '1.0.0'); + + // All the codes a command can return without an own `exitCodes` entry. + expect(res, contains('`0` - success')); + expect(res, contains('`1` -')); + expect(res, contains('`2` -')); + expect(res, contains('`64` -')); + }); + test('should not render a group as a command', () { final index = CommandIndex.build(_runner([ _Group('code', 'Work with code', [_Cmd('gen', 'Generate.')]), From 99539cdbfc9e0d50946532ec9c92ec7ec7f1dc69 Mon Sep 17 00:00:00 2001 From: greymag Date: Tue, 25 Aug 2026 16:04:11 +0200 Subject: [PATCH 06/13] feat(agents): new `agents init` command Writes the alex section into the agent instructions of the project (`CLAUDE.md`, `AGENTS.md` or any file from `agents.files` / `--file`), so an agent learns about alex from the file it reads first - the strongest of the channels, because a role agent reads those files before anything else. The section is generated from the alex config, `pubspec.yaml` and the commands tree: package and version, FVM, locales (by the ARB files), l10n paths, branches, then the commands to use with their exit codes. So it can be regenerated after any change of the project. Only the content between the `` and `` markers is replaced, nothing around it is touched, and broken markers are reported instead of being overwritten. Files that point to the same file through a link (the usual `AGENTS.md -> CLAUDE.md`) are deduplicated, so the section is not written twice. With `--check` nothing is changed and the command fails with exit code 10 if some file is missing or outdated - for CI. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 1 + README.md | 39 +++++ alex.yaml | 6 + docs/agent-support-roadmap.md | 22 +-- lib/commands/agents/agents_command.dart | 2 + lib/commands/agents/init_command.dart | 194 ++++++++++++++++++++++++ lib/src/agents/managed_block.dart | 92 +++++++++++ lib/src/agents/project_facts.dart | 167 ++++++++++++++++++++ lib/src/agents/section_renderer.dart | 89 +++++++++++ lib/src/config.dart | 31 ++++ test/src/agents/managed_block_test.dart | 120 +++++++++++++++ 11 files changed, 752 insertions(+), 11 deletions(-) create mode 100644 lib/commands/agents/init_command.dart create mode 100644 lib/src/agents/managed_block.dart create mode 100644 lib/src/agents/project_facts.dart create mode 100644 lib/src/agents/section_renderer.dart create mode 100644 test/src/agents/managed_block_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index fd2cae0..1ebf7a0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,6 @@ ## Next +* [Agents] New command `agents init`: adds or updates the alex section in the agent instructions of the project (`CLAUDE.md`, `AGENTS.md` and alike), so an agent learns about alex from the file it reads first. The section describes the project (package, FVM, locales, l10n paths, branches) and the commands to use with their exit codes, and is generated from the alex config, `pubspec.yaml` and the commands tree. Only the content between the `` and `` markers is changed. Files are taken from the new `agents.files` config option, the `--file` (`-f`) option, or `CLAUDE.md`/`AGENTS.md` if they exist. With the `--check` flag nothing is changed and the command fails with exit code `10` if some file is missing or outdated. * [Agents] New command group `agents` with the `guide` subcommand: prints a short guide of alex for an AI agent or a script - the rules (config discovery, machine readable output, exit codes, interactive commands) and all commands with their options and exit codes. The guide is generated from the commands tree of the installed version, so it can't get outdated. Supports a command path argument to print a part of it (`alex agents guide l10n`) and `--format=json` for a structured index. * New command `alex pubspec version ` (alias `alex pub version`) — increments the version in `pubspec.yaml`. By default only the version is changed and a build number is kept as is (usually it's already prepared for the next build); pass `--build` (`-b`) to increment the build number too (#12). * [Release] `start`: new `--increment` (`-i`) option (`patch`, `minor` or `major`) to increment the version **of this release** before it's started, keeping the build number: `1.2.3+4` is released as `1.3.0+4` with `-i minor`. The release branch, `CHANGELOG.md`, the tag and build artifacts use the new version. Without the option nothing changes — the current version is released as is. After the release a patch and a build number are incremented in `develop` as always, and the new version is printed in the end (#12). diff --git a/README.md b/README.md index 7a2ac7f..23d26c5 100644 --- a/README.md +++ b/README.md @@ -887,6 +887,45 @@ The recommended way to use it with an AI agent is to mention it in the `CLAUDE.m `AGENTS.md` of the project, so the agent runs `alex agents guide` instead of guessing the commands from a hand written and possibly outdated description. +#### Init agent instructions + +Add or update the alex section in the agent instructions of the project +(`CLAUDE.md`, `AGENTS.md` and alike), so an agent learns about alex from the file it +reads first: + +```bash +$ alex agents init +``` + +The section describes the project - package and version, FVM, locales and l10n paths, +git branches - and the commands an agent should use with their exit codes. Everything is +taken from the alex config, `pubspec.yaml` and the commands tree, so the section can be +regenerated at any time. + +The section is wrapped in the `` / `` markers and +nothing outside of them is changed, so the command is safe to run on a file written by +a human, and safe to run again after the project has changed. + +By default the files are taken from the `agents.files` config option, or `CLAUDE.md` and +`AGENTS.md` if they exist (a symlink between them is resolved, so the section is not +written twice), otherwise `AGENTS.md` is created. You can pass the files explicitly: + +```bash +$ alex agents init -f CLAUDE.md -f .claude/profile/shared.md +``` + +```yaml +agents: + files: [ 'CLAUDE.md', '.claude/profile/shared.md' ] +``` + +For CI: `--check` changes nothing and fails with exit code `10` if some file is missing +or its section is outdated. + +```bash +$ alex agents init --check +``` + ## Problem solving ### Command not found diff --git a/alex.yaml b/alex.yaml index b3bab89..6c8281c 100644 --- a/alex.yaml +++ b/alex.yaml @@ -53,6 +53,12 @@ scripts: ci: enabled: true +# AI agents support (alex agents) +agents: + # Files with the agent instructions to update by `alex agents init`. + # If not defined - CLAUDE.md and AGENTS.md are used, if they exist. + files: [] + # GIT settings git: branches: diff --git a/docs/agent-support-roadmap.md b/docs/agent-support-roadmap.md index dbc5b64..893fac8 100644 --- a/docs/agent-support-roadmap.md +++ b/docs/agent-support-roadmap.md @@ -109,14 +109,16 @@ One generator, three outputs: codes. `--format=json` for a structured index; a command path argument (`alex agents guide l10n`) narrows it. Must stay short — an agent's context is a resource. -- `[ ] llms.txt` in the repository — the same digest written to a file, for web-capable - agents and for humans copying it into their project. Generated (like - `lib/src/version.dart`), with a CI check that it's up to date. -- `[ ] alex agents init` — writes the alex section into the consumer project's - `CLAUDE.md` / `AGENTS.md` and the project facts into `.claude/profile/shared.md` - (locales, l10n paths, gate command, branches) from `alex.yaml` instead of the guesses - `agent-profile-init` makes today. This is the strongest channel: role agents read - those files FIRST, before anything else. +- `[-] llms.txt` in the repository — dropped: the canonical `llms.txt` is for + web-capable agents fetching a docs site, while `agents guide` (local) and + `agents init` (in the consumer project) already cover the agents that matter here. +- `[x] alex agents init` — writes the alex section into the consumer project's + `CLAUDE.md` / `AGENTS.md` (or any files listed in `agents.files` / `--file`, e.g. + `.claude/profile/shared.md`) with the project facts from `alex.yaml` and + `pubspec.yaml` instead of the guesses `agent-profile-init` makes today. This is the + strongest channel: role agents read those files FIRST, before anything else. Only the + content between the `` / `` markers is touched; + `--check` fails with exit code `10` for CI. - `[ ] alex info --format=json` — project facts in one call instead of reading five files: locales, l10n paths (ARB/XML/source), current version, branch names, fvm version, workspace packages, whether generated code is out of sync. @@ -199,9 +201,7 @@ simple lock file inside `alex code gen` / `alex pubspec get`. 1. ~~`alex code check` (#1)~~ — done. 2. `--format=json` for `l10n check_translations` (#2). -2b. Agent onboarding (#5) — `agents guide` done; `llms.txt` and `agents init` next. - Raised above the rest: without a delivery channel agents never notice the other - improvements. +2b. Agent onboarding (#5) — `agents guide` and `agents init` done; `alex info` left. 3. Non-interactive mode (#4). 4. `alex lint` (#3) — largest, needs a decision on how configurable the rules are. 5. Then Tier 2 in listed order. diff --git a/lib/commands/agents/agents_command.dart b/lib/commands/agents/agents_command.dart index 1a3df3a..fc48458 100644 --- a/lib/commands/agents/agents_command.dart +++ b/lib/commands/agents/agents_command.dart @@ -1,12 +1,14 @@ import 'package:alex/runner/alex_command.dart'; import 'guide_command.dart'; +import 'init_command.dart'; /// Command to work with AI agents support. class AgentsCommand extends AlexCommand { AgentsCommand() : super('agents', 'Support of AI agents and scripts.', ['agent']) { addSubcommand(GuideCommand()); + addSubcommand(InitCommand()); } @override diff --git a/lib/commands/agents/init_command.dart b/lib/commands/agents/init_command.dart new file mode 100644 index 0000000..79c59e5 --- /dev/null +++ b/lib/commands/agents/init_command.dart @@ -0,0 +1,194 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:alex/internal/print.dart' as print; +import 'package:alex/runner/alex_command.dart'; +import 'package:alex/src/agents/command_index.dart'; +import 'package:alex/src/agents/managed_block.dart'; +import 'package:alex/src/agents/project_facts.dart'; +import 'package:alex/src/agents/section_renderer.dart'; +import 'package:alex/src/version.dart'; +import 'package:path/path.dart' as p; + +/// Exit code if some file is missing or outdated (with the `--check` flag). +const _kExitCodeOutdated = 10; + +/// Files with the agent instructions to update by default, +/// if they are not defined in the config or in the arguments. +const _kDefaultFiles = ['CLAUDE.md', 'AGENTS.md']; + +/// File to create if there is no file with the agent instructions yet. +const _kFallbackFile = 'AGENTS.md'; + +/// Status of a processed file. +enum _Status { + created('created'), + updated('updated'), + unchanged('unchanged'), + missing('missing'), + outdated('outdated'); + + final String key; + + const _Status(this.key); + + bool get isProblem => this == _Status.missing || this == _Status.outdated; +} + +/// Command to add the alex section in the agent instructions of the project. +class InitCommand extends AlexCommand { + static const _argFile = 'file'; + static const _argCheck = 'check'; + + InitCommand() + : super( + 'init', + 'Add or update the alex section in the agent instructions ' + 'of the project.\n\n' + 'The section describes the project (locales, l10n paths, ' + 'branches, FVM) and the commands an agent should use, ' + 'so an agent learns about alex from the file it reads first. ' + 'Everything is generated from the alex config and the commands ' + 'tree, so it can be updated at any time.\n\n' + 'The section is wrapped in the markers ' + '`${ManagedBlock.beginMarker}` and `${ManagedBlock.endMarker}`, ' + 'nothing outside of them is changed.\n\n' + 'By default the files are taken from the `agents.files` config ' + 'option, or ${_kDefaultFiles.join(' and ')} if they exist, ' + 'otherwise $_kFallbackFile is created.\n\n' + 'Exit code is 0 if the files are up to date or updated, ' + '$_kExitCodeOutdated if some file is missing or outdated ' + 'and --$_argCheck is passed.', + ) { + argParser + ..addMultiOption( + _argFile, + abbr: 'f', + help: 'File with the agent instructions to add the section in. ' + 'Can be passed multiple times.', + valueHelp: 'PATH', + ) + ..addFlag( + _argCheck, + help: 'Do not change anything, just check that the files exist ' + 'and the section in them is up to date.', + negatable: false, + ) + ..addFormatOption(); + } + + @override + Map get exitCodes => const { + _kExitCodeOutdated: 'some file is missing or outdated (with --check)', + }; + + @override + Future doRun() async { + final config = findConfigAndSetWorkingDir(); + final args = argResults!; + final isCheck = args[_argCheck] as bool; + final isJson = args.isJsonFormat(); + + final files = + _targetFiles(config.agents.files, args[_argFile] as List); + printVerbose('Files: ${files.join(', ')}'); + + final facts = await ProjectFacts.collect(config); + final section = SectionRenderer.render( + facts: facts, + index: CommandIndex.build(runner!), + version: packageVersion, + ); + + final results = {}; + + for (final path in files) { + final file = File(path); + final exists = file.existsSync(); + + if (!exists && isCheck) { + results[path] = _Status.missing; + continue; + } + + final content = exists ? file.readAsStringSync() : ''; + + final String updated; + try { + updated = ManagedBlock.apply(content, section); + } on FormatException catch (e) { + return error(2, message: 'Can not update $path: ${e.message}'); + } + + if (content == updated) { + results[path] = _Status.unchanged; + continue; + } + + if (isCheck) { + results[path] = _Status.outdated; + continue; + } + + if (!exists) file.parent.createSync(recursive: true); + file.writeAsStringSync(updated); + results[path] = exists ? _Status.updated : _Status.created; + } + + final hasProblems = results.values.any((s) => s.isProblem); + + if (isJson) { + print.result(jsonEncode({ + 'alex': packageVersion, + 'command': 'agents init', + 'ok': !hasProblems, + 'exitCode': hasProblems ? _kExitCodeOutdated : 0, + 'summary': + results.entries.map((e) => '${e.key}: ${e.value.key}').join(' | '), + 'files': results.entries + .map((e) => {'path': e.key, 'status': e.value.key}) + .toList(), + 'facts': facts.toJson(), + })); + } else { + results.forEach((path, status) { + printInfo('$path: ${status.key}'); + }); + + if (hasProblems) { + printError('Agent instructions are outdated. ' + 'Run "alex agents init" to update them.'); + } + } + + return hasProblems ? _kExitCodeOutdated : 0; + } + + List _targetFiles(List fromConfig, List fromArgs) { + final defined = fromArgs.isNotEmpty ? fromArgs : fromConfig; + if (defined.isNotEmpty) return _unique(defined); + + final existing = _kDefaultFiles.where((f) => File(f).existsSync()); + if (existing.isNotEmpty) return _unique(existing); + + return const [_kFallbackFile]; + } + + /// Returns paths without duplicates, including the ones + /// that point to the same file through a link. + List _unique(Iterable paths) { + final res = []; + final resolved = {}; + + for (final path in paths) { + final file = File(path); + final key = file.existsSync() + ? file.resolveSymbolicLinksSync() + : p.canonicalize(path); + + if (resolved.add(key)) res.add(path); + } + + return res; + } +} diff --git a/lib/src/agents/managed_block.dart b/lib/src/agents/managed_block.dart new file mode 100644 index 0000000..2f225ac --- /dev/null +++ b/lib/src/agents/managed_block.dart @@ -0,0 +1,92 @@ +/// Block of a generated content inside a file that is edited by a human too. +/// +/// The block is marked with [beginMarker] and [endMarker], so it can be +/// updated without touching anything else in the file. +class ManagedBlock { + static const beginMarker = ''; + static const endMarker = ''; + + const ManagedBlock._(); + + /// Returns the content of the block in the [content] + /// or `null` if there is no block. + /// + /// Throws [FormatException] if the markers are broken. + static String? extract(String content) { + final range = _range(content); + if (range == null) return null; + + return content.substring(range.contentStart, range.contentEnd).trim(); + } + + /// Returns `true` if the block in the [content] is the same as [body]. + static bool isUpToDate(String content, String body) => + extract(content) == body.trim(); + + /// Returns the [content] with the block replaced by [body]. + /// + /// If there is no block yet - it will be added at the end of the content. + /// + /// Throws [FormatException] if the markers are broken. + static String apply(String content, String body) { + final block = '$beginMarker\n${body.trim()}\n$endMarker'; + final range = _range(content); + + if (range == null) { + if (content.trim().isEmpty) return '$block\n'; + return '${content.trimRight()}\n\n$block\n'; + } + + return content.substring(0, range.start) + + block + + content.substring(range.end); + } + + static _BlockRange? _range(String content) { + final start = content.indexOf(beginMarker); + final end = content.indexOf(endMarker); + + if (start == -1 && end == -1) return null; + + if (start == -1) { + throw const FormatException( + 'There is a closing marker of the alex block, but no opening one'); + } + + if (end == -1) { + throw const FormatException( + 'There is an opening marker of the alex block, but no closing one'); + } + + if (end < start) { + throw const FormatException( + 'The closing marker of the alex block is before the opening one'); + } + + if (content.indexOf(beginMarker, start + beginMarker.length) != -1) { + throw const FormatException( + 'There is more than one alex block in the file'); + } + + return _BlockRange( + start: start, + contentStart: start + beginMarker.length, + contentEnd: end, + end: end + endMarker.length, + ); + } +} + +class _BlockRange { + final int start; + final int contentStart; + final int contentEnd; + final int end; + + const _BlockRange({ + required this.start, + required this.contentStart, + required this.contentEnd, + required this.end, + }); +} diff --git a/lib/src/agents/project_facts.dart b/lib/src/agents/project_facts.dart new file mode 100644 index 0000000..2dbf63b --- /dev/null +++ b/lib/src/agents/project_facts.dart @@ -0,0 +1,167 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:alex/src/config.dart'; +import 'package:alex/src/fs/fs.dart'; +import 'package:alex/src/pub_spec.dart'; +import 'package:path/path.dart' as p; + +/// Facts about the project that an AI agent or a script needs to know. +/// +/// Collected from the alex configuration and the project files, +/// so they can't diverge from the project. +class ProjectFacts { + final String? packageName; + final String? packageVersion; + + /// Version of Flutter pinned with FVM, if the project uses it. + final String? fvmVersion; + + /// Locales of the app, by the ARB files. + final List locales; + + final L10nConfig l10n; + final AlexGitConfig git; + + const ProjectFacts({ + required this.l10n, + required this.git, + this.packageName, + this.packageVersion, + this.fvmVersion, + this.locales = const [], + }); + + /// Collects the facts of the project with the [config]. + /// + /// Should be called with the project root as a current directory. + static Future collect(AlexConfig config) async { + String? name; + String? version; + + try { + final spec = await Spec.pub(const IOFileSystem()); + name = spec.name; + version = spec.versionOrNull?.toString(); + } on Object catch (_) { + // Pubspec is optional for the facts. + } + + return ProjectFacts( + l10n: config.l10n, + git: config.git, + packageName: name, + packageVersion: version, + fvmVersion: _fvmVersion(), + locales: _locales(config.l10n), + ); + } + + Map toJson() => { + if (packageName != null) 'package': packageName, + if (packageVersion != null) 'version': packageVersion, + if (fvmVersion != null) 'fvm': fvmVersion, + 'locales': locales, + 'l10n': { + 'arbDir': l10n.outputDir, + 'arbPattern': l10n.translationFilesPattern, + 'sourceFile': l10n.sourceFile, + 'xmlDir': l10n.xmlOutputDir, + 'baseLocaleForArb': l10n.baseLocaleForArb.value, + 'baseLocaleForXml': l10n.baseLocaleForXml.value, + }, + 'branches': { + 'master': git.branches.master, + 'develop': git.branches.develop, + 'test': git.branches.test, + 'featurePrefix': git.branches.featurePrefix, + 'remote': git.remote, + }, + }; + + /// Facts as a list of lines for a Markdown document. + List get lines { + final branches = git.branches; + final res = []; + + final name = packageName; + if (name != null) { + final version = packageVersion; + res.add('Package: `$name`${version != null ? ' $version' : ''}'); + } + + final fvm = fvmVersion; + if (fvm != null) { + res.add('Flutter: pinned with FVM ($fvm) - ' + 'run `flutter` and `dart` through `fvm`'); + } + + res.add(locales.isNotEmpty + ? 'Locales (${locales.length}): ' + '${locales.map((l) => '`$l`').join(', ')}' + : 'Locales: not detected - no ARB files in `${l10n.outputDir}`'); + + res.add('ARB: `${p.join(l10n.outputDir, l10n.translationFilesPattern)}`, ' + 'strings source: `${l10n.sourceFile}`'); + + res.add('XML for translation: `${l10n.xmlOutputDir}`, ' + 'base locale for XML: `${l10n.baseLocaleForXml.value}`, ' + 'for ARB: `${l10n.baseLocaleForArb.value}`'); + + res.add('Branches: master `${branches.master}`, ' + 'develop `${branches.develop}`, test `${branches.test}`, ' + 'feature prefix `${branches.featurePrefix}`, ' + 'remote `${git.remote}`'); + + return res; + } + + static String? _fvmVersion() { + for (final path in const ['.fvmrc', '.fvm/fvm_config.json']) { + final file = File(path); + if (!file.existsSync()) continue; + + try { + final data = jsonDecode(file.readAsStringSync()); + if (data is! Map) continue; + + final version = data['flutter'] ?? data['flutterSdkVersion']; + if (version is String && version.isNotEmpty) return version; + } on FormatException catch (_) { + // Broken config - just no fact about FVM. + } + } + + return null; + } + + /// Returns locales of the [fileNames] that match the ARB files [pattern] + /// (`intl_{locale}.arb` and alike), sorted. + static List parseLocales(Iterable fileNames, String pattern) { + final escaped = RegExp.escape(pattern) + .replaceFirst(RegExp.escape('{locale}'), '([a-zA-Z_]+)'); + final regExp = RegExp('^$escaped\$'); + + final res = []; + for (final name in fileNames) { + final match = regExp.firstMatch(name); + if (match != null) res.add(match.group(1)!); + } + + res.sort(); + return res; + } + + static List _locales(L10nConfig config) { + final dir = Directory(config.outputDir); + if (!dir.existsSync()) return const []; + + final names = dir + .listSync() + .whereType() + .map((f) => p.basename(f.path)) + .toList(growable: false); + + return parseLocales(names, config.translationFilesPattern); + } +} diff --git a/lib/src/agents/section_renderer.dart b/lib/src/agents/section_renderer.dart new file mode 100644 index 0000000..5f0642f --- /dev/null +++ b/lib/src/agents/section_renderer.dart @@ -0,0 +1,89 @@ +import 'package:alex/src/agents/command_index.dart'; +import 'package:alex/src/agents/project_facts.dart'; + +/// Commands that are recommended for an agent or a script, +/// in the order they should be listed. +/// +/// A command that is not found in the index is skipped, +/// so the list can't break the section. +const _kRecommendedCommands = [ + 'code check', + 'code gen', + 'l10n check_translations', + 'pubspec get', +]; + +/// Renders the section about alex for the agent instructions +/// of the project (`CLAUDE.md`, `AGENTS.md` and alike). +/// +/// Everything in the section is derived from the project configuration +/// and the commands tree, so it can be regenerated at any time. +class SectionRenderer { + const SectionRenderer._(); + + /// Renders the section in Markdown. + static String render({ + required ProjectFacts facts, + required CommandIndex index, + required String version, + }) { + final sb = StringBuffer() + ..writeln('## alex') + ..writeln() + ..writeln('Generated by `alex agents init` (alex $version). ' + 'Do not edit inside the markers: run the command again to update.') + ..writeln() + ..writeln(_intro) + ..writeln() + ..writeln('### Project') + ..writeln(); + + for (final line in facts.lines) { + sb.writeln('- $line'); + } + + sb + ..writeln() + ..writeln('### Commands') + ..writeln(); + + for (final path in _kRecommendedCommands) { + final command = index.filter(path.split(' '))?.runnable.firstOrNull; + if (command == null) continue; + + final codes = command.exitCodes.entries + .map((e) => '`${e.key}` - ${e.value}') + .join('; '); + + sb.writeln('- `alex ${command.path}` - ${command.summary}' + '${command.supportsJson ? ' Supports `--format=json`.' : ''}' + '${codes.isNotEmpty ? ' Exit codes: $codes.' : ''}'); + } + + sb + ..writeln('- Everything else: run `alex agents guide` ' + 'for the full reference of the installed version.') + ..writeln() + ..writeln(_outro); + + return sb.toString().trim(); + } + + static const _intro = + 'This project is managed with [alex](https://pub.dev/packages/alex) - ' + 'a CLI for releases, localization, code generation and quality gates. ' + 'Prefer its commands over hand written shell: they already know the ' + 'configuration below, so they handle FVM, pub workspaces, subprojects ' + 'and the localization pipeline for you.'; + + static const _outro = + 'Check the exit code of a command, do not parse its human readable ' + 'output: `0` - success, `1` - the command could not do its job, ' + '`2` - an unexpected error, `64` - wrong usage. Commands that support ' + '`--format=json` print a single JSON object in stdout, ' + 'everything else - including the update banner - goes to stderr.'; +} + +extension _FirstOrNullExtension on List { + T? get firstOrNull => isEmpty ? null : first; +} diff --git a/lib/src/config.dart b/lib/src/config.dart index 893de85..6f92cec 100644 --- a/lib/src/config.dart +++ b/lib/src/config.dart @@ -99,6 +99,7 @@ class AlexConfig { AlexScriptsConfig? _scripts; CodeConfig? _code; AlexBuildConfig? _build; + AlexAgentsConfig? _agents; AlexConfig._(this._path, this._data); @@ -155,9 +156,39 @@ class AlexConfig { data != null ? AlexBuildConfig.fromYaml(data) : const AlexBuildConfig(); } + AlexAgentsConfig get agents { + const key = 'agents'; + final data = _data[key] as YamlMap?; + return _agents ??= data != null + ? AlexAgentsConfig.fromYaml(data) + : const AlexAgentsConfig(); + } + String get rootPath => p.dirname(_path); } +/// Configuration of the AI agents support (`alex agents`). +class AlexAgentsConfig { + /// Files with the agent instructions to update by `alex agents init`. + /// + /// If not defined - `CLAUDE.md` and `AGENTS.md` are used if they exist. + final List files; + + const AlexAgentsConfig({this.files = const []}); + + factory AlexAgentsConfig.fromYaml(YamlMap data) { + final files = data['files'] as YamlList?; + return AlexAgentsConfig( + files: files?.whereType().toList(growable: false) ?? const [], + ); + } + + @override + String toString() { + return 'AlexAgentsConfig{files: $files}'; + } +} + /// Configuration for application builds (`alex release start --local`). class AlexBuildConfig { /// Name to use for built artifact files. diff --git a/test/src/agents/managed_block_test.dart b/test/src/agents/managed_block_test.dart new file mode 100644 index 0000000..2fd92fb --- /dev/null +++ b/test/src/agents/managed_block_test.dart @@ -0,0 +1,120 @@ +import 'package:alex/src/agents/managed_block.dart'; +import 'package:alex/src/agents/project_facts.dart'; +import 'package:test/test.dart'; + +const _begin = ManagedBlock.beginMarker; +const _end = ManagedBlock.endMarker; + +void main() { + group('apply()', () { + test('should add a block to an empty content', () { + final res = ManagedBlock.apply('', 'Body.'); + + expect(res, '$_begin\nBody.\n$_end\n'); + }); + + test('should append a block and keep the existing content', () { + const content = '# Title\n\nSome human text.\n'; + + final res = ManagedBlock.apply(content, 'Body.'); + + expect(res, '# Title\n\nSome human text.\n\n$_begin\nBody.\n$_end\n'); + }); + + test('should replace the block and keep everything around it', () { + const content = 'Before.\n\n' + '$_begin\nOld body.\n$_end\n\n' + 'After.\n'; + + final res = ManagedBlock.apply(content, 'New body.'); + + expect(res, 'Before.\n\n$_begin\nNew body.\n$_end\n\nAfter.\n'); + }); + + test('should not change the content if the block is the same', () { + final content = ManagedBlock.apply('Before.\n', 'Body.'); + + expect(ManagedBlock.apply(content, 'Body.'), content); + }); + + test('should throw if there is no opening marker', () { + expect(() => ManagedBlock.apply('Text.\n$_end\n', 'Body.'), + throwsFormatException); + }); + + test('should throw if there is no closing marker', () { + expect(() => ManagedBlock.apply('$_begin\nText.\n', 'Body.'), + throwsFormatException); + }); + + test('should throw if the markers are in the wrong order', () { + expect(() => ManagedBlock.apply('$_end\nText.\n$_begin\n', 'Body.'), + throwsFormatException); + }); + + test('should throw if there is more than one block', () { + const content = '$_begin\nOne.\n$_end\n$_begin\nTwo.\n$_end\n'; + + expect(() => ManagedBlock.apply(content, 'Body.'), throwsFormatException); + }); + }); + + group('extract()', () { + test('should return the body of the block', () { + final content = ManagedBlock.apply('Before.', 'Body.\nSecond line.'); + + expect(ManagedBlock.extract(content), 'Body.\nSecond line.'); + }); + + test('should return null if there is no block', () { + expect(ManagedBlock.extract('Just text.'), isNull); + }); + }); + + group('isUpToDate()', () { + test('should return true for the same body', () { + final content = ManagedBlock.apply('Before.', 'Body.'); + + expect(ManagedBlock.isUpToDate(content, 'Body.'), true); + expect(ManagedBlock.isUpToDate(content, 'Other body.'), false); + }); + + test('should return false if there is no block', () { + expect(ManagedBlock.isUpToDate('Just text.', 'Body.'), false); + }); + }); + + group('ProjectFacts.parseLocales()', () { + test('should return sorted locales of the matched files', () { + final res = ProjectFacts.parseLocales( + const ['intl_ru.arb', 'intl_en.arb', 'intl_pt_BR.arb'], + 'intl_{locale}.arb', + ); + + expect(res, ['en', 'pt_BR', 'ru']); + }); + + test('should skip files that do not match the pattern', () { + final res = ProjectFacts.parseLocales( + const [ + 'intl_en.arb', + 'messages_en.dart', + 'intl_en.arb.bak', + 'l10n.dart' + ], + 'intl_{locale}.arb', + ); + + expect(res, ['en']); + }); + + test('should support a custom pattern', () { + final res = ProjectFacts.parseLocales( + const ['app_en.arb', 'app_ru.arb'], + 'app_{locale}.arb', + ); + + expect(res, ['en', 'ru']); + }); + }); +} From 16406573909d6bb3c4768c562e63661602bc5052 Mon Sep 17 00:00:00 2001 From: greymag Date: Tue, 25 Aug 2026 16:09:47 +0200 Subject: [PATCH 07/13] fix(agents): more accurate locales in the project facts - a locale can have a numeric region (`es_419`, `en_001`) or be written through a dash, so it is not only letters and underscores; - `intl_messages.arb` matches the ARB pattern too, but it's the file with the extracted messages, not a locale - the name is taken from `L10nUtils.arbMessagesSuffix`, so it can't diverge from the l10n commands; - an extra closing marker of the managed block is reported as a broken file too, not only a second opening one. Co-Authored-By: Claude Opus 5 (1M context) --- lib/src/agents/managed_block.dart | 5 +++-- lib/src/agents/project_facts.dart | 15 +++++++++++++-- test/src/agents/managed_block_test.dart | 24 ++++++++++++++++++++++++ 3 files changed, 40 insertions(+), 4 deletions(-) diff --git a/lib/src/agents/managed_block.dart b/lib/src/agents/managed_block.dart index 2f225ac..950b368 100644 --- a/lib/src/agents/managed_block.dart +++ b/lib/src/agents/managed_block.dart @@ -63,9 +63,10 @@ class ManagedBlock { 'The closing marker of the alex block is before the opening one'); } - if (content.indexOf(beginMarker, start + beginMarker.length) != -1) { + if (content.indexOf(beginMarker, start + beginMarker.length) != -1 || + content.indexOf(endMarker, end + endMarker.length) != -1) { throw const FormatException( - 'There is more than one alex block in the file'); + 'There is more than one alex marker of the same type in the file'); } return _BlockRange( diff --git a/lib/src/agents/project_facts.dart b/lib/src/agents/project_facts.dart index 2dbf63b..64a94b1 100644 --- a/lib/src/agents/project_facts.dart +++ b/lib/src/agents/project_facts.dart @@ -3,6 +3,7 @@ import 'dart:io'; import 'package:alex/src/config.dart'; import 'package:alex/src/fs/fs.dart'; +import 'package:alex/src/l10n/l10n_utils.dart'; import 'package:alex/src/pub_spec.dart'; import 'package:path/path.dart' as p; @@ -137,15 +138,25 @@ class ProjectFacts { /// Returns locales of the [fileNames] that match the ARB files [pattern] /// (`intl_{locale}.arb` and alike), sorted. + /// + /// The file with the extracted messages (`intl_messages.arb`) matches the + /// pattern too, but it's not a locale, so it's skipped. static List parseLocales(Iterable fileNames, String pattern) { + // A locale can have a numeric region (es_419, en_001) + // and can be written through a dash. final escaped = RegExp.escape(pattern) - .replaceFirst(RegExp.escape('{locale}'), '([a-zA-Z_]+)'); + .replaceFirst(RegExp.escape('{locale}'), '([a-zA-Z0-9_-]+)'); final regExp = RegExp('^$escaped\$'); final res = []; for (final name in fileNames) { final match = regExp.firstMatch(name); - if (match != null) res.add(match.group(1)!); + if (match == null) continue; + + final locale = match.group(1)!; + if (locale == L10nUtils.arbMessagesSuffix) continue; + + res.add(locale); } res.sort(); diff --git a/test/src/agents/managed_block_test.dart b/test/src/agents/managed_block_test.dart index 2fd92fb..258942c 100644 --- a/test/src/agents/managed_block_test.dart +++ b/test/src/agents/managed_block_test.dart @@ -57,6 +57,12 @@ void main() { expect(() => ManagedBlock.apply(content, 'Body.'), throwsFormatException); }); + + test('should throw if there is an extra closing marker', () { + const content = '$_begin\nOne.\n$_end\n\nText.\n$_end\n'; + + expect(() => ManagedBlock.apply(content, 'Body.'), throwsFormatException); + }); }); group('extract()', () { @@ -108,6 +114,24 @@ void main() { expect(res, ['en']); }); + test('should keep a locale with a numeric region', () { + final res = ProjectFacts.parseLocales( + const ['intl_es_419.arb', 'intl_en_001.arb', 'intl_en.arb'], + 'intl_{locale}.arb', + ); + + expect(res, ['en', 'en_001', 'es_419']); + }); + + test('should skip the file with the extracted messages', () { + final res = ProjectFacts.parseLocales( + const ['intl_messages.arb', 'intl_en.arb', 'intl_ru.arb'], + 'intl_{locale}.arb', + ); + + expect(res, ['en', 'ru']); + }); + test('should support a custom pattern', () { final res = ProjectFacts.parseLocales( const ['app_en.arb', 'app_ru.arb'], From 1443ae09bb3e667fcc1847588733a8ce5da82179 Mon Sep 17 00:00:00 2001 From: greymag Date: Tue, 25 Aug 2026 16:38:59 +0200 Subject: [PATCH 08/13] docs(changelog): group the agent support entries together Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ebf7a0..b711195 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,5 @@ ## Next -* [Agents] New command `agents init`: adds or updates the alex section in the agent instructions of the project (`CLAUDE.md`, `AGENTS.md` and alike), so an agent learns about alex from the file it reads first. The section describes the project (package, FVM, locales, l10n paths, branches) and the commands to use with their exit codes, and is generated from the alex config, `pubspec.yaml` and the commands tree. Only the content between the `` and `` markers is changed. Files are taken from the new `agents.files` config option, the `--file` (`-f`) option, or `CLAUDE.md`/`AGENTS.md` if they exist. With the `--check` flag nothing is changed and the command fails with exit code `10` if some file is missing or outdated. -* [Agents] New command group `agents` with the `guide` subcommand: prints a short guide of alex for an AI agent or a script - the rules (config discovery, machine readable output, exit codes, interactive commands) and all commands with their options and exit codes. The guide is generated from the commands tree of the installed version, so it can't get outdated. Supports a command path argument to print a part of it (`alex agents guide l10n`) and `--format=json` for a structured index. * New command `alex pubspec version ` (alias `alex pub version`) — increments the version in `pubspec.yaml`. By default only the version is changed and a build number is kept as is (usually it's already prepared for the next build); pass `--build` (`-b`) to increment the build number too (#12). * [Release] `start`: new `--increment` (`-i`) option (`patch`, `minor` or `major`) to increment the version **of this release** before it's started, keeping the build number: `1.2.3+4` is released as `1.3.0+4` with `-i minor`. The release branch, `CHANGELOG.md`, the tag and build artifacts use the new version. Without the option nothing changes — the current version is released as is. After the release a patch and a build number are incremented in `develop` as always, and the new version is printed in the end (#12). * [Release] `start`: fixed a failure of a local iOS build (`--local`), when alex reported that the artifact was placed in an unexpected directory, though the `.ipa` was built correctly. `flutter build ipa` prints two `✓ Built` lines — for the Xcode archive and for the IPA itself — and alex checked the first one. Now the line with a path in the build output directory is used. @@ -11,6 +9,8 @@ * **Fixed**: the output of a command could be lost, so a failed command could be reported without its error message. Now alex waits for the output to be fully read before it handles the result. * [Code] New command `check`: runs the quality gates - analyze, tests and (optionally, with the `--build` flag) a debug build of the platform target. Output of the commands is filtered from a noise, a short verdict is printed for each gate. Exit code is `0` if all gates passed, `10` if analyze failed, `11` if tests failed, `12` if build failed. Flags `--analyze-only`, `--test-only` and `--fail-fast` are supported, arguments after `--` are passed to the test command. Build target and additional noise patterns can be defined with the new `code.check` config section. * [Common] New `--format=json` option (supported by `code check` for now): a command prints a single JSON object with a machine readable report in the standard output, all other messages - including the update banner - go to the error output. +* [Agents] New command group `agents` with the `guide` subcommand: prints a short guide of alex for an AI agent or a script - the rules (config discovery, machine readable output, exit codes, interactive commands) and all commands with their options and exit codes. The guide is generated from the commands tree of the installed version, so it can't get outdated. Supports a command path argument to print a part of it (`alex agents guide l10n`) and `--format=json` for a structured index. +* [Agents] New command `agents init`: adds or updates the alex section in the agent instructions of the project (`CLAUDE.md`, `AGENTS.md` and alike), so an agent learns about alex from the file it reads first. The section describes the project (package, FVM, locales, l10n paths, branches) and the commands to use with their exit codes, and is generated from the alex config, `pubspec.yaml` and the commands tree. Only the content between the `` and `` markers is changed. Files are taken from the new `agents.files` config option, the `--file` (`-f`) option, or `CLAUDE.md`/`AGENTS.md` if they exist. With the `--check` flag nothing is changed and the command fails with exit code `10` if some file is missing or outdated. ## 1.14.0 From aea6304bf3706de7a5db53e56cbbfac9be536aaf Mon Sep 17 00:00:00 2001 From: greymag Date: Tue, 25 Aug 2026 16:49:01 +0200 Subject: [PATCH 09/13] feat(info): new `info` command with the project facts Package and version, root and config path, Flutter or Dart, packages of a multi-package project, the Flutter version pinned with FVM, locales (by the ARB files) and localization paths, git branches - in one call with `--format=json`, instead of reading several files. The facts are collected by the same `ProjectFacts` as `agents init` uses for the section it writes, so the two can't disagree. The localization facts are skipped when the project has no ARB directory, so a project that doesn't use the alex l10n gets no facts about directories that don't exist. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 3 +- README.md | 17 ++++ docs/agent-support-roadmap.md | 12 ++- lib/commands/info_command.dart | 56 +++++++++++ lib/runner/alex_command_runner.dart | 2 + lib/src/agents/project_facts.dart | 123 +++++++++++++++++++---- lib/src/config.dart | 3 + test/src/agents/managed_block_test.dart | 53 ---------- test/src/agents/project_facts_test.dart | 128 ++++++++++++++++++++++++ 9 files changed, 320 insertions(+), 77 deletions(-) create mode 100644 lib/commands/info_command.dart create mode 100644 test/src/agents/project_facts_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index b711195..a9abbe7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,9 +8,10 @@ * **Fixed**: alex hangs if a command it runs asks for an input. For example, `alex code gen` stopped with no output when `fvm` asked to install a missing Flutter SDK. Such a question can't be answered anyway, because the command gets a pipe instead of the terminal, so its input is closed right after the start now, and the command fails with its own message instead of hanging. * **Fixed**: the output of a command could be lost, so a failed command could be reported without its error message. Now alex waits for the output to be fully read before it handles the result. * [Code] New command `check`: runs the quality gates - analyze, tests and (optionally, with the `--build` flag) a debug build of the platform target. Output of the commands is filtered from a noise, a short verdict is printed for each gate. Exit code is `0` if all gates passed, `10` if analyze failed, `11` if tests failed, `12` if build failed. Flags `--analyze-only`, `--test-only` and `--fail-fast` are supported, arguments after `--` are passed to the test command. Build target and additional noise patterns can be defined with the new `code.check` config section. -* [Common] New `--format=json` option (supported by `code check` for now): a command prints a single JSON object with a machine readable report in the standard output, all other messages - including the update banner - go to the error output. +* [Common] New `--format=json` option (supported by `code check`, `info` and `agents` commands): a command prints a single JSON object with a machine readable report in the standard output, all other messages - including the update banner - go to the error output. * [Agents] New command group `agents` with the `guide` subcommand: prints a short guide of alex for an AI agent or a script - the rules (config discovery, machine readable output, exit codes, interactive commands) and all commands with their options and exit codes. The guide is generated from the commands tree of the installed version, so it can't get outdated. Supports a command path argument to print a part of it (`alex agents guide l10n`) and `--format=json` for a structured index. * [Agents] New command `agents init`: adds or updates the alex section in the agent instructions of the project (`CLAUDE.md`, `AGENTS.md` and alike), so an agent learns about alex from the file it reads first. The section describes the project (package, FVM, locales, l10n paths, branches) and the commands to use with their exit codes, and is generated from the alex config, `pubspec.yaml` and the commands tree. Only the content between the `` and `` markers is changed. Files are taken from the new `agents.files` config option, the `--file` (`-f`) option, or `CLAUDE.md`/`AGENTS.md` if they exist. With the `--check` flag nothing is changed and the command fails with exit code `10` if some file is missing or outdated. +* [Info] New command `alex info` (alias `alex facts`): prints the facts about the project - package and version, path to the alex config, packages of a multi-package project, Flutter version pinned with FVM, locales (by the ARB files) and localization paths, git branches. Supports `--format=json`, so a script or an AI agent can get all of it with a single call instead of reading several files. ## 1.14.0 diff --git a/README.md b/README.md index 23d26c5..9ec1721 100644 --- a/README.md +++ b/README.md @@ -859,6 +859,23 @@ This will show: See `alex_custom_commands.yaml.example` in the repository for more examples. +### Info + +Print the facts about the project: package and version, path to the alex config, +packages of a multi-package project, Flutter version pinned with FVM, locales and +localization paths, git branches. + +```bash +$ alex info +``` + +Everything is taken from the alex config and the project files, so a script or an AI +agent can get all of it with a single call instead of reading several files: + +```bash +$ alex info --format=json +``` + ### Agents Support of AI agents and scripts. diff --git a/docs/agent-support-roadmap.md b/docs/agent-support-roadmap.md index 893fac8..9abaac8 100644 --- a/docs/agent-support-roadmap.md +++ b/docs/agent-support-roadmap.md @@ -89,7 +89,7 @@ Every `readLineSync` gets a corresponding flag: Plus a global `--non-interactive` that fails with an explicit message instead of blocking on stdin. Without this agents cannot use `feature` / `release` at all. -#### 5. `[~] Agent onboarding: how an agent learns that alex exists` +#### 5. `[x] Agent onboarding: how an agent learns that alex exists` The blocker before every other item: an agent working in a consumer project has no reason to call a CLI it doesn't know about. A static `llms.txt` in this repository does @@ -119,9 +119,11 @@ One generator, three outputs: strongest channel: role agents read those files FIRST, before anything else. Only the content between the `` / `` markers is touched; `--check` fails with exit code `10` for CI. -- `[ ] alex info --format=json` — project facts in one call instead of reading five - files: locales, l10n paths (ARB/XML/source), current version, branch names, fvm - version, workspace packages, whether generated code is out of sync. +- `[x] alex info --format=json` — project facts in one call instead of reading five + files: root and config path, package and version, Flutter vs Dart, packages of a + multi-package project, fvm version, locales (by the ARB files), l10n paths, branches. + Shares the `ProjectFacts` collector with `agents init`, so the two can't disagree. + Left for later: whether the generated code is out of sync (see #8). ### Tier 2 — meaningful simplification @@ -201,7 +203,7 @@ simple lock file inside `alex code gen` / `alex pubspec get`. 1. ~~`alex code check` (#1)~~ — done. 2. `--format=json` for `l10n check_translations` (#2). -2b. Agent onboarding (#5) — `agents guide` and `agents init` done; `alex info` left. +2b. Agent onboarding (#5) — done: `agents guide`, `agents init`, `info`. 3. Non-interactive mode (#4). 4. `alex lint` (#3) — largest, needs a decision on how configurable the rules are. 5. Then Tier 2 in listed order. diff --git a/lib/commands/info_command.dart b/lib/commands/info_command.dart new file mode 100644 index 0000000..8ec51d2 --- /dev/null +++ b/lib/commands/info_command.dart @@ -0,0 +1,56 @@ +import 'dart:convert'; + +import 'package:alex/internal/print.dart' as print; +import 'package:alex/runner/alex_command.dart'; +import 'package:alex/src/agents/project_facts.dart'; +import 'package:alex/src/version.dart'; + +/// Command to print the facts about the project. +class InfoCommand extends AlexCommand { + InfoCommand() + : super( + 'info', + 'Print the facts about the project.\n\n' + 'Package and version, path to the alex config, packages of a ' + 'multi-package project, Flutter version pinned with FVM, ' + 'locales and localization paths, git branches.\n\n' + 'Everything is taken from the alex config and the project ' + 'files, so a script or an AI agent can get all of it with a ' + 'single call instead of reading several files.', + ['facts'], + ) { + argParser.addFormatOption(); + } + + @override + Future doRun() async { + final config = findConfigAndSetWorkingDir(); + final facts = await ProjectFacts.collect(config); + + if (argResults!.isJsonFormat()) { + print.result(jsonEncode({ + 'alex': packageVersion, + 'command': 'info', + 'ok': true, + 'summary': _summary(facts), + ...facts.toJson(), + })); + } else { + printInfo('alex $packageVersion'); + for (final line in facts.lines) { + printInfo('- $line'); + } + } + + return 0; + } + + String _summary(ProjectFacts facts) { + final name = facts.packageName ?? 'unknown package'; + final version = facts.packageVersion; + return '$name${version != null ? ' $version' : ''}, ' + '${facts.isFlutter ? 'flutter' : 'dart'}, ' + '${facts.locales.length} locale(s), ' + '${facts.packages.length} package(s)'; + } +} diff --git a/lib/runner/alex_command_runner.dart b/lib/runner/alex_command_runner.dart index cbfbd77..7886d60 100644 --- a/lib/runner/alex_command_runner.dart +++ b/lib/runner/alex_command_runner.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:alex/commands/agents/agents_command.dart'; import 'package:alex/commands/changelog/changelog_command.dart'; +import 'package:alex/commands/info_command.dart'; import 'package:alex/commands/code/code_command.dart'; import 'package:alex/commands/custom/custom_command.dart'; import 'package:alex/commands/custom/user_custom_command.dart'; @@ -45,6 +46,7 @@ class AlexCommandRunner extends CommandRunner { UpdateCommand(), CustomCommand(), AgentsCommand(), + InfoCommand(), ].forEach(addCommand); // Load and register custom commands diff --git a/lib/src/agents/project_facts.dart b/lib/src/agents/project_facts.dart index 64a94b1..071445f 100644 --- a/lib/src/agents/project_facts.dart +++ b/lib/src/agents/project_facts.dart @@ -12,9 +12,27 @@ import 'package:path/path.dart' as p; /// Collected from the alex configuration and the project files, /// so they can't diverge from the project. class ProjectFacts { + /// Path to the root directory of the project. + final String rootPath; + + /// Path to the file with the alex configuration, + /// relative to the [rootPath]. + final String configPath; + + /// Whether the project has the localization managed by alex. + final bool hasL10n; + final String? packageName; final String? packageVersion; + /// Whether the project depends on Flutter. + final bool isFlutter; + + /// All packages of the project: subprojects and workspace members. + /// + /// Contains more than one item only for a multi-package project. + final List packages; + /// Version of Flutter pinned with FVM, if the project uses it. final String? fvmVersion; @@ -27,8 +45,13 @@ class ProjectFacts { const ProjectFacts({ required this.l10n, required this.git, + this.rootPath = '', + this.configPath = '', + this.hasL10n = false, this.packageName, this.packageVersion, + this.isFlutter = false, + this.packages = const [], this.fvmVersion, this.locales = const [], }); @@ -39,11 +62,13 @@ class ProjectFacts { static Future collect(AlexConfig config) async { String? name; String? version; + var isFlutter = false; try { final spec = await Spec.pub(const IOFileSystem()); name = spec.name; version = spec.versionOrNull?.toString(); + isFlutter = spec.dependsOn('flutter'); } on Object catch (_) { // Pubspec is optional for the facts. } @@ -51,25 +76,37 @@ class ProjectFacts { return ProjectFacts( l10n: config.l10n, git: config.git, + rootPath: config.rootPath, + configPath: p.relative(config.configPath, from: config.rootPath), + hasL10n: Directory(config.l10n.outputDir).existsSync(), packageName: name, packageVersion: version, + isFlutter: isFlutter, + packages: await _packages(), fvmVersion: _fvmVersion(), locales: _locales(config.l10n), ); } Map toJson() => { + if (rootPath.isNotEmpty) 'root': rootPath, + if (configPath.isNotEmpty) 'config': configPath, if (packageName != null) 'package': packageName, if (packageVersion != null) 'version': packageVersion, + 'flutter': isFlutter, + if (packages.length > 1) + 'packages': packages.map((p) => p.toJson()).toList(), if (fvmVersion != null) 'fvm': fvmVersion, - 'locales': locales, - 'l10n': { - 'arbDir': l10n.outputDir, - 'arbPattern': l10n.translationFilesPattern, - 'sourceFile': l10n.sourceFile, - 'xmlDir': l10n.xmlOutputDir, - 'baseLocaleForArb': l10n.baseLocaleForArb.value, - 'baseLocaleForXml': l10n.baseLocaleForXml.value, + if (hasL10n) ...{ + 'locales': locales, + 'l10n': { + 'arbDir': l10n.outputDir, + 'arbPattern': l10n.translationFilesPattern, + 'sourceFile': l10n.sourceFile, + 'xmlDir': l10n.xmlOutputDir, + 'baseLocaleForArb': l10n.baseLocaleForArb.value, + 'baseLocaleForXml': l10n.baseLocaleForXml.value, + }, }, 'branches': { 'master': git.branches.master, @@ -85,10 +122,14 @@ class ProjectFacts { final branches = git.branches; final res = []; + if (rootPath.isNotEmpty) res.add('Project root: `$rootPath`'); + if (configPath.isNotEmpty) res.add('Alex config: `$configPath`'); + final name = packageName; if (name != null) { final version = packageVersion; - res.add('Package: `$name`${version != null ? ' $version' : ''}'); + res.add('Package: `$name`${version != null ? ' $version' : ''}' + ' (${isFlutter ? 'Flutter' : 'Dart'})'); } final fvm = fvmVersion; @@ -97,17 +138,24 @@ class ProjectFacts { 'run `flutter` and `dart` through `fvm`'); } - res.add(locales.isNotEmpty - ? 'Locales (${locales.length}): ' - '${locales.map((l) => '`$l`').join(', ')}' - : 'Locales: not detected - no ARB files in `${l10n.outputDir}`'); + if (hasL10n) { + res.add(locales.isNotEmpty + ? 'Locales (${locales.length}): ' + '${locales.map((l) => '`$l`').join(', ')}' + : 'Locales: not detected - no ARB files in `${l10n.outputDir}`'); - res.add('ARB: `${p.join(l10n.outputDir, l10n.translationFilesPattern)}`, ' - 'strings source: `${l10n.sourceFile}`'); + res.add('ARB: `${p.join(l10n.outputDir, l10n.translationFilesPattern)}`, ' + 'strings source: `${l10n.sourceFile}`'); - res.add('XML for translation: `${l10n.xmlOutputDir}`, ' - 'base locale for XML: `${l10n.baseLocaleForXml.value}`, ' - 'for ARB: `${l10n.baseLocaleForArb.value}`'); + res.add('XML for translation: `${l10n.xmlOutputDir}`, ' + 'base locale for XML: `${l10n.baseLocaleForXml.value}`, ' + 'for ARB: `${l10n.baseLocaleForArb.value}`'); + } + + if (packages.length > 1) { + res.add('Packages (${packages.length}): ' + '${packages.map((p) => '`${p.name}` (`${p.path}`)').join(', ')}'); + } res.add('Branches: master `${branches.master}`, ' 'develop `${branches.develop}`, test `${branches.test}`, ' @@ -117,6 +165,30 @@ class ProjectFacts { return res; } + static Future> _packages() async { + try { + final files = await Spec.getPubspecs(); + + final res = []; + for (final file in files) { + try { + final spec = Spec.byFile(file); + final dir = p.dirname(p.relative(file.path, from: p.current)); + res.add(ProjectPackage( + name: spec.name, + path: dir.isEmpty ? '.' : dir, + )); + } on Object catch (_) { + // Broken pubspec - just not a package in the list. + } + } + + return res; + } on Object catch (_) { + return const []; + } + } + static String? _fvmVersion() { for (final path in const ['.fvmrc', '.fvm/fvm_config.json']) { final file = File(path); @@ -176,3 +248,18 @@ class ProjectFacts { return parseLocales(names, config.translationFilesPattern); } } + +/// Package of the project. +class ProjectPackage { + final String name; + + /// Path to the package directory, relative to the project root. + final String path; + + const ProjectPackage({required this.name, required this.path}); + + Map toJson() => { + 'name': name, + 'path': path, + }; +} diff --git a/lib/src/config.dart b/lib/src/config.dart index 6f92cec..94c210a 100644 --- a/lib/src/config.dart +++ b/lib/src/config.dart @@ -164,6 +164,9 @@ class AlexConfig { : const AlexAgentsConfig(); } + /// Path to the file with the configuration. + String get configPath => _path; + String get rootPath => p.dirname(_path); } diff --git a/test/src/agents/managed_block_test.dart b/test/src/agents/managed_block_test.dart index 258942c..fef6e4f 100644 --- a/test/src/agents/managed_block_test.dart +++ b/test/src/agents/managed_block_test.dart @@ -1,5 +1,4 @@ import 'package:alex/src/agents/managed_block.dart'; -import 'package:alex/src/agents/project_facts.dart'; import 'package:test/test.dart'; const _begin = ManagedBlock.beginMarker; @@ -89,56 +88,4 @@ void main() { expect(ManagedBlock.isUpToDate('Just text.', 'Body.'), false); }); }); - - group('ProjectFacts.parseLocales()', () { - test('should return sorted locales of the matched files', () { - final res = ProjectFacts.parseLocales( - const ['intl_ru.arb', 'intl_en.arb', 'intl_pt_BR.arb'], - 'intl_{locale}.arb', - ); - - expect(res, ['en', 'pt_BR', 'ru']); - }); - - test('should skip files that do not match the pattern', () { - final res = ProjectFacts.parseLocales( - const [ - 'intl_en.arb', - 'messages_en.dart', - 'intl_en.arb.bak', - 'l10n.dart' - ], - 'intl_{locale}.arb', - ); - - expect(res, ['en']); - }); - - test('should keep a locale with a numeric region', () { - final res = ProjectFacts.parseLocales( - const ['intl_es_419.arb', 'intl_en_001.arb', 'intl_en.arb'], - 'intl_{locale}.arb', - ); - - expect(res, ['en', 'en_001', 'es_419']); - }); - - test('should skip the file with the extracted messages', () { - final res = ProjectFacts.parseLocales( - const ['intl_messages.arb', 'intl_en.arb', 'intl_ru.arb'], - 'intl_{locale}.arb', - ); - - expect(res, ['en', 'ru']); - }); - - test('should support a custom pattern', () { - final res = ProjectFacts.parseLocales( - const ['app_en.arb', 'app_ru.arb'], - 'app_{locale}.arb', - ); - - expect(res, ['en', 'ru']); - }); - }); } diff --git a/test/src/agents/project_facts_test.dart b/test/src/agents/project_facts_test.dart new file mode 100644 index 0000000..752e00c --- /dev/null +++ b/test/src/agents/project_facts_test.dart @@ -0,0 +1,128 @@ +import 'package:alex/src/agents/project_facts.dart'; +import 'package:alex/src/config.dart'; +import 'package:test/test.dart'; + +void main() { + group('parseLocales()', () { + test('should return sorted locales of the matched files', () { + final res = ProjectFacts.parseLocales( + const ['intl_ru.arb', 'intl_en.arb', 'intl_pt_BR.arb'], + 'intl_{locale}.arb', + ); + + expect(res, ['en', 'pt_BR', 'ru']); + }); + + test('should skip files that do not match the pattern', () { + final res = ProjectFacts.parseLocales( + const [ + 'intl_en.arb', + 'messages_en.dart', + 'intl_en.arb.bak', + 'l10n.dart' + ], + 'intl_{locale}.arb', + ); + + expect(res, ['en']); + }); + + test('should keep a locale with a numeric region', () { + final res = ProjectFacts.parseLocales( + const ['intl_es_419.arb', 'intl_en_001.arb', 'intl_en.arb'], + 'intl_{locale}.arb', + ); + + expect(res, ['en', 'en_001', 'es_419']); + }); + + test('should skip the file with the extracted messages', () { + final res = ProjectFacts.parseLocales( + const ['intl_messages.arb', 'intl_en.arb', 'intl_ru.arb'], + 'intl_{locale}.arb', + ); + + expect(res, ['en', 'ru']); + }); + + test('should support a custom pattern', () { + final res = ProjectFacts.parseLocales( + const ['app_en.arb', 'app_ru.arb'], + 'app_{locale}.arb', + ); + + expect(res, ['en', 'ru']); + }); + }); + + group('lines', () { + test('should describe the project', () { + final facts = ProjectFacts( + l10n: L10nConfig(), + git: const AlexGitConfig(), + rootPath: '/projects/demo', + configPath: 'alex.yaml', + packageName: 'demo_app', + packageVersion: '1.2.3+4', + isFlutter: true, + fvmVersion: '3.32.6', + hasL10n: true, + locales: const ['en', 'ru'], + ); + + final res = facts.lines.join('\n'); + + expect(res, contains('Project root: `/projects/demo`')); + expect(res, contains('Alex config: `alex.yaml`')); + expect(res, contains('Package: `demo_app` 1.2.3+4 (Flutter)')); + expect(res, contains('FVM (3.32.6)')); + expect(res, contains('Locales (2): `en`, `ru`')); + expect(res, contains('Branches: master `master`')); + }); + + test('should mark a project without Flutter as Dart', () { + final facts = ProjectFacts( + l10n: L10nConfig(), + git: const AlexGitConfig(), + packageName: 'some_tool', + ); + + expect(facts.lines.join('\n'), contains('Package: `some_tool` (Dart)')); + }); + + test('should skip the localization facts if there is no l10n', () { + final facts = ProjectFacts( + l10n: L10nConfig(), + git: const AlexGitConfig(), + packageName: 'some_tool', + ); + + final res = facts.lines.join('\n'); + + expect(res, isNot(contains('Locales'))); + expect(res, isNot(contains('ARB'))); + expect(res, isNot(contains('XML'))); + }); + + test('should list the packages of a multi-package project only', () { + ProjectFacts factsOf(List packages) => ProjectFacts( + l10n: L10nConfig(), + git: const AlexGitConfig(), + packageName: 'demo_app', + packages: packages, + ); + + const single = [ProjectPackage(name: 'demo_app', path: '.')]; + const multi = [ + ProjectPackage(name: 'demo_app', path: '.'), + ProjectPackage(name: 'demo_core', path: 'packages/core'), + ]; + + expect(factsOf(single).lines.join('\n'), isNot(contains('Packages'))); + expect( + factsOf(multi).lines.join('\n'), + contains('Packages (2): `demo_app` (`.`), `demo_core` ' + '(`packages/core`)')); + }); + }); +} From 3ef2a8541c537f585fda252cf93667e6c1919be0 Mon Sep 17 00:00:00 2001 From: greymag Date: Tue, 25 Aug 2026 17:44:39 +0200 Subject: [PATCH 10/13] refactor(runner): common envelope for the machine readable result Four commands built the `--format=json` envelope by hand, each with its own hardcoded command name. Now `jsonResult()` in `AlexCommand` builds it: the alex version, the full name taken from the commands tree (`code check`, `agents init`), `ok`, the exit code and a summary. It also makes the contract hold on every path out of a command. Before, a command that failed by returning `error(...)` - not by throwing - printed nothing in stdout, and a failure before the command was started (arguments parsing, initialization) went to `app.dart` and bypassed the result completely. Both print an error object now, so a script always has JSON to parse; the raw arguments are checked for the option in `json_format.dart`, because at that point they may be unparseable. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 1 + lib/app.dart | 31 +++++++++- lib/commands/agents/guide_command.dart | 19 +++--- lib/commands/agents/init_command.dart | 24 ++++---- lib/commands/code/check_command.dart | 11 ++-- lib/commands/info_command.dart | 17 ++---- lib/runner/alex_command.dart | 82 ++++++++++++++++++++++++-- lib/src/check/check_report.dart | 13 +--- lib/src/output/json_format.dart | 35 +++++++++++ test/src/output/json_format_test.dart | 48 +++++++++++++++ test/src/runner/alex_command_test.dart | 57 ++++++++++++++++++ 11 files changed, 279 insertions(+), 59 deletions(-) create mode 100644 lib/src/output/json_format.dart create mode 100644 test/src/output/json_format_test.dart create mode 100644 test/src/runner/alex_command_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index a9abbe7..851f092 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ * [Common] New `--format=json` option (supported by `code check`, `info` and `agents` commands): a command prints a single JSON object with a machine readable report in the standard output, all other messages - including the update banner - go to the error output. * [Agents] New command group `agents` with the `guide` subcommand: prints a short guide of alex for an AI agent or a script - the rules (config discovery, machine readable output, exit codes, interactive commands) and all commands with their options and exit codes. The guide is generated from the commands tree of the installed version, so it can't get outdated. Supports a command path argument to print a part of it (`alex agents guide l10n`) and `--format=json` for a structured index. * [Agents] New command `agents init`: adds or updates the alex section in the agent instructions of the project (`CLAUDE.md`, `AGENTS.md` and alike), so an agent learns about alex from the file it reads first. The section describes the project (package, FVM, locales, l10n paths, branches) and the commands to use with their exit codes, and is generated from the alex config, `pubspec.yaml` and the commands tree. Only the content between the `` and `` markers is changed. Files are taken from the new `agents.files` config option, the `--file` (`-f`) option, or `CLAUDE.md`/`AGENTS.md` if they exist. With the `--check` flag nothing is changed and the command fails with exit code `10` if some file is missing or outdated. +* [Common] `--format=json`: an error object (with `ok: false`, the exit code and the message) is printed in the standard output for any failure - not only when a command has finished with a result. It covers an error of a command, a failure of the arguments parsing and a failure before the command is started, so a script always gets a JSON object to parse. * [Info] New command `alex info` (alias `alex facts`): prints the facts about the project - package and version, path to the alex config, packages of a multi-package project, Flutter version pinned with FVM, locales (by the ARB files) and localization paths, git branches. Supports `--format=json`, so a script or an AI agent can get all of it with a single call instead of reading several files. ## 1.14.0 diff --git a/lib/app.dart b/lib/app.dart index 6c2fdc5..0aa5399 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -1,15 +1,42 @@ +import 'dart:convert'; + import 'package:alex/runner/alex_command_runner.dart'; import 'package:args/command_runner.dart'; import 'package:alex/internal/print.dart' as print; +import 'package:alex/src/output/json_format.dart'; +import 'package:alex/src/version.dart'; Future run(List args) async { try { return await AlexCommandRunner().run(args) ?? 2; } on UsageException catch (e, st) { print.exception(e, st); - return 64; + return _failed(args, 64, e.message); } catch (e, st) { print.exception(e, st); - return -1; + return _failed(args, -1, '$e'); } } + +/// Prints the error result in the machine readable mode, if it was requested, +/// and returns the [exitCode]. +/// +/// A command prints such a result by itself, but alex can fail before the +/// command is started - on the arguments parsing or on the initialization - +/// so a script gets a JSON object in the standard output in any case. +int _failed(List args, int exitCode, String message) { + if (!isJsonFormatRequested(args)) return exitCode; + + final command = commandNameFromArgs(args); + + print.result(jsonEncode({ + 'alex': packageVersion, + if (command.isNotEmpty) 'command': command, + 'ok': false, + 'exitCode': exitCode, + 'summary': message.split('\n').first.trim(), + 'error': message, + })); + + return exitCode; +} diff --git a/lib/commands/agents/guide_command.dart b/lib/commands/agents/guide_command.dart index 42381c1..1819d8d 100644 --- a/lib/commands/agents/guide_command.dart +++ b/lib/commands/agents/guide_command.dart @@ -1,5 +1,3 @@ -import 'dart:convert'; - import 'package:alex/internal/print.dart' as print; import 'package:alex/runner/alex_command.dart'; import 'package:alex/src/agents/command_index.dart'; @@ -48,18 +46,15 @@ class GuideCommand extends AlexCommand { 'Run "alex agents guide" to see all commands.'); } - if (args.isJsonFormat()) { - print.result(jsonEncode({ - 'alex': packageVersion, - 'command': 'agents guide', - 'ok': true, - 'summary': '${index.runnable.length} command(s)', - 'commands': index.toJson(), - })); - } else { - print.info(GuideRenderer.render(index, version: packageVersion)); + if (isJsonFormat) { + return jsonResult( + exitCode: 0, + summary: '${index.runnable.length} command(s)', + data: {'commands': index.toJson()}, + ); } + print.info(GuideRenderer.render(index, version: packageVersion)); return 0; } } diff --git a/lib/commands/agents/init_command.dart b/lib/commands/agents/init_command.dart index 79c59e5..b6497c7 100644 --- a/lib/commands/agents/init_command.dart +++ b/lib/commands/agents/init_command.dart @@ -1,7 +1,5 @@ -import 'dart:convert'; import 'dart:io'; -import 'package:alex/internal/print.dart' as print; import 'package:alex/runner/alex_command.dart'; import 'package:alex/src/agents/command_index.dart'; import 'package:alex/src/agents/managed_block.dart'; @@ -138,18 +136,18 @@ class InitCommand extends AlexCommand { final hasProblems = results.values.any((s) => s.isProblem); if (isJson) { - print.result(jsonEncode({ - 'alex': packageVersion, - 'command': 'agents init', - 'ok': !hasProblems, - 'exitCode': hasProblems ? _kExitCodeOutdated : 0, - 'summary': + return jsonResult( + exitCode: hasProblems ? _kExitCodeOutdated : 0, + summary: results.entries.map((e) => '${e.key}: ${e.value.key}').join(' | '), - 'files': results.entries - .map((e) => {'path': e.key, 'status': e.value.key}) - .toList(), - 'facts': facts.toJson(), - })); + data: { + 'files': results.entries + .map((e) => + {'path': e.key, 'status': e.value.key}) + .toList(), + 'facts': facts.toJson(), + }, + ); } else { results.forEach((path, status) { printInfo('$path: ${status.key}'); diff --git a/lib/commands/code/check_command.dart b/lib/commands/code/check_command.dart index 8e1582f..affc255 100644 --- a/lib/commands/code/check_command.dart +++ b/lib/commands/code/check_command.dart @@ -1,7 +1,5 @@ -import 'dart:convert'; import 'dart:io'; -import 'package:alex/internal/print.dart' as print; import 'package:alex/runner/alex_command.dart'; import 'package:alex/src/check/analyze_output_parser.dart'; import 'package:alex/src/check/check_report.dart'; @@ -11,7 +9,6 @@ import 'package:alex/src/check/test_run_result.dart'; import 'package:alex/src/config.dart'; import 'package:alex/src/fs/fs.dart'; import 'package:alex/src/pub_spec.dart'; -import 'package:alex/src/version.dart'; import 'src/code_command_base.dart'; @@ -145,10 +142,14 @@ class CheckCommand extends CodeCommandBase { need: needBuild, ); - final report = CheckReport(alexVersion: packageVersion, gates: gates); + final report = CheckReport(gates: gates); if (isJson) { - print.result(jsonEncode(report.toJson())); + return jsonResult( + exitCode: report.exitCode, + summary: report.summary, + data: report.toJson(), + ); } else { printInfo(''); printInfo('=== alex code check: ${report.ok ? 'PASS' : 'FAIL'} ==='); diff --git a/lib/commands/info_command.dart b/lib/commands/info_command.dart index 8ec51d2..31d7e79 100644 --- a/lib/commands/info_command.dart +++ b/lib/commands/info_command.dart @@ -1,6 +1,3 @@ -import 'dart:convert'; - -import 'package:alex/internal/print.dart' as print; import 'package:alex/runner/alex_command.dart'; import 'package:alex/src/agents/project_facts.dart'; import 'package:alex/src/version.dart'; @@ -27,14 +24,12 @@ class InfoCommand extends AlexCommand { final config = findConfigAndSetWorkingDir(); final facts = await ProjectFacts.collect(config); - if (argResults!.isJsonFormat()) { - print.result(jsonEncode({ - 'alex': packageVersion, - 'command': 'info', - 'ok': true, - 'summary': _summary(facts), - ...facts.toJson(), - })); + if (isJsonFormat) { + return jsonResult( + exitCode: 0, + summary: _summary(facts), + data: facts.toJson(), + ); } else { printInfo('alex $packageVersion'); for (final line in facts.lines) { diff --git a/lib/runner/alex_command.dart b/lib/runner/alex_command.dart index 77456e8..cb76ab6 100644 --- a/lib/runner/alex_command.dart +++ b/lib/runner/alex_command.dart @@ -1,3 +1,4 @@ +import 'dart:convert'; import 'dart:io'; import 'package:alex/commands/release/demo.dart'; @@ -10,6 +11,7 @@ import 'package:alex/src/git/git.dart'; import 'package:alex/src/run/cmd.dart'; import 'package:alex/src/run/flutter_cmd.dart'; import 'package:alex/src/settings.dart'; +import 'package:alex/src/version.dart'; import 'package:args/args.dart'; import 'package:args/command_runner.dart'; import 'package:logging/logging.dart'; @@ -27,6 +29,8 @@ abstract class AlexCommand extends Command { Console? _console; Cmd? _cmd; FlutterCmd? _flutter; + bool _isResultPrinted = false; + String? _lastErrorMessage; // TODO: as an argument in constructor final _logger = Logger('alex'); @@ -49,6 +53,20 @@ abstract class AlexCommand extends Command { @override List get aliases => _aliases; + /// Full name of the command with all the parent commands, + /// for example `code check`. + String get fullName { + final parts = [name]; + + var parent = this.parent; + while (parent != null) { + parts.insert(0, parent.name); + parent = parent.parent; + } + + return parts.join(' '); + } + /// Exit codes of the command with their meaning, /// except the common ones (`0` - success, `2` - error). /// @@ -81,6 +99,35 @@ abstract class AlexCommand extends Command { @protected bool get isVerbose => argResults!.isVerbose(); + /// Whether a machine readable result is requested (`--format=json`). + @protected + bool get isJsonFormat => argResults?.isJsonFormat() ?? false; + + /// Prints the result of the command in the standard output + /// as a single JSON object and returns the [exitCode]. + /// + /// Should be called only if [isJsonFormat] is `true`. + /// Common fields of the envelope are filled here, so they are the same + /// for every command; [data] is a command specific payload. + @protected + int jsonResult({ + required int exitCode, + String? summary, + Map data = const {}, + }) { + print.result(jsonEncode({ + 'alex': packageVersion, + 'command': fullName, + 'ok': exitCode == 0, + 'exitCode': exitCode, + if (summary != null) 'summary': summary, + ...data, + })); + + _isResultPrinted = true; + return exitCode; + } + @protected bool get isVerboseFlutterCmd => (argResults!.options.contains(_kArgVerboseFlutterCmd.name) @@ -105,18 +152,40 @@ abstract class AlexCommand extends Command { print.setRootLoggerLevel(isVerbose: isVerbose); try { - return await doRun(); + return _jsonResultIfNeeded(await doRun()); } on RunException catch (e) { - return errorBy(e); + return _jsonResultIfNeeded(errorBy(e)); } catch (e, st) { printVerbose('Exception: $e\nStackTrace: $st'); - return error(2, message: 'Failed by: $e'); + return _jsonResultIfNeeded(error(2, message: 'Failed by: $e')); } } @protected Future doRun(); + /// Prints the result in the machine readable mode, if the command + /// has finished without printing it itself. + /// + /// It happens when the command has failed - by an exception or just by + /// returning an error code - before it got to the result. So a script + /// always gets a JSON object in the standard output. + int _jsonResultIfNeeded(int exitCode) { + if (!isJsonFormat || _isResultPrinted) return exitCode; + + final message = _lastErrorMessage; + // Summary is a single line, the whole message is in the `error` field. + final summary = message?.split('\n').first.trim(); + + return jsonResult( + exitCode: exitCode, + summary: summary?.isNotEmpty == true + ? summary + : (exitCode == 0 ? 'done' : 'failed'), + data: {if (message != null) 'error': message}, + ); + } + @protected AlexConfig findConfigAndSetWorkingDir() { final config = this.config; @@ -163,7 +232,12 @@ abstract class AlexCommand extends Command { /// Returns error code and prints a error message if provided. @protected int error(int code, {String? message}) { - if (message != null) printError(message); + if (message != null) { + printError(message); + // Kept for the machine readable result, if the command has failed + // before it printed one. + _lastErrorMessage = message; + } return code; } diff --git a/lib/src/check/check_report.dart b/lib/src/check/check_report.dart index 33a8960..780c602 100644 --- a/lib/src/check/check_report.dart +++ b/lib/src/check/check_report.dart @@ -78,16 +78,10 @@ class CheckGateResult { /// Report of the `alex code check` run. class CheckReport { - /// Version of alex. - final String alexVersion; - /// Results of the gates in the order of the run. final List gates; - const CheckReport({ - required this.alexVersion, - required this.gates, - }); + const CheckReport({required this.gates}); /// `true` if no gate failed. bool get ok => gates.every((g) => !g.isFailed); @@ -106,11 +100,6 @@ class CheckReport { gates.map((g) => '${g.gate.key}: ${g.summary}').join(' | '); Map toJson() => { - 'alex': alexVersion, - 'command': 'code check', - 'ok': ok, - 'exitCode': exitCode, - 'summary': summary, 'gates': gates.map((g) => g.toJson()).toList(), }; } diff --git a/lib/src/output/json_format.dart b/lib/src/output/json_format.dart new file mode 100644 index 0000000..3a5cd33 --- /dev/null +++ b/lib/src/output/json_format.dart @@ -0,0 +1,35 @@ +import 'package:alex/src/const.dart'; + +/// Returns `true` if the machine readable output is requested +/// in the raw command line [args]. +/// +/// Used when the arguments are not parsed yet or can't be parsed at all, +/// but the result still should be printed as JSON. +bool isJsonFormatRequested(List args) { + for (var i = 0; i < args.length; i++) { + final arg = args[i]; + + if (arg == '--$kFormat=$kFormatJson') return true; + if (arg == '--$kFormat' && i + 1 < args.length) { + return args[i + 1] == kFormatJson; + } + } + + return false; +} + +/// Returns the name of the command from the raw command line [args], +/// for example `code check`. +/// +/// Everything before the first option is treated as a command path, +/// so it works even if the arguments were not parsed. +String commandNameFromArgs(List args) { + final parts = []; + + for (final arg in args) { + if (arg.startsWith('-')) break; + parts.add(arg); + } + + return parts.join(' '); +} diff --git a/test/src/output/json_format_test.dart b/test/src/output/json_format_test.dart new file mode 100644 index 0000000..ad339f7 --- /dev/null +++ b/test/src/output/json_format_test.dart @@ -0,0 +1,48 @@ +import 'package:alex/src/output/json_format.dart'; +import 'package:test/test.dart'; + +void main() { + group('isJsonFormatRequested()', () { + test('should detect the option written through the equals sign', () { + expect(isJsonFormatRequested(['code', 'check', '--format=json']), true); + }); + + test('should detect the option written with a space', () { + expect(isJsonFormatRequested(['info', '--format', 'json']), true); + }); + + test('should return false for another format', () { + expect(isJsonFormatRequested(['info', '--format=text']), false); + expect(isJsonFormatRequested(['info', '--format', 'text']), false); + }); + + test('should return false if there is no option', () { + expect(isJsonFormatRequested(['code', 'check']), false); + expect(isJsonFormatRequested(const []), false); + }); + + test('should not fail if the option has no value', () { + expect(isJsonFormatRequested(['info', '--format']), false); + }); + }); + + group('commandNameFromArgs()', () { + test('should return the command with all its parents', () { + expect(commandNameFromArgs(['code', 'check', '--format=json']), + 'code check'); + }); + + test('should return the command without options', () { + expect(commandNameFromArgs(['info']), 'info'); + }); + + test('should stop on the first option', () { + expect(commandNameFromArgs(['l10n', 'check', '-l', 'ru']), 'l10n check'); + }); + + test('should return empty string if there is no command', () { + expect(commandNameFromArgs(['--version']), ''); + expect(commandNameFromArgs(const []), ''); + }); + }); +} diff --git a/test/src/runner/alex_command_test.dart b/test/src/runner/alex_command_test.dart new file mode 100644 index 0000000..ed91c4e --- /dev/null +++ b/test/src/runner/alex_command_test.dart @@ -0,0 +1,57 @@ +import 'package:alex/runner/alex_command.dart'; +import 'package:args/command_runner.dart'; +import 'package:test/test.dart'; + +void main() { + group('fullName', () { + test('should be the name for a top level command', () { + final command = _Cmd('info'); + _runner([command]); + + expect(command.fullName, 'info'); + }); + + test('should contain all the parent commands', () { + final command = _Cmd('check'); + _runner([ + _Group('code', [command]), + ]); + + expect(command.fullName, 'code check'); + }); + + test('should contain all the levels of nesting', () { + final command = _Cmd('third'); + _runner([ + _Group('first', [ + _Group('second', [command]), + ]), + ]); + + expect(command.fullName, 'first second third'); + }); + }); +} + +CommandRunner _runner(List commands) { + final runner = CommandRunner('alex', 'Test runner.'); + commands.forEach(runner.addCommand); + return runner; +} + +class _Cmd extends AlexCommand { + _Cmd(String name) : super(name, 'Some command.'); + + @override + Future doRun() async => 0; +} + +class _Group extends AlexCommand { + _Group(String name, List subcommands) + : super(name, 'Some group.') { + subcommands.forEach(addSubcommand); + } + + @override + Future doRun() async => 0; +} From 3f8c7be9e6be95e4ad9f878189b20db547972ab1 Mon Sep 17 00:00:00 2001 From: greymag Date: Tue, 25 Aug 2026 17:44:51 +0200 Subject: [PATCH 11/13] feat(l10n): machine readable report for check_translations `--format=json` prints the result of every check as a JSON object: a stable `id` (`arb_untranslated`, `xml_duplicates`, `code_not_generated` and so on), what the check expects, the status, and for a failed one - the problems with the locale and the keys. So CI or an AI agent reacts to a particular problem instead of parsing the detailed human report, which stays in stderr as before. The files changed after `pub get` are reported in `changedFiles` in both cases: when `--fail-on-changed-files` makes it an error and when it's only a warning - a script should be able to see that the dependencies are not in sync either way. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 3 +- README.md | 28 ++++ docs/agent-support-roadmap.md | 12 +- .../l10n/check_translation_command.dart | 128 +++++++++++++++--- 4 files changed, 148 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 851f092..cdc7c07 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,9 +8,10 @@ * **Fixed**: alex hangs if a command it runs asks for an input. For example, `alex code gen` stopped with no output when `fvm` asked to install a missing Flutter SDK. Such a question can't be answered anyway, because the command gets a pipe instead of the terminal, so its input is closed right after the start now, and the command fails with its own message instead of hanging. * **Fixed**: the output of a command could be lost, so a failed command could be reported without its error message. Now alex waits for the output to be fully read before it handles the result. * [Code] New command `check`: runs the quality gates - analyze, tests and (optionally, with the `--build` flag) a debug build of the platform target. Output of the commands is filtered from a noise, a short verdict is printed for each gate. Exit code is `0` if all gates passed, `10` if analyze failed, `11` if tests failed, `12` if build failed. Flags `--analyze-only`, `--test-only` and `--fail-fast` are supported, arguments after `--` are passed to the test command. Build target and additional noise patterns can be defined with the new `code.check` config section. -* [Common] New `--format=json` option (supported by `code check`, `info` and `agents` commands): a command prints a single JSON object with a machine readable report in the standard output, all other messages - including the update banner - go to the error output. +* [Common] New `--format=json` option (supported by `code check`, `info`, `agents` and `l10n check_translations` commands): a command prints a single JSON object with a machine readable report in the standard output, all other messages - including the update banner - go to the error output. * [Agents] New command group `agents` with the `guide` subcommand: prints a short guide of alex for an AI agent or a script - the rules (config discovery, machine readable output, exit codes, interactive commands) and all commands with their options and exit codes. The guide is generated from the commands tree of the installed version, so it can't get outdated. Supports a command path argument to print a part of it (`alex agents guide l10n`) and `--format=json` for a structured index. * [Agents] New command `agents init`: adds or updates the alex section in the agent instructions of the project (`CLAUDE.md`, `AGENTS.md` and alike), so an agent learns about alex from the file it reads first. The section describes the project (package, FVM, locales, l10n paths, branches) and the commands to use with their exit codes, and is generated from the alex config, `pubspec.yaml` and the commands tree. Only the content between the `` and `` markers is changed. Files are taken from the new `agents.files` config option, the `--file` (`-f`) option, or `CLAUDE.md`/`AGENTS.md` if they exist. With the `--check` flag nothing is changed and the command fails with exit code `10` if some file is missing or outdated. +* [L10n] `check_translations`: new `--format=json` option - a single JSON object with the result of every check (a stable `id`, the title, the status, and the problems with locales and keys for the failed ones) is printed in the standard output, so CI or an AI agent doesn't have to parse the human readable report. The list of files changed after `pub get` is reported in JSON too - both when it fails the command and when it's just a warning, so CI can see that the dependencies are not in sync. * [Common] `--format=json`: an error object (with `ok: false`, the exit code and the message) is printed in the standard output for any failure - not only when a command has finished with a result. It covers an error of a command, a failure of the arguments parsing and a failure before the command is started, so a script always gets a JSON object to parse. * [Info] New command `alex info` (alias `alex facts`): prints the facts about the project - package and version, path to the alex config, packages of a multi-package project, Flutter version pinned with FVM, locales (by the ARB files) and localization paths, git branches. Supports `--format=json`, so a script or an AI agent can get all of it with a single call instead of reading several files. diff --git a/README.md b/README.md index 9ec1721..d553816 100644 --- a/README.md +++ b/README.md @@ -323,6 +323,34 @@ $ alex l10n check --print-changed-files --fail-on-changed-files * `--fail-on-changed-files` — exit with code `11` instead of printing a warning, so CI can rely on the exit code instead of parsing the output. +For a machine readable report (for CI or an AI agent) use `--format=json`: a single JSON +object with the result of every check is printed in the standard output, all other +messages go to the error output. + +```bash +$ alex l10n check_translations --format=json +``` + +```json +{ + "alex": "1.15.0", + "command": "l10n check_translations", + "ok": false, + "exitCode": 10, + "summary": "1 of 6 checks failed", + "checks": [ + {"id": "arb_untranslated", "title": "All strings have translation in ARB", "ok": true}, + {"id": "xml_duplicates", "title": "No duplicated keys in XML", "ok": false, + "message": "Duplicated keys found in XML", + "problems": [{"locale": "ru", "unexpectedKeys": ["some_key"]}]} + ] +} +``` + +Every check has a stable `id`, so a script can react to a particular problem +without parsing the human readable output. If some files were changed after `pub get`, +then they are listed in the `changedFiles` field - even when it's only a warning. + #### Import translations from XML It's for working with translations from Google Play. diff --git a/docs/agent-support-roadmap.md b/docs/agent-support-roadmap.md index 9abaac8..a7f5e81 100644 --- a/docs/agent-support-roadmap.md +++ b/docs/agent-support-roadmap.md @@ -56,11 +56,15 @@ gates per package in a pub workspace (now only the root is checked). #### 2. `[~] --format=json` for everything agents read -Done for `code check` (JSON-native from day one) — the option, the stdout/stderr -contract and the envelope are in place (`--format` in `AlexCommand`, messages routed to -stderr in `alex_command_runner`). +Done: `code check`, `info`, `agents guide`, `agents init`, `l10n check_translations`. -Remaining: `l10n check_translations`, `changelog`, `pubspec`. +The contract lives in `AlexCommand` now: `addFormatOption()` on the parser, +`jsonResult()` builds the common envelope (alex version, the full command name from the +commands tree, `ok`, `exitCode`, `summary`) so no command repeats it, and a command that +fails with an error before printing a result still prints an error object — a script +always has JSON to parse. Messages are routed to stderr in `alex_command_runner`. + +Remaining: `changelog`, `pubspec`. #### 3. `[ ] alex lint` — house conventions as code diff --git a/lib/commands/l10n/check_translation_command.dart b/lib/commands/l10n/check_translation_command.dart index 0cc7e1b..d114173 100644 --- a/lib/commands/l10n/check_translation_command.dart +++ b/lib/commands/l10n/check_translation_command.dart @@ -64,6 +64,7 @@ class CheckTranslationsCommand extends L10nCommandBase { 'if some files were changed after pub get. ' 'By default only a warning is printed.', ) + ..addFormatOption() ..addVerboseFlutterCmdFlag(); } @@ -88,7 +89,10 @@ class CheckTranslationsCommand extends L10nCommandBase { git.ensureCleanStatus(printChanges: true); - final reports = <_CheckReport>[]; + final outcomes = <_CheckOutcome>[]; + // Kept for the result: even if it's just a warning, a script should be + // able to see that the dependencies are not in sync. + var changedAfterPubGet = const []; try { printInfo('Get dependencies...'); await flutter.pubGetOrFail( @@ -96,7 +100,7 @@ class CheckTranslationsCommand extends L10nCommandBase { immediatePrint: printFlutterOut, ); - final changedAfterPubGet = git.getModifiedFiles(); + changedAfterPubGet = git.getModifiedFiles(); if (changedAfterPubGet.isNotEmpty) { // Only file paths are listed here, without a diff, // so the log is not flooded with the content of the changes. @@ -112,7 +116,12 @@ class CheckTranslationsCommand extends L10nCommandBase { final message = sb.toString(); if (failOnChangedFiles) { - return error(_kExitCodeChangedAfterPubGet, message: '🚨 $message'); + error(_kExitCodeChangedAfterPubGet, message: '🚨 $message'); + return _result( + _kExitCodeChangedAfterPubGet, + summary: 'some files were changed after pub get', + data: {'changedFiles': changedAfterPubGet}, + ); } printInfo('⚠️ $message'); @@ -136,6 +145,7 @@ class CheckTranslationsCommand extends L10nCommandBase { final checks = Function()>[]; Future Function() check({ + required String id, required String successMessage, required String failMessage, required Future<_CheckReport> Function() check, @@ -143,7 +153,7 @@ class CheckTranslationsCommand extends L10nCommandBase { }) => () async { final report = await check(); - final checkNum = reports.length + 1; + final checkNum = outcomes.length + 1; final checksTotal = checks.length; if (report.isOk) { @@ -158,21 +168,29 @@ class CheckTranslationsCommand extends L10nCommandBase { ); } - reports.add(report); + outcomes.add(_CheckOutcome( + id: id, + title: successMessage, + failMessage: failMessage, + report: report, + )); }; checks.addAll([ check( + id: 'arb_untranslated', successMessage: 'All strings have translation in ARB', failMessage: 'Untranslated strings found in ARB', check: () => _checkForUntranslated(l10nConfig, locale).report(), ), check( + id: 'not_sent_for_translation', successMessage: 'All strings were sent for translation', failMessage: 'Some strings probably were not sent for translation', check: () => _checkForUnsent(l10nConfig).report(), ), check( + id: 'xml_untranslated', successMessage: 'All strings have translation in XML', failMessage: 'Untranslated or redundant strings found in XML', check: () => _checkForUntranslatedXml(l10nConfig, locale).report(), @@ -181,16 +199,19 @@ class CheckTranslationsCommand extends L10nCommandBase { 'Before do anything about it, check that all required strings are present in the base XML file.', ), check( + id: 'xml_duplicates', successMessage: 'No duplicated keys in XML', failMessage: 'Duplicated keys found in XML', check: () => _checkXmlForDuplicates(l10nConfig, locale).report(), ), check( + id: 'not_imported_to_arb', successMessage: 'All strings are imported to ARB', failMessage: 'Some strings are not imported to ARB from XML', check: () => _checkForNotImportedToArb(l10nConfig, locale).report(), ), check( + id: 'code_not_generated', successMessage: 'Generated localization code is up to date', failMessage: 'Localization code probably is not generated after last changes', @@ -217,23 +238,47 @@ class CheckTranslationsCommand extends L10nCommandBase { printInfo(''); - final total = reports.length; + final total = outcomes.length; + final data = { + if (locale != null) 'locale': locale.value, + if (changedAfterPubGet.isNotEmpty) 'changedFiles': changedAfterPubGet, + 'checks': outcomes.map((e) => e.toJson()).toList(), + }; + if (total == 0) { - return error( - 2, - message: 'No checks were performed. This is probably a bug.', - ); - } else if (reports.every((e) => e.isOk)) { - return success(message: '🏆 All checks passed [$total/$total].'); - } else { - final failed = reports.countWhere((e) => !e.isOk); - return error( - _kExitCodeCheckFailed, - message: '🚨 $failed of $total checks failed. See details above', - ); + const message = 'No checks were performed. This is probably a bug.'; + error(2, message: message); + return _result(2, summary: message, data: data); + } + + if (outcomes.every((e) => e.report.isOk)) { + success(message: '🏆 All checks passed [$total/$total].'); + return _result(0, summary: 'all $total checks passed', data: data); } + + final failed = outcomes.countWhere((e) => !e.report.isOk); + error( + _kExitCodeCheckFailed, + message: '🚨 $failed of $total checks failed. See details above', + ); + return _result( + _kExitCodeCheckFailed, + summary: '$failed of $total checks failed', + data: data, + ); } + /// Returns the [exitCode], printing the machine readable result before it, + /// if it was requested. + int _result( + int exitCode, { + String? summary, + Map data = const {}, + }) => + isJsonFormat + ? jsonResult(exitCode: exitCode, summary: summary, data: data) + : exitCode; + /// Checks for not translated strings in ARB. /// /// Not translated strings are those that are present in the base ARB file @@ -843,6 +888,53 @@ class _CheckResult { bool get isOk => notPresentedKeys.isEmpty && notExpectedKeys.isEmpty && error == null; + + Map toJson() => { + if (locale != null) 'locale': locale!.value, + if (notPresentedKeys.isNotEmpty) + 'missedKeys': notPresentedKeys.toList(), + if (notExpectedKeys.isNotEmpty) + 'unexpectedKeys': notExpectedKeys.toList(), + if (notExpectedKeys.isNotEmpty && notExpectedKeysLabel != null) + 'unexpectedKeysLabel': notExpectedKeysLabel, + if (error != null) 'error': error, + }; +} + +/// Result of a single check with its description. +class _CheckOutcome { + /// Stable identifier of the check for a machine readable report. + final String id; + + /// What the check expects, for example + /// `All strings have translation in ARB`. + final String title; + + /// What is wrong if the check has failed. + final String failMessage; + + final _CheckReport report; + + const _CheckOutcome({ + required this.id, + required this.title, + required this.failMessage, + required this.report, + }); + + Map toJson() { + final problems = report.results.where((e) => !e.isOk); + + return { + 'id': id, + 'title': title, + 'ok': report.isOk, + if (!report.isOk) ...{ + 'message': failMessage, + 'problems': problems.map((e) => e.toJson()).toList(), + }, + }; + } } extension _CheckResultListFutureExt on Future> { From 224e7ab130d11db5577453d42530612717497273 Mon Sep 17 00:00:00 2001 From: greymag Date: Tue, 25 Aug 2026 17:59:04 +0200 Subject: [PATCH 12/13] feat(common): non-interactive mode for the commands that ask questions A command that waits for an answer in stdin can't be used by a script or an AI agent: it just hangs. `--non-interactive` makes such a command fail with the name of the option that provides the answer instead. The flag is added to the parser automatically for a command whose `isInteractive` is `true`, so a new interactive command can't forget it, and `alex agents guide` prints it for every `[INTERACTIVE]` command. `feature finish` needed the most: - new `--section` option (`added`, `fixed` or `pre-release`) instead of choosing the CHANGELOG.md section interactively, `added` by default - as it is for an empty answer; - `--issue` and `--changelog` are checked BEFORE any git operation, so the command fails before the branch is merged, not after it; - an empty `--changelog=` skips the changelog, as the message about the missing option says - it doesn't fall back to the question; - fixed a real hang: the issue id prompt looped forever when there was no input at all (a closed stdin in a script), because `readLineSync` keeps returning null. `pubspec update` fails naming `--dependency`. `custom add` is a wizard by nature, so it points to the config file to edit directly. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 3 + README.md | 18 +++ docs/agent-support-roadmap.md | 19 +-- lib/commands/custom/add_custom_command.dart | 11 ++ lib/commands/feature/finish_command.dart | 127 ++++++++++++++------ lib/commands/pubspec/update_command.dart | 11 +- lib/runner/alex_command.dart | 37 +++++- lib/src/agents/guide_renderer.dart | 11 +- lib/src/const.dart | 3 + 9 files changed, 188 insertions(+), 52 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cdc7c07..caf5983 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,9 @@ * [Common] New `--format=json` option (supported by `code check`, `info`, `agents` and `l10n check_translations` commands): a command prints a single JSON object with a machine readable report in the standard output, all other messages - including the update banner - go to the error output. * [Agents] New command group `agents` with the `guide` subcommand: prints a short guide of alex for an AI agent or a script - the rules (config discovery, machine readable output, exit codes, interactive commands) and all commands with their options and exit codes. The guide is generated from the commands tree of the installed version, so it can't get outdated. Supports a command path argument to print a part of it (`alex agents guide l10n`) and `--format=json` for a structured index. * [Agents] New command `agents init`: adds or updates the alex section in the agent instructions of the project (`CLAUDE.md`, `AGENTS.md` and alike), so an agent learns about alex from the file it reads first. The section describes the project (package, FVM, locales, l10n paths, branches) and the commands to use with their exit codes, and is generated from the alex config, `pubspec.yaml` and the commands tree. Only the content between the `` and `` markers is changed. Files are taken from the new `agents.files` config option, the `--file` (`-f`) option, or `CLAUDE.md`/`AGENTS.md` if they exist. With the `--check` flag nothing is changed and the command fails with exit code `10` if some file is missing or outdated. +* [Common] New `--non-interactive` flag for every command that can ask a question in the standard input (`feature finish`, `pubspec update`, `custom add`). With the flag a command fails with an explanation of the option to pass instead of waiting for an answer that a script can't give. `feature finish` checks everything it would ask about before it starts, so it fails before the branch is merged, not after. +* [Feature] `finish`: new `--section` option (`added`, `fixed` or `pre-release`) to define the section of `CHANGELOG.md` for the entry, instead of choosing it interactively. Default is `added`, as for an empty answer. +* **Fixed** [Feature] `finish`: the command looped forever asking for an issue id when there was no input at all (a closed stdin in a script). Now it fails with a message. * [L10n] `check_translations`: new `--format=json` option - a single JSON object with the result of every check (a stable `id`, the title, the status, and the problems with locales and keys for the failed ones) is printed in the standard output, so CI or an AI agent doesn't have to parse the human readable report. The list of files changed after `pub get` is reported in JSON too - both when it fails the command and when it's just a warning, so CI can see that the dependencies are not in sync. * [Common] `--format=json`: an error object (with `ok: false`, the exit code and the message) is printed in the standard output for any failure - not only when a command has finished with a result. It covers an error of a command, a failure of the arguments parsing and a failure before the command is started, so a script always gets a JSON object to parse. * [Info] New command `alex info` (alias `alex facts`): prints the facts about the project - package and version, path to the alex config, packages of a multi-package project, Flutter version pinned with FVM, locales (by the ARB files) and localization paths, git branches. Supports `--format=json`, so a script or an AI agent can get all of it with a single call instead of reading several files. diff --git a/README.md b/README.md index d553816..82f1414 100644 --- a/README.md +++ b/README.md @@ -255,6 +255,24 @@ $ alex f f -i{issueId} -c"Some new feature" It's important to use double quote (`"`) on Windows, but on macOS or Linux you can also use a single quote (`'`). +The section of `CHANGELOG.md` can be passed with `--section` (`added` by default, +also `fixed` and `pre-release`), so nothing has to be answered interactively: + +```bash +$ alex f f -i{issueId} -c"Some new feature" --section=fixed +``` + +For scripts and CI add `--non-interactive`: the command checks that everything it would +ask about is provided and fails with an explanation **before** it changes anything, +instead of waiting for an answer that will never come. + +```bash +$ alex f f --non-interactive -i{issueId} -c"Some new feature" +``` + +The flag is supported by every command that can ask a question - they are marked +`[INTERACTIVE]` in `alex agents guide`. + ### l10n Work with localization files. diff --git a/docs/agent-support-roadmap.md b/docs/agent-support-roadmap.md index a7f5e81..6812330 100644 --- a/docs/agent-support-roadmap.md +++ b/docs/agent-support-roadmap.md @@ -82,16 +82,21 @@ Rules configured in `alex.yaml` (enable/disable, severity, path excludes). JSON + non-zero exit on blockers. This is the item that turns half of code review from probabilistic into deterministic — and shrinks the reviewer's prompt. -#### 4. `[ ] Non-interactive mode` +#### 4. `[x] Non-interactive mode` -Every `readLineSync` gets a corresponding flag: +`--non-interactive` is added automatically to every command whose `isInteractive` is +`true`, so a new interactive command can't forget it. With the flag a command fails with +the name of the option that answers the question instead of blocking on stdin. -- `feature finish --issue=N --changelog-section=… --entry="…"`; -- `pubspec update `; -- `custom add` (full definition from flags or a file). +- `feature finish` — gained `--section` (`added`/`fixed`/`pre-release`); with the flag it + validates `--issue` and `--changelog` **before** any git operation, so it fails before + the merge, not after it. Also fixed a real hang: the issue id prompt looped forever + when stdin was closed. +- `pubspec update` — fails naming `--dependency`. +- `custom add` — an interactive wizard by nature, so it points to the config file to + edit directly. -Plus a global `--non-interactive` that fails with an explicit message instead of -blocking on stdin. Without this agents cannot use `feature` / `release` at all. +The guide (`agents guide`) prints the flag for every `[INTERACTIVE]` command. #### 5. `[x] Agent onboarding: how an agent learns that alex exists` diff --git a/lib/commands/custom/add_custom_command.dart b/lib/commands/custom/add_custom_command.dart index c4aabb4..109bfc9 100644 --- a/lib/commands/custom/add_custom_command.dart +++ b/lib/commands/custom/add_custom_command.dart @@ -18,6 +18,17 @@ class AddCustomCommand extends AlexCommand { @override Future doRun() async { + if (isNonInteractive) { + final config = CustomCommandsConfig.instance; + return error( + 1, + message: 'Adding a command is an interactive wizard, ' + 'it can not be used in the non-interactive mode. ' + 'Edit the config file directly: ' + '${config.configPath ?? config.getOrCreateConfigPath()}', + ); + } + final config = CustomCommandsConfig.instance; printInfo('Add new custom command'); diff --git a/lib/commands/feature/finish_command.dart b/lib/commands/feature/finish_command.dart index 4ddd869..b589fe1 100644 --- a/lib/commands/feature/finish_command.dart +++ b/lib/commands/feature/finish_command.dart @@ -14,6 +14,11 @@ class FinishCommand extends FeatureCommandBase { static const _argIssue = CmdArg('issue', abbr: 'i'); static const _argChangelog = CmdArg('changelog', abbr: 'c'); static const _argSquash = CmdArg('squash', abbr: 's'); + static const _argSection = CmdArg('section'); + + static const _sectionAdded = 'added'; + static const _sectionFixed = 'fixed'; + static const _sectionPreRelease = 'pre-release'; FinishCommand() : super( @@ -44,6 +49,14 @@ class FinishCommand extends FeatureCommandBase { abbr: _argSquash.abbr, help: 'Squash all feature commits into a single commit when merging ' 'into develop. Useful for tasks like golden tests updates.', + ) + ..addArg( + _argSection, + help: 'Section of CHANGELOG.md to add the line in. ' + 'Optional, you can provide it in interactive mode. ' + 'Default is $_sectionAdded.', + allowed: const [_sectionAdded, _sectionFixed, _sectionPreRelease], + valueHelp: 'SECTION', ); } @@ -59,6 +72,21 @@ class FinishCommand extends FeatureCommandBase { final changelog = args.getString(_argChangelog); final squash = args.getBool(_argSquash); + // Everything that would be asked should be provided upfront, + // so the command fails before it has changed anything. + if (isNonInteractive) { + if (issueId == null) { + return errorNoAnswer('issue id', '--${_argIssue.name}=NUMBER'); + } + + if (changelog == null) { + return errorNoAnswer( + 'changelog line', + '--${_argChangelog.name}="Some line" ' + '(pass an empty value to skip the changelog)'); + } + } + final console = this.console; final gitConfig = config.git; @@ -94,14 +122,17 @@ class FinishCommand extends FeatureCommandBase { printInfo('Enter issue id:'); - do { + while (issueId == null) { final issueIdStr = console.readLineSync(); - if (issueIdStr != null && issueIdStr.isNotEmpty) { - issueId = int.tryParse(issueIdStr); + // No input at all - there is nobody to answer, + // so it makes no sense to ask again. + if (issueIdStr == null) { + return error(1, message: 'Issue id is not provided.'); } - // ignore: invariant_booleans - } while (issueId == null); + + if (issueIdStr.isNotEmpty) issueId = int.tryParse(issueIdStr); + } } final branch = await _getBranch(branches, issueId); @@ -127,8 +158,8 @@ class FinishCommand extends FeatureCommandBase { ); printVerbose('Add entry in changelog'); - final changed = await _updateChangelog( - console, fs, issueId, changelog, config.issueUrl); + final changed = await _updateChangelog(console, fs, issueId, changelog, + args.getString(_argSection), config.issueUrl); if (changed) { printVerbose('Commit changelog'); @@ -206,8 +237,42 @@ class FinishCommand extends FeatureCommandBase { return res.first; } + /// Asks which section of CHANGELOG.md to add the line in. + /// + /// In the non-interactive mode the default section is used, + /// as it is for an empty answer. + Future _getSection(Console console) async { + if (isNonInteractive) return _sectionAdded; + + while (true) { + printInfo(''' +Which section to add: +[1]: Added (Default) +[2]: Fixed +[3]: Pre-release +?'''); + + final input = console.readLineSync(); + if (input == null || input.trim().isEmpty) { + printInfo('Use default Added'); + return _sectionAdded; + } + + switch (int.tryParse(input)) { + case 1: + return _sectionAdded; + case 2: + return _sectionFixed; + case 3: + return _sectionPreRelease; + default: + printVerbose('Invalid value <$input>'); + } + } + } + Future _updateChangelog(Console console, FileSystem fs, int issueId, - String? changelogLine, String? issueUrl) async { + String? changelogLine, String? section, String? issueUrl) async { final changelog = Changelog(fs); if (!(await changelog.exists)) { @@ -224,6 +289,14 @@ class FinishCommand extends FeatureCommandBase { // TODO: get changelog entry candidate from task final String? line; if (changelogLine == null || changelogLine.isEmpty) { + // An empty value is the documented way to skip the changelog, + // and there is nobody to ask anyway. + if (isNonInteractive) { + printInfo('No changelog record - an empty ' + '--${_argChangelog.name} was passed'); + return false; + } + printInfo('Enter changelog line:'); line = console.readLineSync(); } else { @@ -237,40 +310,20 @@ class FinishCommand extends FeatureCommandBase { } // Can be in section Added, Fixed or even Pre-release. - int? section; - do { - printInfo(''' -Which section to add: -[1]: Added (Default) -[2]: Fixed -[3]: Pre-release -?'''); - - final sectionInput = console.readLineSync(); - if (sectionInput == null || sectionInput.trim().isEmpty) { - printInfo('Use default Added'); - section = 1; - } else { - final intValue = int.tryParse(sectionInput); - if (![1, 2, 3].contains(intValue)) { - printVerbose('Invalid value <$sectionInput>'); - } else { - section = intValue; - } - } - } while (section == null); + final target = section ?? await _getSection(console); - printVerbose('Write to changelog: $line'); - switch (section) { - case 1: - await changelog.addAddedEntry(line, issueId, issueUrl); - break; - case 2: + printVerbose('Write to changelog: $line ($target)'); + switch (target) { + case _sectionFixed: await changelog.addFixedEntry(line, issueId, issueUrl); break; - case 3: + case _sectionPreRelease: await changelog.addPreReleaseEntry(line, issueId, issueUrl); break; + case _sectionAdded: + default: + await changelog.addAddedEntry(line, issueId, issueUrl); + break; } await changelog.save(); diff --git a/lib/commands/pubspec/update_command.dart b/lib/commands/pubspec/update_command.dart index eb04545..85f4bc9 100644 --- a/lib/commands/pubspec/update_command.dart +++ b/lib/commands/pubspec/update_command.dart @@ -34,15 +34,20 @@ class UpdateCommand extends PubspecCommandBase { var dependency = _getDepName(args[_argDependency] as String?); if (dependency == null) { + const optionHint = '--$_argDependency=PACKAGE_NAME or ' + '-${_argDependencyAbbr}PACKAGE_NAME'; + + if (isNonInteractive) { + return errorNoAnswer('package name to update', optionHint); + } + printInfo('Enter package name to update:'); dependency = _getDepName(console.readLineSync()); if (dependency == null) { return error(1, message: 'Nothing to update - dependency name is not provider. ' - 'You can pass package name as ' - '--$_argDependency=PACKAGE_NAME or ' - '-${_argDependencyAbbr}PACKAGE_NAME'); + 'You can pass package name as $optionHint'); } } diff --git a/lib/runner/alex_command.dart b/lib/runner/alex_command.dart index cb76ab6..b88853d 100644 --- a/lib/runner/alex_command.dart +++ b/lib/runner/alex_command.dart @@ -39,7 +39,11 @@ abstract class AlexCommand extends Command { allowTrailingOptions: true, )..addVerboseFlag(); - AlexCommand(this._name, this._description, [this._aliases = const []]); + AlexCommand(this._name, this._description, [this._aliases = const []]) { + // A command that can ask a question should be able to run + // in a script too, so it always has a way to forbid the questions. + if (isInteractive) _argParser.addNonInteractiveFlag(); + } @override String get name => _name; @@ -99,6 +103,25 @@ abstract class AlexCommand extends Command { @protected bool get isVerbose => argResults!.isVerbose(); + /// Whether the command is forbidden to ask questions + /// in the standard input (`--non-interactive`). + /// + /// Instead of waiting for an answer that will never come, the command + /// should fail with a message about the option that provides it. + @protected + bool get isNonInteractive => + argResults?.options.contains(kNonInteractive) == true && + argResults![kNonInteractive] == true; + + /// Returns an error code and prints a message about the [option] + /// that should be passed instead of the answer to a question. + @protected + int errorNoAnswer(String question, String option) => error( + 1, + message: 'Can not ask "$question" in the non-interactive mode. ' + 'Pass $option or run without --$kNonInteractive.', + ); + /// Whether a machine readable result is requested (`--format=json`). @protected bool get isJsonFormat => argResults?.isJsonFormat() ?? false; @@ -312,6 +335,18 @@ extension CmdArgArgParserExtension on ArgParser { void addVerboseFlag() => addFlag(kVerbose, help: 'Show additional diagnostic info'); + /// Adds the `--non-interactive` flag. + /// + /// Added automatically for a command that can ask a question, + /// see [AlexCommand.isInteractive]. + void addNonInteractiveFlag() => addFlag( + kNonInteractive, + help: 'Do not ask anything in the standard input: ' + 'fail with an explanation instead of waiting for an answer. ' + 'For scripts and CI.', + negatable: false, + ); + /// Adds the `--format` option. /// /// In the [kFormatJson] mode a command prints a single JSON object diff --git a/lib/src/agents/guide_renderer.dart b/lib/src/agents/guide_renderer.dart index 75e237f..b855104 100644 --- a/lib/src/agents/guide_renderer.dart +++ b/lib/src/agents/guide_renderer.dart @@ -49,8 +49,9 @@ class GuideRenderer { } if (command.isInteractive) { - sb.writeln('Asks questions in stdin: pass all the options below ' - 'or do not call it from a script.'); + sb.writeln('Asks questions in stdin: pass the options that answer them, ' + 'and add `--non-interactive` to fail with an explanation ' + 'instead of waiting for an answer.'); } sb @@ -115,8 +116,10 @@ class GuideRenderer { 'parse the human readable output.'; static const _ruleInteractive = - 'Commands marked `[INTERACTIVE]` can ask a question in stdin. Pass the ' - 'options that answer the questions, otherwise the command will hang.'; + 'Commands marked `[INTERACTIVE]` can ask a question in stdin. Always run ' + 'them with `--non-interactive` and the options that answer the ' + 'questions: with the flag such a command fails with an explanation of ' + 'what to pass instead of waiting for an answer that never comes.'; static const _ruleGuide = 'This guide is generated from the commands tree of the installed ' diff --git a/lib/src/const.dart b/lib/src/const.dart index ba03d03..a373dc3 100644 --- a/lib/src/const.dart +++ b/lib/src/const.dart @@ -5,6 +5,9 @@ const packageName = 'alex'; const kVerbose = 'verbose'; +/// Name of the flag to forbid a command to ask questions in the standard input. +const kNonInteractive = 'non-interactive'; + /// Name of the option to define an output format of a command. const kFormat = 'format'; From 13e4a97b66b427f6d5d3a3ed274883d93a5304a7 Mon Sep 17 00:00:00 2001 From: greymag Date: Wed, 26 Aug 2026 09:31:57 +0200 Subject: [PATCH 13/13] fix(agents): do not report an empty default in the commands index `addMultiOption` without `defaultsTo` gives an empty `List`, not `null`, so the guide printed `(default: [])` for the `--file` option of `agents init`. In a machine readable index such a value is worse than no value: it looks like a real default. An empty collection is treated as "no default" now, the same as `false` for a flag. Also fixes the wording of the `code check` description, which is shown in the help and in the generated guide: "filtered from noise". Both are from the Copilot review of #61. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 2 +- README.md | 2 +- alex.yaml | 2 +- lib/commands/code/check_command.dart | 2 +- lib/src/agents/command_index.dart | 8 +++++--- lib/src/check/output_filter.dart | 2 +- lib/src/config.dart | 2 +- test/src/agents/command_index_test.dart | 13 +++++++++++++ 8 files changed, 24 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index caf5983..60d62fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,7 @@ * [Release] `start`: fix the length limit of the release notes in the form. The limit is enforced by alex now, instead of the `maxlength` attribute: a browser counts a newline as CRLF (2 characters), so a note with N newlines was cut N characters earlier than the store allows, and it was impossible to type anything while the counter still showed a value below the limit. The counter shows the number of characters as the store counts them, and it's highlighted if the value is longer than the limit. * **Fixed**: alex hangs if a command it runs asks for an input. For example, `alex code gen` stopped with no output when `fvm` asked to install a missing Flutter SDK. Such a question can't be answered anyway, because the command gets a pipe instead of the terminal, so its input is closed right after the start now, and the command fails with its own message instead of hanging. * **Fixed**: the output of a command could be lost, so a failed command could be reported without its error message. Now alex waits for the output to be fully read before it handles the result. -* [Code] New command `check`: runs the quality gates - analyze, tests and (optionally, with the `--build` flag) a debug build of the platform target. Output of the commands is filtered from a noise, a short verdict is printed for each gate. Exit code is `0` if all gates passed, `10` if analyze failed, `11` if tests failed, `12` if build failed. Flags `--analyze-only`, `--test-only` and `--fail-fast` are supported, arguments after `--` are passed to the test command. Build target and additional noise patterns can be defined with the new `code.check` config section. +* [Code] New command `check`: runs the quality gates - analyze, tests and (optionally, with the `--build` flag) a debug build of the platform target. Output of the commands is filtered from noise, a short verdict is printed for each gate. Exit code is `0` if all gates passed, `10` if analyze failed, `11` if tests failed, `12` if build failed. Flags `--analyze-only`, `--test-only` and `--fail-fast` are supported, arguments after `--` are passed to the test command. Build target and additional noise patterns can be defined with the new `code.check` config section. * [Common] New `--format=json` option (supported by `code check`, `info`, `agents` and `l10n check_translations` commands): a command prints a single JSON object with a machine readable report in the standard output, all other messages - including the update banner - go to the error output. * [Agents] New command group `agents` with the `guide` subcommand: prints a short guide of alex for an AI agent or a script - the rules (config discovery, machine readable output, exit codes, interactive commands) and all commands with their options and exit codes. The guide is generated from the commands tree of the installed version, so it can't get outdated. Supports a command path argument to print a part of it (`alex agents guide l10n`) and `--format=json` for a structured index. * [Agents] New command `agents init`: adds or updates the alex section in the agent instructions of the project (`CLAUDE.md`, `AGENTS.md` and alike), so an agent learns about alex from the file it reads first. The section describes the project (package, FVM, locales, l10n paths, branches) and the commands to use with their exit codes, and is generated from the alex config, `pubspec.yaml` and the commands tree. Only the content between the `` and `` markers is changed. Files are taken from the new `agents.files` config option, the `--file` (`-f`) option, or `CLAUDE.md`/`AGENTS.md` if they exist. With the `--check` flag nothing is changed and the command fails with exit code `10` if some file is missing or outdated. diff --git a/README.md b/README.md index 82f1414..76c20ba 100644 --- a/README.md +++ b/README.md @@ -431,7 +431,7 @@ $ alex code gen #### Check code quality Run the quality gates: analyze, tests and (optionally) a debug build of the platform target. -Output of the commands is filtered from a noise (update banners, dependency resolution +Output of the commands is filtered from noise (update banners, dependency resolution chatter, deprecation notices of third-party plugins), a short verdict is printed for each gate. ```bash diff --git a/alex.yaml b/alex.yaml index 6c8281c..0da75a3 100644 --- a/alex.yaml +++ b/alex.yaml @@ -34,7 +34,7 @@ code: # If not defined - ios is used on macOS and apk on other platforms. build_target: # Additional patterns (regular expressions) of the output lines - # that should be hidden as a noise. + # that should be hidden as noise. noise: [] # Application build settings (alex release start --local) diff --git a/lib/commands/code/check_command.dart b/lib/commands/code/check_command.dart index affc255..96635fd 100644 --- a/lib/commands/code/check_command.dart +++ b/lib/commands/code/check_command.dart @@ -35,7 +35,7 @@ class CheckCommand extends CodeCommandBase { : super( 'check', 'Run quality gates: analyze, tests and (optionally) a debug build.\n\n' - 'Output of the commands is filtered from a noise, ' + 'Output of the commands is filtered from noise, ' 'a short verdict is printed for each gate.\n\n' 'Exit code is 0 if all gates passed, ' '$kExitCodeAnalyzeFailed if analyze failed, ' diff --git a/lib/src/agents/command_index.dart b/lib/src/agents/command_index.dart index dfae0d1..1901f06 100644 --- a/lib/src/agents/command_index.dart +++ b/lib/src/agents/command_index.dart @@ -37,9 +37,11 @@ class CommandOptionInfo { factory CommandOptionInfo.fromOption(Option option) { final defaultValue = option.defaultsTo; - // `false` for a flag is the obvious default, no need to mention it. - final hasDefault = - defaultValue != null && !(option.isFlag && defaultValue == false); + // `false` for a flag and an empty list for a multi option + // are the obvious defaults, there is nothing to report about them. + final hasDefault = defaultValue != null && + !(option.isFlag && defaultValue == false) && + !(defaultValue is Iterable && defaultValue.isEmpty); return CommandOptionInfo( name: option.name, abbr: option.abbr, diff --git a/lib/src/check/output_filter.dart b/lib/src/check/output_filter.dart index 47a2f5a..3101db1 100644 --- a/lib/src/check/output_filter.dart +++ b/lib/src/check/output_filter.dart @@ -4,7 +4,7 @@ /// update banners, box-drawing borders, dependency resolution chatter, /// deprecation notices of third-party plugins, progress indicators. class OutputFilter { - /// Patterns of the lines that are considered as a noise by default. + /// Patterns of the lines that are considered noise by default. static const defaultNoisePatterns = [ // Box drawing borders of flutter banners. r'^\s*[╔╚║╭╰│┌└├┤┬┴┼─═╞╡]', diff --git a/lib/src/config.dart b/lib/src/config.dart index 94c210a..7d1e880 100644 --- a/lib/src/config.dart +++ b/lib/src/config.dart @@ -261,7 +261,7 @@ class CodeCheckConfig { final String? buildTarget; /// Additional patterns (regular expressions) of the output lines - /// that should be hidden as a noise. + /// that should be hidden as noise. final List noise; const CodeCheckConfig({ diff --git a/test/src/agents/command_index_test.dart b/test/src/agents/command_index_test.dart index 6946b7e..509877f 100644 --- a/test/src/agents/command_index_test.dart +++ b/test/src/agents/command_index_test.dart @@ -77,6 +77,19 @@ void main() { expect(command.exitCodes, const {10: 'no issue id'}); }); + test('should not report an empty list as a default of a multi option', () { + final command = _Cmd('init', 'Init.'); + command.argParser + ..addMultiOption('file', help: 'Files.') + ..addMultiOption('locale', help: 'Locales.', defaultsTo: const ['en']); + + final index = CommandIndex.build(_runner([command])); + final options = index.commands.single.options; + + expect(options.first.defaultValue, isNull); + expect(options.last.defaultValue, '[en]'); + }); + test('should not report false as a default value of a flag', () { final command = _Cmd('gen', 'Generate.'); command.argParser