diff --git a/ADDING_A_HARNESS.md b/ADDING_A_HARNESS.md index 5ffa807..c097281 100644 --- a/ADDING_A_HARNESS.md +++ b/ADDING_A_HARNESS.md @@ -277,6 +277,10 @@ In the preamble, `__ROOT__`, `__AGENT__` and `__COMPAT__` are replaced with quot plugin root, the agent the hook is scoped to — or an empty string when hooks of more than one agent scope run the script — and the compat script's path. +Before the compat script is sourced, set `VGV_AI_HARNESS=` and add +`$installedPluginsPreamble`. It tells a hook that recommends plugins which ones vgv_ai installed +and how to install another, as `recommend-plugins.sh` in Wingspan reads them. + **The compat script** goes in `_compat.dart` beside the translator. It reads what the harness sends on stdin, hands the script the payload Claude Code would have sent, and respells the answer the way the harness reads it. [`copilot_compat.dart`][copilot_compat] is the one to copy diff --git a/packages/vgv_ai_cli/lib/src/translate/hook_scripts.dart b/packages/vgv_ai_cli/lib/src/translate/hook_scripts.dart index 08b0123..a391056 100644 --- a/packages/vgv_ai_cli/lib/src/translate/hook_scripts.dart +++ b/packages/vgv_ai_cli/lib/src/translate/hook_scripts.dart @@ -8,6 +8,24 @@ import 'package:vgv_ai_cli/src/translate/copy_source.dart'; import 'package:vgv_ai_cli/src/translate/model/model.dart'; import 'package:vgv_ai_plugins/vgv_ai_plugins.dart'; +/// Goes in every harness's preamble, below a line that sets `VGV_AI_HARNESS` +/// to the harness's name. It exports, for a hook that recommends plugins, +/// the VGV plugins vgv_ai installed for that harness in `VGV_AI_PLUGINS`, +/// separated by spaces, and the command that installs another in +/// `VGV_AI_INSTALL_COMMAND`. +/// +/// The plugins are read from the manifest each run, since a later install +/// adds plugins without rewriting the scripts already copied. A value the +/// script inherits is kept, so the compat script's rerun reads it once. +const installedPluginsPreamble = r''' +export VGV_AI_INSTALL_COMMAND='vgv_ai install --plugin' +[ -n "${VGV_AI_PLUGINS+x}" ] || export VGV_AI_PLUGINS="$( + jq -r --arg harness "$VGV_AI_HARNESS" \ + '.plugins[$harness] // {} | keys | join(" ")' \ + "$HOME/.vgv_ai/manifest.json" 2> /dev/null +)" +'''; + /// {@template hook_scripts} /// A harness's copy of each plugin's `hooks` folder under the hooks root /// and the plugin's name, with the harness's [preamble] below the shebang diff --git a/packages/vgv_ai_cli/lib/src/translate/translators/antigravity_compat.dart b/packages/vgv_ai_cli/lib/src/translate/translators/antigravity_compat.dart index c440682..6fc1f8c 100644 --- a/packages/vgv_ai_cli/lib/src/translate/translators/antigravity_compat.dart +++ b/packages/vgv_ai_cli/lib/src/translate/translators/antigravity_compat.dart @@ -1,11 +1,16 @@ +import 'package:vgv_ai_cli/src/translate/translate.dart'; + /// Goes below the shebang of every script a hook runs, in Antigravity's /// copy of the plugin's hooks folder. `__ROOT__` and `__COMPAT__` are /// replaced with quoted shell words: the plugin root and the path of /// [antigravityCompatScript]. -const antigravityPreamble = ''' +const antigravityPreamble = + ''' # Added by vgv_ai install so this Claude Code hook script runs under # Antigravity CLI. export CLAUDE_PLUGIN_ROOT=__ROOT__ +VGV_AI_HARNESS=antigravity +$installedPluginsPreamble . __COMPAT__ '''; diff --git a/packages/vgv_ai_cli/lib/src/translate/translators/codex_compat.dart b/packages/vgv_ai_cli/lib/src/translate/translators/codex_compat.dart index ff16fca..a222650 100644 --- a/packages/vgv_ai_cli/lib/src/translate/translators/codex_compat.dart +++ b/packages/vgv_ai_cli/lib/src/translate/translators/codex_compat.dart @@ -1,10 +1,15 @@ +import 'package:vgv_ai_cli/src/translate/translate.dart'; + /// Goes below the shebang of every script a hook runs, in Codex's copy of /// the plugin's hooks folder. `__ROOT__`, `__AGENT__` and `__COMPAT__` are /// replaced with quoted shell words: the plugin root, the agent the hook is /// scoped to or an empty string, and the path of [codexCompatScript]. -const codexPreamble = ''' +const codexPreamble = + ''' # Added by vgv_ai install so this Claude Code hook script runs under Codex. export CLAUDE_PLUGIN_ROOT=__ROOT__ +VGV_AI_HARNESS=codex +$installedPluginsPreamble VGV_AGENT=__AGENT__ . __COMPAT__ '''; diff --git a/packages/vgv_ai_cli/lib/src/translate/translators/copilot_compat.dart b/packages/vgv_ai_cli/lib/src/translate/translators/copilot_compat.dart index 9740607..8d8d16a 100644 --- a/packages/vgv_ai_cli/lib/src/translate/translators/copilot_compat.dart +++ b/packages/vgv_ai_cli/lib/src/translate/translators/copilot_compat.dart @@ -1,10 +1,15 @@ +import 'package:vgv_ai_cli/src/translate/translate.dart'; + /// Goes below the shebang of every script a hook runs, in Copilot's copy of /// the plugin's hooks folder. `__ROOT__` and `__COMPAT__` are replaced with /// quoted shell words: the plugin root and the path of /// [copilotCompatScript]. -const copilotPreamble = ''' +const copilotPreamble = + ''' # Added by vgv_ai install so this Claude Code hook script runs under Copilot. export CLAUDE_PLUGIN_ROOT=__ROOT__ +VGV_AI_HARNESS=copilot +$installedPluginsPreamble . __COMPAT__ '''; diff --git a/packages/vgv_ai_cli/test/src/translate/installed_plugins_preamble_test.dart b/packages/vgv_ai_cli/test/src/translate/installed_plugins_preamble_test.dart new file mode 100644 index 0000000..472ef80 --- /dev/null +++ b/packages/vgv_ai_cli/test/src/translate/installed_plugins_preamble_test.dart @@ -0,0 +1,99 @@ +import 'dart:io'; + +import 'package:file/file.dart'; +import 'package:file/local.dart'; +import 'package:test/test.dart'; +import 'package:vgv_ai_cli/src/model/model.dart'; +import 'package:vgv_ai_cli/src/translate/translate.dart'; + +/// Runs [installedPluginsPreamble] for real, through bash and jq. Skipped +/// where bash or jq is missing. +void main() { + final bash = Process.runSync('bash', ['-c', 'command -v jq']).exitCode == 0; + + group('installedPluginsPreamble', () { + late Directory home; + late File script; + + setUp(() { + home = const LocalFileSystem().systemTempDirectory.createTempSync(); + addTearDown(() => home.deleteSync(recursive: true)); + script = home.childFile('hook.sh') + ..writeAsStringSync( + '#!/bin/bash\n' + 'VGV_AI_HARNESS=codex\n' + '$installedPluginsPreamble' + r'printf "%s|%s" "$VGV_AI_PLUGINS" "$VGV_AI_INSTALL_COMMAND"', + ); + }); + + /// Writes the manifest vgv_ai keeps under [home]. + void manifest(String json) { + home.childDirectory('.vgv_ai').childFile('manifest.json') + ..createSync(recursive: true) + ..writeAsStringSync(json); + } + + /// What the script saw, with [env] on top of a [home] of its own. + String run([Map env = const {}]) { + final result = Process.runSync( + 'bash', + [script.path], + environment: {'HOME': home.path, ...env}, + ); + return result.stdout as String; + } + + test( + 'export the plugins installed for the harness and the install command', + () { + manifest(''' +{ + "version": 3, + "plugins": { + "codex": {"vgv-wingspan": "0.1.0", "vgv-ai-flutter-plugin": "0.1.0"}, + "copilot": {"vgv-ffca-plugin": "0.1.0"} + } +}'''); + + expect( + run(), + 'vgv-ai-flutter-plugin vgv-wingspan|vgv_ai install --plugin', + ); + }, + skip: !bash, + ); + + test('export no plugins when the harness has none', () { + manifest('{"version": 3, "plugins": {"copilot": {"vgv-wingspan": "1"}}}'); + + expect(run(), '|vgv_ai install --plugin'); + }, skip: !bash); + + test('export no plugins when there is no manifest', () { + expect(run(), '|vgv_ai install --plugin'); + }, skip: !bash); + + test('keep the plugins the script inherits', () { + manifest('{"version": 3, "plugins": {"codex": {"vgv-wingspan": "1"}}}'); + + expect( + run({'VGV_AI_PLUGINS': 'inherited'}), + 'inherited|vgv_ai install --plugin', + ); + }, skip: !bash); + }); + + test('every preamble exports the installed plugins for its harness', () { + for (final (harness, preamble) in [ + (Harness.codex, codexPreamble), + (Harness.copilot, copilotPreamble), + (Harness.antigravity, antigravityPreamble), + ]) { + expect( + preamble, + contains('VGV_AI_HARNESS=${harness.name}\n$installedPluginsPreamble'), + ); + } + }); +}