diff --git a/packages/plugin_source/README.md b/packages/plugin_source/README.md index a22d75eb..d2c5d805 100644 --- a/packages/plugin_source/README.md +++ b/packages/plugin_source/README.md @@ -43,7 +43,8 @@ Future main() async { A source that can't supply the plugin returns an `UnavailablePlugin` with the reasons, rather than throwing. A plugin file that can't be understood makes the parser throw a `PluginParseException` that names the file and the field. -`PluginAssets.merge` combines several plugins into one set of assets. +`PluginAssets.merge` combines several plugins into one set of assets, and +`withPlugin` records which plugin supplied one. ## Layout diff --git a/packages/plugin_source/lib/src/model/plugin_assets.dart b/packages/plugin_source/lib/src/model/plugin_assets.dart index f672a35f..48443aa8 100644 --- a/packages/plugin_source/lib/src/model/plugin_assets.dart +++ b/packages/plugin_source/lib/src/model/plugin_assets.dart @@ -2,6 +2,7 @@ import 'package:plugin_source/src/model/agent_asset.dart'; import 'package:plugin_source/src/model/hook_asset.dart'; import 'package:plugin_source/src/model/mcp_server.dart'; import 'package:plugin_source/src/model/skill_asset.dart'; +import 'package:plugin_source/src/plugin_ref.dart'; /// {@template plugin_assets} /// Everything one or more plugins contribute, in a harness-neutral shape. @@ -18,19 +19,27 @@ class const PluginAssets({ /// The hooks, in the order the plugins declare them. final List hooks = const [], + + /// The plugins these come from, which the harness that installs them + /// itself names in its settings. + final List plugins = const [], }) { - /// Combines what several plugins contribute into one [PluginAssets]. + /// Combines what the [plugins] contribute into one [PluginAssets]. /// /// Each skill, agent and MCP server is written to a path or key derived /// from its name, so the first declaration of a name wins and a later one /// is dropped rather than planned as a second write to the same place. - factory merge(Iterable assets) => PluginAssets( + factory merge( + Iterable assets, { + List plugins = const [], + }) => PluginAssets( skills: _firstByName([for (final a in assets) ...a.skills], (s) => s.name), agents: _firstByName([for (final a in assets) ...a.agents], (a) => a.name), mcpServers: _firstByName([ for (final a in assets) ...a.mcpServers, ], (s) => s.name), hooks: [for (final a in assets) ...a.hooks], + plugins: plugins, ); /// The skills a user can invoke as a command. diff --git a/packages/plugin_source/test/src/model/plugin_assets_test.dart b/packages/plugin_source/test/src/model/plugin_assets_test.dart index d2d9a0e0..f84085a3 100644 --- a/packages/plugin_source/test/src/model/plugin_assets_test.dart +++ b/packages/plugin_source/test/src/model/plugin_assets_test.dart @@ -29,6 +29,20 @@ void main() { hooks: hooks, ); + const wingspan = PluginRef( + name: 'wingspan', + repo: 'acme/wingspan', + version: '1.0.0', + gitRef: 'v1.0.0', + ); + + const flutter = PluginRef( + name: 'flutter', + repo: 'acme/flutter', + version: '2.0.0', + gitRef: 'v2.0.0', + ); + setUp(() => fs = MemoryFileSystem.test()); group('invocableSkills', () { @@ -57,6 +71,15 @@ void main() { }); group('merge', () { + test('names the plugins the assets came from', () { + final merged = PluginAssets.merge( + [pluginAssets(), pluginAssets()], + plugins: [wingspan, flutter], + ); + + expect(merged.plugins, [wingspan, flutter]); + }); + AgentAsset agent(String name) => AgentAsset(name: name, description: 'd', body: 'b', skills: const []); diff --git a/packages/vgv_ai_cli/README.md b/packages/vgv_ai_cli/README.md index bf8f1611..08756c41 100644 --- a/packages/vgv_ai_cli/README.md +++ b/packages/vgv_ai_cli/README.md @@ -65,6 +65,9 @@ vgv_ai install --no-claude # Also overwrite your values that conflict with the ones install writes vgv_ai install --force + +# Install just the VGV plugins you name, instead of every pinned one +vgv_ai install --plugin vgv-wingspan ``` `install` reads the pinned plugins from the Claude Code plugin cache when it @@ -82,6 +85,9 @@ how `doctor` and `uninstall` tell what's yours from what's VGV's. are written whole. A file you edited since `install` wrote it, or one at the same path that `install` didn't write, is kept unless you pass `--force`. - **Selection:** `--only` and `--no-` can't be combined. +- **Plugins:** without `--plugin`, every pinned plugin is installed. Pass it + once per plugin to name the ones you want. What a previous `install` wrote + for the plugins you leave out stays where it is; `uninstall` removes that. ### doctor @@ -100,7 +106,8 @@ vgv_ai doctor --no-claude For each harness, `doctor` reports the files and keys that are missing, were changed since, or differ from what this version installs. It also compares the -plugin versions you installed from with the ones this version pins, suggests +plugin versions each harness was installed from with the ones this version +pins, notes a pinned plugin a harness wasn't installed from, suggests configuring a harness that's installed but not configured, and warns about a configured harness that's no longer installed. diff --git a/packages/vgv_ai_cli/lib/src/commands/doctor/doctor_command.dart b/packages/vgv_ai_cli/lib/src/commands/doctor/doctor_command.dart index 0081940d..be60dcf3 100644 --- a/packages/vgv_ai_cli/lib/src/commands/doctor/doctor_command.dart +++ b/packages/vgv_ai_cli/lib/src/commands/doctor/doctor_command.dart @@ -1,6 +1,7 @@ import 'dart:io' show Platform; import 'package:args/command_runner.dart'; +import 'package:collection/collection.dart'; import 'package:file/file.dart'; import 'package:mason_logger/mason_logger.dart'; import 'package:path/path.dart' as p; @@ -93,7 +94,11 @@ class DoctorCommand({ if (reports.isEmpty) _logger.info(_nothingToCheck(manifest)); - final fix = FixCommand.combine(reports.map((report) => report.fix)); + // A section install can fix but that no one command covers would make + // the closing line promise more than it does, so there is none. + final fix = reports.any((report) => report.needsItsOwnFix) + ? null + : FixCommand.combine(reports.map((report) => report.fix)); if (fix != null) { _logger ..info('') @@ -160,30 +165,71 @@ class DoctorCommand({ /// What a dry run of `install` for [selected] would do, or an unchecked /// plan, having said why, when the plugins cannot be resolved. + /// + /// Harnesses installed from different plugins are planned apart, so a + /// plugin one of them was installed without is not planned for it. Future _plan( InstallManifest manifest, Directory home, Set selected, ) async { final progress = _logger.progress('Resolving plugins'); - switch (await _resolver.resolve(home)) { - case ResolvedAssets(:final assets): - progress.complete('Resolved plugins'); - final translation = await _translator.translate( - assets: assets, - home: home, - selected: selected, - ); - return ContentPlan( - _reconciler - .apply(translation.changes, manifest: manifest, dryRun: true) - .changes, - ); - case UnavailableAssets(:final plugin, :final reasons): - return _unchecked(progress, 'resolve $plugin', reasons); - case UnparseableAssets(:final plugin, :final error): - return _unchecked(progress, 'parse $plugin', ['$error']); + final changes = []; + + for (final MapEntry(key: only, value: harnesses) in _byPlugins( + manifest, + selected, + ).entries) { + switch (await _resolver.resolve(home, only: only)) { + case ResolvedAssets(:final assets): + final translation = await _translator.translate( + assets: assets, + home: home, + selected: harnesses, + ); + changes.addAll( + _reconciler + .apply(translation.changes, manifest: manifest, dryRun: true) + .changes, + ); + case UnavailableAssets(:final plugin, :final reasons): + return _unchecked(progress, 'resolve $plugin', reasons); + case UnparseableAssets(:final plugin, :final error): + return _unchecked(progress, 'parse $plugin', ['$error']); + } + } + + progress.complete('Resolved plugins'); + return ContentPlan(changes); + } + + /// [selected] grouped by the plugin names each harness was installed + /// from, which the map compares as sets rather than by identity. + Map, Set> _byPlugins( + InstallManifest manifest, + Set selected, + ) { + final groups = EqualityMap, Set>(_sets); + for (final harness in selected) { + groups + .putIfAbsent(_installedFrom(manifest, harness), () => {}) + .add(harness); } + return groups; + } + + /// How a set of plugin names is compared, since two of them are the same + /// selection whatever order they were built in. + static const Equality> _sets = SetEquality(); + + /// The plugins [harness] was installed from, which for one the manifest + /// records none for is every pinned plugin, as `install` writes without + /// `--plugin`. + Set _installedFrom(InstallManifest manifest, Harness harness) { + final recorded = manifest.plugins[harness]?.keys.toSet() ?? const {}; + return recorded.isEmpty + ? {for (final plugin in vgvPlugins) plugin.name} + : recorded; } /// An unchecked plan, having failed [progress] and listed [reasons] for @@ -205,10 +251,10 @@ class DoctorCommand({ ); } - /// The sections of the report: the plugin versions, and why content was - /// not checked if it was not, when a harness is [installed], the shared - /// skills when an [installed] harness reads them, and each harness that - /// is [installed] or [detected]. + /// The sections of the report: why content was not checked if it was not, + /// the shared skills when an [installed] harness reads them, and each + /// harness that is [installed] or [detected], with the plugin versions it + /// was installed from. /// /// [installed] are the harnesses of the selected scope VGV configured, and /// [detected] those of it on this machine. @@ -222,11 +268,17 @@ class DoctorCommand({ final readsShared = installed.intersection(_readShared); return [ - if (installed.isNotEmpty) _pluginsSection(manifest, installed, plan), + if (plan.skipped case final skipped?) + ReportSection( + title: 'Plugins', + installs: installed, + findings: [skipped], + ), if (readsShared.isNotEmpty && manifest.entriesFor(null).isNotEmpty) ReportSection( title: 'Shared', installs: readsShared, + plugins: _shared(manifest, readsShared), findings: _entryFindings(null, manifest, home, plan), ), for (final harness in Harness.values) @@ -242,25 +294,6 @@ class DoctorCommand({ ]; } - /// The plugin versions [installed] read, and why content was not checked - /// if [plan] was not. - ReportSection _pluginsSection( - InstallManifest manifest, - Set installed, - ContentPlan plan, - ) => ReportSection( - title: 'Plugins', - installs: installed, - findings: [ - for (final plugin in vgvPlugins) - PluginVersion.of( - plugin, - installed: manifest.plugins[plugin.name], - ).finding, - ?plan.skipped, - ], - ); - /// The section for [harness], which VGV configured when [isInstalled] and /// is on this machine when [isDetected]. ReportSection _harnessSection( @@ -270,21 +303,47 @@ class DoctorCommand({ ContentPlan plan, { required bool isInstalled, required bool isDetected, - }) => ReportSection( - title: harness.displayName, - installs: {harness}, - findings: [ - if (!isDetected) - const Finding.warning( - 'not installed on this machine, but VGV AI CLI configured it', - ), - if (!isInstalled) - const Finding.suggestion( - "installed, but VGV AI CLI hasn't configured it", - ), - ..._entryFindings(harness, manifest, home, plan), - ], - ); + }) { + final recorded = manifest.plugins[harness] ?? const {}; + return ReportSection( + title: harness.displayName, + installs: {harness}, + plugins: _selected(recorded), + findings: [ + if (!isDetected) + const Finding.warning( + 'not installed on this machine, but VGV AI CLI configured it', + ), + if (!isInstalled) + const Finding.suggestion( + "installed, but VGV AI CLI hasn't configured it", + ), + if (isInstalled) + for (final plugin in vgvPlugins) + PluginVersion.of(plugin, installed: recorded[plugin.name]).finding, + ..._entryFindings(harness, manifest, home, plan), + ], + ); + } + + /// The plugins every harness of [readers] was installed from, or null + /// when they differ, since one `install` cannot then fix the skills they + /// share without adding a plugin one of them was left without. + Set? _shared(InstallManifest manifest, Set readers) { + final selections = EqualitySet>(_sets) + ..addAll([ + for (final harness in readers) + _selected(manifest.plugins[harness] ?? const {}), + ]); + return selections.length == 1 ? selections.single : null; + } + + /// The plugin names a fix must name, or none when [recorded] holds every + /// pinned plugin, which `install` writes without being asked. + Set _selected(Map recorded) => + vgvPlugins.every((plugin) => recorded.containsKey(plugin.name)) + ? const {} + : recorded.keys.toSet(); /// What is wrong with the entries [owner] has in [manifest], and the /// files [plan] would add for it. diff --git a/packages/vgv_ai_cli/lib/src/commands/doctor/model/fix_command.dart b/packages/vgv_ai_cli/lib/src/commands/doctor/model/fix_command.dart index 1edb0c56..c6bd7753 100644 --- a/packages/vgv_ai_cli/lib/src/commands/doctor/model/fix_command.dart +++ b/packages/vgv_ai_cli/lib/src/commands/doctor/model/fix_command.dart @@ -1,3 +1,5 @@ +import 'package:collection/collection.dart'; +import 'package:plugin_source/plugin_source.dart'; import 'package:vgv_ai_cli/src/model/model.dart'; /// {@template fix_command} @@ -9,15 +11,28 @@ class const FixCommand({ /// Whether VGV's values must replace ones changed since install. final bool force = false, + + /// The plugins to install, or empty for every pinned one. + /// + /// Named so the fix cannot install a plugin the user left out of a + /// harness with `install --plugin`. + final Set plugins = const {}, }) { /// One command that does what every one of [fixes] does, or null when - /// there are none. + /// there are none, or when they install different plugins and so cannot + /// be said in one command. static FixCommand? combine(Iterable fixes) { - final all = fixes.nonNulls; + final all = fixes.nonNulls.toList(); if (all.isEmpty) return null; + + final plugins = EqualitySet>(const SetEquality()) + ..addAll(all.map((fix) => fix.plugins)); + if (plugins.length > 1) return null; + return FixCommand( harnesses: {for (final fix in all) ...fix.harnesses}, force: all.any((fix) => fix.force), + plugins: plugins.single, ); } @@ -26,6 +41,8 @@ class const FixCommand({ 'vgv_ai install', for (final harness in Harness.values) if (harnesses.contains(harness)) '--only ${harness.name}', + for (final plugin in vgvPlugins) + if (plugins.contains(plugin.name)) '--plugin ${plugin.name}', if (force) '--force', ].join(' '); } diff --git a/packages/vgv_ai_cli/lib/src/commands/doctor/model/plugin_version.dart b/packages/vgv_ai_cli/lib/src/commands/doctor/model/plugin_version.dart index 66faf6b3..52a67e96 100644 --- a/packages/vgv_ai_cli/lib/src/commands/doctor/model/plugin_version.dart +++ b/packages/vgv_ai_cli/lib/src/commands/doctor/model/plugin_version.dart @@ -67,9 +67,7 @@ class const PluginVersion._({ PluginStatus.newer => Finding.stale( '$name $installed installed, newer than the pinned $pinned', ), - PluginStatus.unrecorded => Finding.stale( - '$name not recorded, $pinned pinned', - ), + PluginStatus.unrecorded => Finding.warning('$name $pinned not installed'), }; } } diff --git a/packages/vgv_ai_cli/lib/src/commands/doctor/model/report_section.dart b/packages/vgv_ai_cli/lib/src/commands/doctor/model/report_section.dart index 4c57405e..95741356 100644 --- a/packages/vgv_ai_cli/lib/src/commands/doctor/model/report_section.dart +++ b/packages/vgv_ai_cli/lib/src/commands/doctor/model/report_section.dart @@ -15,6 +15,11 @@ class const ReportSection({ /// What was found, in the order it was found. required final List findings, + + /// The plugins the fix installs, empty for every pinned one, or null when + /// the harnesses were installed from different ones and no one `install` + /// fixes them all. + final Set? plugins = const {}, }) { /// Whether anything was found that is wrong. bool get hasProblems => findings.any((finding) => finding.isProblem); @@ -22,12 +27,22 @@ class const ReportSection({ /// Whether anything was found that is worth knowing, though not wrong. bool get hasNotices => findings.any((finding) => finding.isNotice); + /// Whether `install` fixes something here that [fix] cannot say in one + /// command, because the harnesses were installed from different plugins. + bool get needsItsOwnFix => + fix == null && findings.any((finding) => finding.needsInstall); + /// The one `install` command that fixes every finding `install` can fix, - /// or null when there are none. - FixCommand? get fix => findings.any((finding) => finding.needsInstall) - ? FixCommand( - harnesses: installs, - force: findings.any((finding) => finding.needsForce), - ) - : null; + /// or null when there are none, or when no one command fixes them. + FixCommand? get fix { + if (plugins case final plugins? + when findings.any((finding) => finding.needsInstall)) { + return FixCommand( + harnesses: installs, + force: findings.any((finding) => finding.needsForce), + plugins: plugins, + ); + } + return null; + } } diff --git a/packages/vgv_ai_cli/lib/src/commands/install_command.dart b/packages/vgv_ai_cli/lib/src/commands/install_command.dart index 6d3c08af..2b6dc53a 100644 --- a/packages/vgv_ai_cli/lib/src/commands/install_command.dart +++ b/packages/vgv_ai_cli/lib/src/commands/install_command.dart @@ -4,6 +4,7 @@ import 'package:args/command_runner.dart'; import 'package:collection/collection.dart'; import 'package:file/file.dart'; import 'package:mason_logger/mason_logger.dart'; +import 'package:plugin_source/plugin_source.dart'; import 'package:vgv_ai_cli/src/commands/harness_selection.dart'; import 'package:vgv_ai_cli/src/commands/reporting_manifest_store.dart'; import 'package:vgv_ai_cli/src/commands/user_home.dart'; @@ -22,6 +23,8 @@ import 'package:vgv_ai_cli/src/translate/translate.dart'; /// * `--only` configures the named harnesses, detected or not. /// * `--no-` skips one detected harness. It cannot be combined with /// `--only`, which already names the set. +/// * `--plugin` installs just the named VGV plugins instead of every pinned +/// one. /// {@endtemplate} class InstallCommand({ required final Logger _logger, @@ -45,6 +48,11 @@ class InstallCommand({ 'Also overwrite your values that conflict with the ones install ' 'writes.', negatable: false, + ) + ..addMultiOption( + 'plugin', + help: 'Install just these VGV plugins, instead of every pinned one.', + allowed: [for (final plugin in vgvPlugins) plugin.name], ); addHarnessOptions('Configure the Very Good AI tooling for'); } @@ -134,9 +142,9 @@ class InstallCommand({ dryRun || store.save( from: manifest, - to: reconciliation.manifest.withPlugins({ - for (final plugin in resolved.plugins) plugin.name: plugin.version, - }), + to: reconciliation.manifest.withPlugins( + _pluginVersions(manifest, resolved.assets.plugins, selected), + ), ); final anyChangeFailed = applied.any( (change) => change.outcome == ChangeOutcome.failed, @@ -185,14 +193,46 @@ class InstallCommand({ return selected; } - /// Resolves and parses every pinned VGV plugin into one asset bundle. + /// The plugins `--plugin` names, or null when it names none and every + /// pinned plugin is installed. + Set? get _selectedPlugins { + final names = argResults!['plugin'] as List; + return names.isEmpty ? null : names.toSet(); + } + + /// The versions to record for each harness of [selected]: [installed] for + /// the plugins this run wrote, and what [manifest] already records for the + /// ones it skipped, whose files are still installed. Every other harness + /// keeps what it had. + Map> _pluginVersions( + InstallManifest manifest, + List installed, + Set selected, + ) { + final written = { + for (final plugin in installed) plugin.name: plugin.version, + }; + return { + ...manifest.plugins, + for (final harness in selected) + harness: { + for (final plugin in vgvPlugins) + plugin.name: + ?(written[plugin.name] ?? + manifest.plugins[harness]?[plugin.name]), + }, + }; + } + + /// Resolves and parses the plugins `--plugin` selects into one asset + /// bundle, or every pinned one when it selects none. /// /// Returns `null`, having said why, when any plugin cannot be resolved or /// parsed. Future _resolveAssets(Directory home) async { final progress = _logger.progress('Resolving plugins'); - switch (await _resolver.resolve(home)) { + switch (await _resolver.resolve(home, only: _selectedPlugins)) { case final ResolvedAssets resolved: progress.complete( 'Resolved ${resolved.assets.skills.length} skills and ' diff --git a/packages/vgv_ai_cli/lib/src/manifest/manifest_auditor.dart b/packages/vgv_ai_cli/lib/src/manifest/manifest_auditor.dart index a92cb56e..49da2137 100644 --- a/packages/vgv_ai_cli/lib/src/manifest/manifest_auditor.dart +++ b/packages/vgv_ai_cli/lib/src/manifest/manifest_auditor.dart @@ -73,12 +73,14 @@ class const ManifestAuditor({ final narrowed = [ for (final ReversedEntry(:retained) in reversed) ?retained, ]; + final kept = [...manifest.without(settled).entries, ...narrowed]; + final remaining = {for (final entry in kept) entry.owner}; return Reversal( entries: reversed, - manifest: InstallManifest([ - ...manifest.without(settled).entries, - ...narrowed, - ], manifest.plugins), + manifest: InstallManifest(kept, { + for (final MapEntry(:key, :value) in manifest.plugins.entries) + if (remaining.contains(key)) key: value, + }), ); } diff --git a/packages/vgv_ai_cli/lib/src/model/install_manifest.dart b/packages/vgv_ai_cli/lib/src/model/install_manifest.dart index c19b0874..2ad537cc 100644 --- a/packages/vgv_ai_cli/lib/src/model/install_manifest.dart +++ b/packages/vgv_ai_cli/lib/src/model/install_manifest.dart @@ -174,7 +174,7 @@ final class const MarkdownSections({ /// {@template install_manifest} /// Everything VGV wrote under a home directory, by file, and the plugin -/// versions it was written from. +/// versions each harness was written from. /// /// `install` writes it, and `doctor` and `uninstall` read it, so this is the /// one place ownership is defined. It is persisted rather than recomputed @@ -196,9 +196,9 @@ class InstallManifest extends Equatable { /// /// A malformed manifest is empty: an unrecorded file is the user's, which /// is the safe way to be wrong. For the same reason, an entry whose path - /// is absolute or leads out of [home] is dropped. A version 1 manifest is - /// read as one that records no plugin versions. One from a newer version - /// is null, because writing over it would lose everything it records. + /// is absolute or leads out of [home] is dropped, as are plugin versions + /// that are not written per harness. One from a newer version is null, + /// because writing over it would lose everything it records. static InstallManifest? decode(String source, {required String home}) { final Object? json; try { @@ -209,25 +209,33 @@ class InstallManifest extends Equatable { return switch (json) { { - 'version': 1 || InstallManifest.version, + 'version': InstallManifest.version, 'entries': final List list, } => - InstallManifest( - [ - for (final item in list) - ?ManifestEntry.fromJson(_resolved(item, home: home)), - ], - { - if (json['plugins'] case final Map plugins) - for (final MapEntry(:key, :value) in plugins.entries) - if (value is String) key: value, - }, - ), + InstallManifest([ + for (final item in list) + ?ManifestEntry.fromJson(_resolved(item, home: home)), + ], _plugins(json['plugins'])), {'version': final int other} when other > version => null, _ => InstallManifest(), }; } + /// The versions [json] records per harness, without what this version + /// cannot read. + static Map> _plugins(Object? json) { + if (json is! Map) return const {}; + return { + for (final MapEntry(:key, :value) in json.entries) + if (Harness.values.asNameMap()[key] case final harness?) + if (value is Map) + harness: { + for (final MapEntry(:key, :value) in value.entries) + if (value is String) key: value, + }, + }; + } + /// [item] with its path resolved against [home], or null when the path /// is absolute or leads out of [home]. static Object? _resolved(Object? item, {required String home}) { @@ -241,12 +249,16 @@ class InstallManifest extends Equatable { } /// The format [encode] writes. - static const version = 2; + static const version = 1; final Map _entries; - /// Each plugin's name to the version VGV's files were written from. - final Map plugins; + /// Each harness to the plugin versions its files were written from, by + /// plugin name. + /// + /// Per harness rather than per home, since `install --plugin` can leave a + /// plugin out of one harness while another is installed from it. + final Map> plugins; /// Every entry, in the order they were recorded. Iterable get entries => _entries.values; @@ -279,8 +291,9 @@ class InstallManifest extends Equatable { ); } - /// This manifest with [plugins] as the versions it was written from. - InstallManifest withPlugins(Map plugins) { + /// This manifest with [plugins] as the versions each harness was written + /// from. + InstallManifest withPlugins(Map> plugins) { return InstallManifest(entries, plugins); } @@ -289,7 +302,9 @@ class InstallManifest extends Equatable { String encode({required String home}) { final json = { 'version': version, - 'plugins': plugins, + 'plugins': { + for (final MapEntry(:key, :value) in plugins.entries) key.name: value, + }, 'entries': [ for (final entry in entries) {...entry.toJson(), 'path': p.relative(entry.path, from: home)}, diff --git a/packages/vgv_ai_cli/lib/src/resolve/model/asset_resolution.dart b/packages/vgv_ai_cli/lib/src/resolve/model/asset_resolution.dart index 4587ca1b..285a071d 100644 --- a/packages/vgv_ai_cli/lib/src/resolve/model/asset_resolution.dart +++ b/packages/vgv_ai_cli/lib/src/resolve/model/asset_resolution.dart @@ -6,14 +6,11 @@ import 'package:plugin_source/plugin_source.dart'; sealed class const AssetResolution(); /// {@template resolved_assets} -/// Every plugin in [plugins] resolved and parsed, merged into [assets]. +/// Every plugin resolved and parsed, merged into [assets], which names them. /// {@endtemplate} final class const ResolvedAssets({ /// What the plugins contribute, merged. required final PluginAssets assets, - - /// The plugins that were resolved, at the versions they were pinned to. - required final List plugins, }) extends AssetResolution; /// {@template unavailable_assets} diff --git a/packages/vgv_ai_cli/lib/src/resolve/plugin_resolver.dart b/packages/vgv_ai_cli/lib/src/resolve/plugin_resolver.dart index c11cb18e..33babc25 100644 --- a/packages/vgv_ai_cli/lib/src/resolve/plugin_resolver.dart +++ b/packages/vgv_ai_cli/lib/src/resolve/plugin_resolver.dart @@ -18,10 +18,10 @@ class const PluginResolver({ final PluginParser _parser = const ClaudePluginParser(), final List _plugins = vgvPlugins, }) { - /// Resolves every plugin for [home], stopping at the first that cannot be - /// resolved or parsed: a half-configured harness is worse than an - /// unconfigured one. - Future resolve(Directory home) async { + /// Resolves the plugins [only] names for [home], or every pinned one when + /// it names none, stopping at the first that cannot be resolved or parsed: + /// a half-configured harness is worse than an unconfigured one. + Future resolve(Directory home, {Set? only}) async { final sources = [ LocalCachePluginSource(home: home), GitPluginSource( @@ -30,8 +30,13 @@ class const PluginResolver({ ), ]; + final plugins = [ + for (final plugin in _plugins) + if (only?.contains(plugin.name) ?? true) plugin, + ]; + final parsed = []; - for (final plugin in _plugins) { + for (final plugin in plugins) { switch (await _resolveOne(plugin, sources)) { case ResolvedAssets(:final assets): parsed.add(assets); @@ -42,10 +47,7 @@ class const PluginResolver({ } } - return ResolvedAssets( - assets: PluginAssets.merge(parsed), - plugins: _plugins, - ); + return ResolvedAssets(assets: PluginAssets.merge(parsed, plugins: plugins)); } /// [plugin] parsed from the first of [sources] holding a copy that parses. @@ -65,10 +67,7 @@ class const PluginResolver({ switch (await source.resolve(plugin)) { case ResolvedPlugin(:final directory): try { - return ResolvedAssets( - assets: _parser.parse(directory), - plugins: [plugin], - ); + return ResolvedAssets(assets: _parser.parse(directory)); } on PluginParseException catch (error) { unparseable = UnparseableAssets(plugin, error); } diff --git a/packages/vgv_ai_cli/lib/src/translate/translators/claude_translator.dart b/packages/vgv_ai_cli/lib/src/translate/translators/claude_translator.dart index c3aeb6e6..1dae2f81 100644 --- a/packages/vgv_ai_cli/lib/src/translate/translators/claude_translator.dart +++ b/packages/vgv_ai_cli/lib/src/translate/translators/claude_translator.dart @@ -29,7 +29,7 @@ class const ClaudeTranslator() extends HarnessTranslator { }, }, 'enabledPlugins': { - for (final plugin in vgvPlugins) + for (final plugin in assets.plugins) '${plugin.name}@$vgvMarketplaceName': true, }, }); diff --git a/packages/vgv_ai_cli/test/src/commands/doctor/doctor_command_test.dart b/packages/vgv_ai_cli/test/src/commands/doctor/doctor_command_test.dart index de06c498..d7995d3f 100644 --- a/packages/vgv_ai_cli/test/src/commands/doctor/doctor_command_test.dart +++ b/packages/vgv_ai_cli/test/src/commands/doctor/doctor_command_test.dart @@ -28,15 +28,23 @@ class _MockProgress extends Mock implements Progress; /// A [PluginResolver] that answers with [resolution], and counts its calls. class _FakeResolver extends Fake implements PluginResolver { AssetResolution resolution = const ResolvedAssets( - assets: PluginAssets(skills: [], agents: [], mcpServers: []), - plugins: vgvPlugins, + assets: PluginAssets( + skills: [], + agents: [], + mcpServers: [], + plugins: vgvPlugins, + ), ); int calls = 0; + /// The plugins each call asked for, in order. + final resolved = ?>[]; + @override - Future resolve(Directory home) async { + Future resolve(Directory home, {Set? only}) async { calls++; + resolved.add(only); return resolution; } } @@ -143,14 +151,18 @@ void main() { }; /// Installs [changes] with the real reconciler, recording the pinned - /// plugin versions, as `install` does, and plans them again, so they - /// are current unless a test changes the plan. + /// plugin versions for each harness it writes for, as `install` does, + /// and plans them again, so they are current unless a test changes the + /// plan. void install(List changes) { + final manifest = const Reconciler() + .apply(changes, manifest: store.load()!) + .manifest; store.save( - const Reconciler() - .apply(changes, manifest: store.load()!) - .manifest - .withPlugins(pinned), + manifest.withPlugins({ + ...manifest.plugins, + for (final change in changes) ?change.harness: pinned, + }), ); translator.changes.addAll(changes); } @@ -189,12 +201,11 @@ void main() { final exitCode = await runDoctor(); expect(exitCode, ExitCode.success.code); - verifyInfo('✓ Plugins'); + verifyInfo('✓ Shared'); + verifyInfo('✓ Codex'); for (final plugin in vgvPlugins) { verifyInfo(' ${plugin.name} ${plugin.version}'); } - verifyInfo('✓ Shared'); - verifyInfo('✓ Codex'); }); test('reports nothing installed when there is no manifest', () async { @@ -639,7 +650,7 @@ void main() { void installWith(Map plugins) { install([owned('.codex/agents/a.toml', harness: Harness.codex)]); - store.save(store.load()!.withPlugins(plugins)); + store.save(store.load()!.withPlugins({Harness.codex: plugins})); } test('an older recorded version exits 70 with the install fix', () async { @@ -648,7 +659,7 @@ void main() { final exitCode = await runDoctor(); expect(exitCode, ExitCode.software.code); - verifyInfo('✗ Plugins'); + verifyInfo('✗ Codex'); verifyInfo( ' ${wingspan.name} 0.0.1 installed, ${wingspan.version} pinned', ); @@ -668,41 +679,179 @@ void main() { verifyInfo(' Run vgv_ai install --only codex'); }); - test('a pinned plugin that was never recorded exits 70', () async { + test('a pinned plugin that was never installed is only noted', () async { installWith({}); final exitCode = await runDoctor(); - expect(exitCode, ExitCode.software.code); + expect(exitCode, ExitCode.success.code); + verifyInfo(' ${wingspan.name} ${wingspan.version} not installed'); + // Nothing was recorded, so every pinned plugin is still checked. + expect(resolver.resolved, [ + {for (final plugin in vgvPlugins) plugin.name}, + ]); + }); + + test( + 'only the plugins a harness was installed from are checked', + () async { + installWith({wingspan.name: wingspan.version}); + + await runDoctor(); + + expect(resolver.resolved, [ + {wingspan.name}, + ]); + }, + ); + + test( + 'harnesses installed from different plugins are planned apart', + () async { + install([ + owned('.codex/agents/a.toml', harness: Harness.codex), + owned('.gemini/agents/a.md', harness: Harness.gemini), + ]); + store.save( + store.load()!.withPlugins({ + Harness.codex: {wingspan.name: wingspan.version}, + Harness.gemini: pinned, + }), + ); + + await runDoctor(); + + expect( + resolver.resolved, + unorderedEquals([ + {wingspan.name}, + {for (final plugin in vgvPlugins) plugin.name}, + ]), + ); + }, + ); + + test( + 'the fix names the plugins the harness was installed from', + () async { + install([owned('.codex/agents/a.toml', harness: Harness.codex)]); + store.save( + store.load()!.withPlugins({ + Harness.codex: {wingspan.name: '0.0.1'}, + }), + ); + + await runDoctor(); + + verifyInfo( + ' Run vgv_ai install --only codex --plugin ${wingspan.name}', + ); + }, + ); + + test('are reported under each harness they were installed for', () async { + install([ + owned('.codex/agents/a.toml', harness: Harness.codex), + owned('.gemini/agents/a.md', harness: Harness.gemini), + ]); + store.save( + store.load()!.withPlugins({ + Harness.codex: {...pinned, wingspan.name: '0.0.1'}, + Harness.gemini: pinned, + }), + ); + + await runDoctor(); + + verifyInfo('✗ Codex'); verifyInfo( - ' ${wingspan.name} not recorded, ${wingspan.version} pinned', + ' ${wingspan.name} 0.0.1 installed, ${wingspan.version} pinned', + ); + verifyInfo(' Run vgv_ai install --only codex'); + verifyInfo('✓ Gemini'); + }); + + test('the shared fix names the plugins its readers share', () async { + install([ + owned('.agents/skills/a/SKILL.md'), + owned('.codex/agents/a.toml', harness: Harness.codex), + ]); + store.save( + store.load()!.withPlugins({ + Harness.codex: {wingspan.name: wingspan.version}, + }), + ); + homeFile('.agents/skills/a/SKILL.md').deleteSync(); + + await runDoctor(); + + verifyInfo(' missing ~/.agents/skills/a/SKILL.md'); + verifyInfo( + ' Run vgv_ai install --only codex --plugin ${wingspan.name}', + ); + }); + + test('the shared fix is left out when its readers differ', () async { + install([ + owned('.agents/skills/a/SKILL.md'), + owned('.codex/agents/a.toml', harness: Harness.codex), + owned('.gemini/agents/a.md', harness: Harness.gemini), + ]); + store.save( + store.load()!.withPlugins({ + Harness.codex: {wingspan.name: wingspan.version}, + Harness.gemini: pinned, + }), + ); + homeFile('.agents/skills/a/SKILL.md').deleteSync(); + + await runDoctor(); + + verifyInfo(' missing ~/.agents/skills/a/SKILL.md'); + verifyNever( + () => + logger.info(' Run vgv_ai install --only gemini --only codex'), ); }); test( - 'are fixed by installing every configured harness in scope', + 'nothing closes the report when the shared fix is left out', () async { install([ + owned('.agents/skills/a/SKILL.md'), owned('.codex/agents/a.toml', harness: Harness.codex), owned('.gemini/agents/a.md', harness: Harness.gemini), - owned('.pi/agent/a.md', harness: Harness.pi), ]); - store.save(store.load()!.withPlugins({})); + store.save( + store.load()!.withPlugins({ + Harness.codex: {wingspan.name: wingspan.version}, + Harness.gemini: pinned, + }), + ); + homeFile('.agents/skills/a/SKILL.md').deleteSync(); + homeFile('.codex/agents/a.toml').deleteSync(); - await runDoctor(args: ['--no-pi']); + await runDoctor(); - verifyInfo(' Run vgv_ai install --only gemini --only codex'); + verifyInfo( + ' Run vgv_ai install --only codex --plugin ${wingspan.name}', + ); + verifyNever( + () => logger.info( + any(that: endsWith('to fix everything install can.')), + ), + ); }, ); - test('are not reported when nothing in scope is configured', () async { + test('are not reported for a harness VGV has not configured', () async { installWith({}); detect(const [Harness.codex, Harness.pi]); final exitCode = await runDoctor(args: ['--only', 'pi']); expect(exitCode, ExitCode.success.code); - verifyNever(() => logger.info(any(that: endsWith('Plugins')))); + verifyNever(() => logger.info(any(that: contains(wingspan.name)))); }); }); diff --git a/packages/vgv_ai_cli/test/src/commands/doctor/model/fix_command_test.dart b/packages/vgv_ai_cli/test/src/commands/doctor/model/fix_command_test.dart index 92a1421b..66b4ea86 100644 --- a/packages/vgv_ai_cli/test/src/commands/doctor/model/fix_command_test.dart +++ b/packages/vgv_ai_cli/test/src/commands/doctor/model/fix_command_test.dart @@ -1,3 +1,4 @@ +import 'package:plugin_source/plugin_source.dart'; import 'package:test/test.dart'; import 'package:vgv_ai_cli/src/commands/doctor/model/model.dart'; import 'package:vgv_ai_cli/src/model/model.dart'; @@ -18,6 +19,19 @@ void main() { ); }); + test('names each plugin with --plugin, in pinned order', () { + expect( + FixCommand( + harnesses: const {Harness.codex}, + plugins: {for (final plugin in vgvPlugins) plugin.name}, + ).toString(), + [ + 'vgv_ai install --only codex', + for (final plugin in vgvPlugins) '--plugin ${plugin.name}', + ].join(' '), + ); + }); + group('combine', () { test('installs every harness any fix names', () { expect( @@ -40,6 +54,36 @@ void main() { ); }); + test('keeps the plugins every fix installs', () { + expect( + FixCommand.combine([ + FixCommand( + harnesses: const {Harness.codex}, + plugins: {vgvPlugins.first.name}, + ), + FixCommand( + harnesses: const {Harness.pi}, + plugins: {vgvPlugins.first.name}, + ), + ]).toString(), + 'vgv_ai install --only codex --only pi ' + '--plugin ${vgvPlugins.first.name}', + ); + }); + + test('is null when the fixes install different plugins', () { + expect( + FixCommand.combine([ + FixCommand( + harnesses: const {Harness.codex}, + plugins: {vgvPlugins.first.name}, + ), + const FixCommand(harnesses: {Harness.pi}), + ]), + isNull, + ); + }); + test('is null when there is nothing to fix', () { expect(FixCommand.combine(const []), isNull); expect(FixCommand.combine(const [null, null]), isNull); diff --git a/packages/vgv_ai_cli/test/src/commands/doctor/model/plugin_version_test.dart b/packages/vgv_ai_cli/test/src/commands/doctor/model/plugin_version_test.dart index 8c9519e9..e23b89cc 100644 --- a/packages/vgv_ai_cli/test/src/commands/doctor/model/plugin_version_test.dart +++ b/packages/vgv_ai_cli/test/src/commands/doctor/model/plugin_version_test.dart @@ -63,7 +63,7 @@ void main() { expect(version.status, PluginStatus.unrecorded); expect( version.finding, - isFinding(FindingKind.stale, 'wingspan not recorded, 0.2.0 pinned'), + isFinding(FindingKind.warning, 'wingspan 0.2.0 not installed'), ); }); }); diff --git a/packages/vgv_ai_cli/test/src/commands/doctor/model/report_section_test.dart b/packages/vgv_ai_cli/test/src/commands/doctor/model/report_section_test.dart index d3756eba..0aa9825a 100644 --- a/packages/vgv_ai_cli/test/src/commands/doctor/model/report_section_test.dart +++ b/packages/vgv_ai_cli/test/src/commands/doctor/model/report_section_test.dart @@ -75,6 +75,20 @@ void main() { ); }); + test('needs its own fix when no one command fixes what it found', () { + const unfixable = ReportSection( + title: 'Shared', + installs: {Harness.codex, Harness.gemini}, + findings: [Finding.missing('a')], + plugins: null, + ); + + expect(unfixable.fix, isNull); + expect(unfixable.needsItsOwnFix, isTrue); + expect(section(const [Finding.missing('a')]).needsItsOwnFix, isFalse); + expect(section(const []).needsItsOwnFix, isFalse); + }); + test('has no fix when install changes nothing it found', () { expect(section(const []).fix, isNull); expect( diff --git a/packages/vgv_ai_cli/test/src/commands/install_command_test.dart b/packages/vgv_ai_cli/test/src/commands/install_command_test.dart index b6116c47..81c39dea 100644 --- a/packages/vgv_ai_cli/test/src/commands/install_command_test.dart +++ b/packages/vgv_ai_cli/test/src/commands/install_command_test.dart @@ -246,6 +246,75 @@ void main() { }); }); + group('--plugin', () { + // The manifest is only written once something is, so every run here + // writes one file. + setUp(() { + translator.plan = TranslationPlan( + harnesses: { + Harness.claude: TranslationResult( + changes: [ownChange(Harness.claude, '.claude/a.md', 'a')], + ), + }, + ); + }); + + test('installs just the plugins it names', () async { + final exitCode = await runInstall([ + '--only', + 'claude', + '--plugin', + vgvPlugins.first.name, + ]); + + expect(exitCode, ExitCode.success.code); + expect(translator.assets?.plugins, [vgvPlugins.first]); + expect(ManifestStore(home).load()!.plugins, { + Harness.claude: {vgvPlugins.first.name: vgvPlugins.first.version}, + }); + }); + + test('keeps the recorded version of a plugin it skips', () async { + final skipped = vgvPlugins.last; + await runInstall(['--only', 'claude']); + final store = ManifestStore(home); + store.save( + store.load()!.withPlugins({ + Harness.claude: { + for (final plugin in vgvPlugins) plugin.name: plugin.version, + skipped.name: '0.0.1', + }, + }), + ); + + await runInstall([ + '--only', + 'claude', + '--plugin', + vgvPlugins.first.name, + ]); + + expect(store.load()!.plugins, { + Harness.claude: { + vgvPlugins.first.name: vgvPlugins.first.version, + skipped.name: '0.0.1', + }, + }); + }); + + test('rejects a plugin it does not pin', () async { + final exitCode = await runInstall([ + '--only', + 'claude', + '--plugin', + 'vgv-unknown', + ]); + + expect(exitCode, ExitCode.usage.code); + expect(translator.assets, isNull); + }); + }); + group('resolution', () { test('clones the plugins when there is no Claude Code cache', () async { final cloned = >[]; @@ -518,7 +587,9 @@ void main() { final manifest = ManifestStore(home).load()!; expect(manifest[homeFile('.claude/a.md').path], isA()); expect(manifest.plugins, { - for (final plugin in vgvPlugins) plugin.name: plugin.version, + Harness.claude: { + for (final plugin in vgvPlugins) plugin.name: plugin.version, + }, }); }); @@ -526,12 +597,18 @@ void main() { planOne(); await runInstall(['--only', 'claude']); final store = ManifestStore(home); - store.save(store.load()!.withPlugins(const {'vgv-wingspan': '0.0.1'})); + store.save( + store.load()!.withPlugins(const { + Harness.claude: {'vgv-wingspan': '0.0.1'}, + }), + ); await runInstall(['--only', 'claude']); expect(store.load()!.plugins, { - for (final plugin in vgvPlugins) plugin.name: plugin.version, + Harness.claude: { + for (final plugin in vgvPlugins) plugin.name: plugin.version, + }, }); }); diff --git a/packages/vgv_ai_cli/test/src/manifest/manifest_auditor_test.dart b/packages/vgv_ai_cli/test/src/manifest/manifest_auditor_test.dart index 3d786ee6..3883b16f 100644 --- a/packages/vgv_ai_cli/test/src/manifest/manifest_auditor_test.dart +++ b/packages/vgv_ai_cli/test/src/manifest/manifest_auditor_test.dart @@ -623,13 +623,35 @@ void main() { expect(manifest.file.readAsStringSync(), before); }); - test('keeps the plugin versions it records', () { + test('keeps the plugin versions of a harness it leaves alone', () { + const recorded = { + Harness.codex: {'vgv-wingspan': '0.0.5'}, + }; + install([ + owned(seed('.codex/a.md', 'a'), 'a', harness: Harness.codex), + ]); + + final result = auditor.reverse( + manifest.load()!.withPlugins(recorded), + fs, + owners: const {Harness.claude}, + ); + + expect(result.manifest.plugins, recorded); + }); + + test('forgets the plugin versions of a harness it empties', () { + install([owned(file('.codex/a.md'), 'a', harness: Harness.codex)]); + final result = auditor.reverse( - InstallManifest(const [], const {'vgv-wingspan': '0.0.5'}), + manifest.load()!.withPlugins(const { + Harness.codex: {'vgv-wingspan': '0.0.5'}, + }), fs, ); - expect(result.manifest.plugins, {'vgv-wingspan': '0.0.5'}); + expect(result.manifest.entries, isEmpty); + expect(result.manifest.plugins, isEmpty); }); test('does nothing with an empty manifest', () { diff --git a/packages/vgv_ai_cli/test/src/model/install_manifest_test.dart b/packages/vgv_ai_cli/test/src/model/install_manifest_test.dart index e1dee865..cc87bc8b 100644 --- a/packages/vgv_ai_cli/test/src/model/install_manifest_test.dart +++ b/packages/vgv_ai_cli/test/src/model/install_manifest_test.dart @@ -44,7 +44,10 @@ void main() { test('round trips every kind of entry, and the plugin versions', () { final manifest = InstallManifest( const [owned, merged, sections], - const {'vgv-wingspan': '0.0.5', 'vgv-ai-flutter-plugin': '0.0.6'}, + const { + Harness.claude: {'vgv-wingspan': '0.0.5'}, + Harness.codex: {'vgv-ai-flutter-plugin': '0.0.6'}, + }, ); final decoded = InstallManifest.decode( @@ -79,8 +82,8 @@ void main() { isA().having((e) => e.sha256, 'sha256', 'abc'), ); expect(decoded.plugins, { - 'vgv-wingspan': '0.0.5', - 'vgv-ai-flutter-plugin': '0.0.6', + Harness.claude: {'vgv-wingspan': '0.0.5'}, + Harness.codex: {'vgv-ai-flutter-plugin': '0.0.6'}, }); }); @@ -88,12 +91,16 @@ void main() { final json = jsonDecode( InstallManifest( const [owned, merged], - const {'vgv-wingspan': '0.0.5'}, + const { + Harness.codex: {'vgv-wingspan': '0.0.5'}, + }, ).encode(home: home), ) as Map; - expect(json['version'], 2); - expect(json['plugins'], {'vgv-wingspan': '0.0.5'}); + expect(json['version'], 1); + expect(json['plugins'], { + 'codex': {'vgv-wingspan': '0.0.5'}, + }); expect(json['entries'], [ { 'path': '.agents/skills/a/SKILL.md', @@ -123,7 +130,7 @@ void main() { expect( decode(jsonEncode({owned.path: 'hash'}))!.entries, isEmpty, - reason: 'the flat format before version 1 never shipped', + reason: 'a document with no version is not a manifest', ); }); @@ -139,9 +146,20 @@ void main() { expect(decode(null).plugins, isEmpty); expect(decode(['vgv-wingspan']).plugins, isEmpty); + expect(decode({'codex': 'vgv-wingspan'}).plugins, isEmpty); + expect( + decode({ + 'nope': {'vgv-wingspan': '0.0.5'}, + }).plugins, + isEmpty, + ); expect( - decode({'vgv-wingspan': 5, 'vgv-ai-flutter-plugin': '0.0.5'}).plugins, - {'vgv-ai-flutter-plugin': '0.0.5'}, + decode({ + 'codex': {'vgv-wingspan': 5, 'vgv-ai-flutter-plugin': '0.0.5'}, + }).plugins, + { + Harness.codex: {'vgv-ai-flutter-plugin': '0.0.5'}, + }, ); }); @@ -165,11 +183,12 @@ void main() { ); }); - test('reads a version 1 manifest as one with no plugin versions', () { + test('keeps the entries when it cannot read the plugin versions', () { final manifest = InstallManifest.decode( jsonEncode({ - 'version': 1, + 'version': InstallManifest.version, 'entries': [onDisk(owned)], + 'plugins': {'vgv-wingspan': '0.0.5'}, }), home: home, )!; @@ -248,31 +267,46 @@ void main() { const updated = OwnedFile(path: '/x', owner: null, sha256: 'new'); final manifest = InstallManifest( const [OwnedFile(path: '/x', owner: null, sha256: 'old'), merged], - const {'vgv-wingspan': '0.0.5'}, + const { + Harness.codex: {'vgv-wingspan': '0.0.5'}, + }, ).record(updated); expect(manifest.entries, [updated, merged]); - expect(manifest.plugins, {'vgv-wingspan': '0.0.5'}); + expect(manifest.plugins, { + Harness.codex: {'vgv-wingspan': '0.0.5'}, + }); }); test('drops the entries for the given paths', () { final manifest = InstallManifest( const [owned, merged, sections], - const {'vgv-wingspan': '0.0.5'}, + const { + Harness.codex: {'vgv-wingspan': '0.0.5'}, + }, ).without([owned.path, sections.path]); expect(manifest.entries, [merged]); - expect(manifest.plugins, {'vgv-wingspan': '0.0.5'}); + expect(manifest.plugins, { + Harness.codex: {'vgv-wingspan': '0.0.5'}, + }); }); test('replaces the plugin versions, keeping the entries', () { - final manifest = InstallManifest( - const [owned], - const {'vgv-wingspan': '0.0.4'}, - ).withPlugins(const {'vgv-wingspan': '0.0.5'}); + final manifest = + InstallManifest( + const [owned], + const { + Harness.codex: {'vgv-wingspan': '0.0.4'}, + }, + ).withPlugins(const { + Harness.codex: {'vgv-wingspan': '0.0.5'}, + }); expect(manifest.entries, [owned]); - expect(manifest.plugins, {'vgv-wingspan': '0.0.5'}); + expect(manifest.plugins, { + Harness.codex: {'vgv-wingspan': '0.0.5'}, + }); }); test('equals a manifest with the same entries, in the same order', () { @@ -293,8 +327,20 @@ void main() { ), ); expect( - InstallManifest(const [owned], const {'vgv-wingspan': '0.0.5'}), - isNot(InstallManifest(const [owned], const {'vgv-wingspan': '0.0.4'})), + InstallManifest( + const [owned], + const { + Harness.codex: {'vgv-wingspan': '0.0.5'}, + }, + ), + isNot( + InstallManifest( + const [owned], + const { + Harness.codex: {'vgv-wingspan': '0.0.4'}, + }, + ), + ), ); }); diff --git a/packages/vgv_ai_cli/test/src/resolve/plugin_resolver_test.dart b/packages/vgv_ai_cli/test/src/resolve/plugin_resolver_test.dart index e444d5c3..31796fe6 100644 --- a/packages/vgv_ai_cli/test/src/resolve/plugin_resolver_test.dart +++ b/packages/vgv_ai_cli/test/src/resolve/plugin_resolver_test.dart @@ -131,7 +131,7 @@ void main() { expect( resolution, isA() - .having((r) => r.plugins, 'plugins', [wingspan, flutter]) + .having((r) => r.assets.plugins, 'plugins', [wingspan, flutter]) .having( (r) => r.assets.agents.map((agent) => agent.name), 'agents', @@ -271,6 +271,25 @@ void main() { ); }); + test('resolves only the plugins `only` names', () async { + cache(wingspan); + final resolver = PluginResolver( + runProcess: processes.never, + parser: parser, + plugins: const [wingspan, flutter], + ); + + final resolution = await resolver.resolve(home, only: {wingspan.name}); + + expect(parser.roots, [cached(wingspan)]); + expect( + resolution, + isA().having((r) => r.assets.plugins, 'plugins', [ + wingspan, + ]), + ); + }); + test('resolves the pinned VGV plugins by default', () async { vgvPlugins.forEach(cache); final resolver = PluginResolver( @@ -282,7 +301,11 @@ void main() { expect( resolution, - isA().having((r) => r.plugins, 'plugins', vgvPlugins), + isA().having( + (r) => r.assets.plugins, + 'plugins', + vgvPlugins, + ), ); }); }); diff --git a/packages/vgv_ai_cli/test/src/translate/_assets.dart b/packages/vgv_ai_cli/test/src/translate/_assets.dart index d1b3335f..5c1edf54 100644 --- a/packages/vgv_ai_cli/test/src/translate/_assets.dart +++ b/packages/vgv_ai_cli/test/src/translate/_assets.dart @@ -73,11 +73,13 @@ PluginAssets pluginAssets({ List agents = const [], List mcpServers = const [], List hooks = const [], + List plugins = const [], }) => PluginAssets( skills: skills, agents: agents, mcpServers: mcpServers, hooks: hooks, + plugins: plugins, ); /// Parses a rendered `---` fenced frontmatter block back into its values. diff --git a/packages/vgv_ai_cli/test/src/translate/translators/claude_translator_test.dart b/packages/vgv_ai_cli/test/src/translate/translators/claude_translator_test.dart index 3d9303bf..fc422bff 100644 --- a/packages/vgv_ai_cli/test/src/translate/translators/claude_translator_test.dart +++ b/packages/vgv_ai_cli/test/src/translate/translators/claude_translator_test.dart @@ -1,6 +1,7 @@ import 'dart:convert'; import 'package:file/memory.dart'; +import 'package:plugin_source/plugin_source.dart'; import 'package:test/test.dart'; import 'package:vgv_ai_cli/src/model/model.dart'; import 'package:vgv_ai_cli/src/translate/translate.dart'; @@ -16,7 +17,9 @@ void main() { }); test('points Claude Code at the marketplace and enables both plugins', () { - final settings = jsonDecode(translator.settings(pluginAssets())); + final settings = jsonDecode( + translator.settings(pluginAssets(plugins: vgvPlugins)), + ); expect(settings, { 'extraKnownMarketplaces': { @@ -34,6 +37,17 @@ void main() { }); }); + test('enables only the plugins the assets came from', () { + final wingspan = vgvPlugins.first; + final settings = jsonDecode( + translator.settings(pluginAssets(plugins: [wingspan])), + ) as Map; + + expect(settings['enabledPlugins'], { + '${wingspan.name}@very-good-claude-code-marketplace': true, + }); + }); + test('spells no agents or commands, which the marketplace supplies', () { final fs = MemoryFileSystem.test();