Skip to content
3 changes: 2 additions & 1 deletion packages/plugin_source/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,8 @@ Future<void> 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

Expand Down
13 changes: 11 additions & 2 deletions packages/plugin_source/lib/src/model/plugin_assets.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -18,19 +19,27 @@ class const PluginAssets({

/// The hooks, in the order the plugins declare them.
final List<HookAsset> hooks = const [],

/// The plugins these come from, which the harness that installs them
/// itself names in its settings.
final List<PluginRef> 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<PluginAssets> assets) => PluginAssets(
factory merge(
Iterable<PluginAssets> assets, {
List<PluginRef> 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.
Expand Down
23 changes: 23 additions & 0 deletions packages/plugin_source/test/src/model/plugin_assets_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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', () {
Expand Down Expand Up @@ -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 []);

Expand Down
9 changes: 8 additions & 1 deletion packages/vgv_ai_cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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-<harness>` 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

Expand All @@ -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.

Expand Down
173 changes: 116 additions & 57 deletions packages/vgv_ai_cli/lib/src/commands/doctor/doctor_command.dart
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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('')
Expand Down Expand Up @@ -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<ContentPlan> _plan(
InstallManifest manifest,
Directory home,
Set<Harness> 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 = <AppliedChange>[];

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<String>, Set<Harness>> _byPlugins(
InstallManifest manifest,
Set<Harness> selected,
) {
final groups = EqualityMap<Set<String>, Set<Harness>>(_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<Set<String>> _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<String> _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
Expand All @@ -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.
Expand All @@ -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',
Comment thread
ryzizub marked this conversation as resolved.
Resolved
installs: readsShared,
plugins: _shared(manifest, readsShared),
findings: _entryFindings(null, manifest, home, plan),
),
for (final harness in Harness.values)
Expand All @@ -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<Harness> 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(
Expand All @@ -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 <String, String>{};
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<String>? _shared(InstallManifest manifest, Set<Harness> readers) {
final selections = EqualitySet<Set<String>>(_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<String> _selected(Map<String, String> 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.
Expand Down
Original file line number Diff line number Diff line change
@@ -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}
Expand All @@ -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<String> 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<FixCommand?> fixes) {
final all = fixes.nonNulls;
final all = fixes.nonNulls.toList();
Comment thread
ryzizub marked this conversation as resolved.
Resolved
if (all.isEmpty) return null;

final plugins = EqualitySet<Set<String>>(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,
);
}

Expand All @@ -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(' ');
}
Loading
Loading