Allow adding from multiple sources (#3571)

And having some packages be dev_dependencies while others are not.
By having:

each package carry its own (yaml-formatted) descriptor as it would be written in pubspec.yaml.
dev: be a prefix to the package instead of a top-level argument.
This allows eg.:

dart pub add foo:^1.2.3  'dev:bar:{"git":"../bar.git"}
This is a more full resolution to #3273 than #3324 but implemented in a backwards compatible way (the old arguments still work as long as they are not combined with descriptors (other than bare constraints)).

If a constraint is not given explicitly, either as foo:^1.2.3 or foo:{<source>:<descriptor>,"version":"<constraint"} the "best" constraint is inferred and inserted as before (instead of being interpreted as 'any' as it would in a pubspec.yaml).
diff --git a/lib/src/command/add.dart b/lib/src/command/add.dart
index 15b64a4..844f389 100644
--- a/lib/src/command/add.dart
+++ b/lib/src/command/add.dart
@@ -2,7 +2,8 @@
 // for details. All rights reserved. Use of this source code is governed by a
 // BSD-style license that can be found in the LICENSE file.
 
-import 'package:collection/collection.dart' show IterableExtension;
+import 'package:args/args.dart';
+import 'package:collection/collection.dart';
 import 'package:path/path.dart' as p;
 import 'package:pub_semver/pub_semver.dart';
 import 'package:yaml/yaml.dart';
@@ -13,7 +14,6 @@
 import '../exceptions.dart';
 import '../git.dart';
 import '../io.dart';
-import '../language_version.dart';
 import '../log.dart' as log;
 import '../package.dart';
 import '../package_name.dart';
@@ -31,54 +31,95 @@
 /// the other dependencies in `pubspec.yaml`, and then enter that as the lower
 /// bound in a ^x.y.z constraint.
 ///
-/// Currently supports only adding one dependency at a time.
+/// The descriptor used to be given with args like --path, --sdk,
+/// --git-<option>.
+///
+/// We still support these arguments, but now the documented way to give the
+/// descriptor is to give a yaml-descriptor as in pubspec.yaml.
 class AddCommand extends PubCommand {
   @override
   String get name => 'add';
   @override
-  String get description => 'Add dependencies to pubspec.yaml.';
+  String get description => r'''
+Add dependencies to `pubspec.yaml`.
+
+Invoking `dart pub add foo bar` will add `foo` and `bar` to `pubspec.yaml`
+with a default constraint derived from latest compatible version.
+
+Add to dev_dependencies by prefixing with "dev:".
+
+Add packages with specific constraints or other sources by giving a descriptor
+after a colon.
+
+For example:
+  * Add a hosted dependency at newest compatible stable version:
+    `$topLevelProgram pub add foo`
+  * Add a hosted dev dependency at newest compatible stable version:
+    `$topLevelProgram pub add dev:foo`
+  * Add a hosted dependency with the given constraint
+    `$topLevelProgram pub add foo:^1.2.3`
+  * Add multiple dependencies:
+    `$topLevelProgram pub add foo dev:bar`
+  * Add a path dependency:
+    `$topLevelProgram pub add 'foo{"path":"../foo"}'`
+  * Add a hosted dependency:
+    `$topLevelProgram pub add 'foo{"hosted":"my-pub.dev"}'`
+  * Add an sdk dependency:
+    `$topLevelProgram pub add 'foo{"sdk":"flutter"}'`
+  * Add a git dependency:
+    `$topLevelProgram pub add 'foo{"git":"https://github.com/foo/foo"}'`
+  * Add a git dependency with a path and ref specified:
+    `$topLevelProgram pub add \
+      'foo{"git":{"url":"../foo.git","ref":"branch","path":"subdir"}}'`''';
+
   @override
   String get argumentsDescription =>
-      '<package>[:<constraint>] [<package2>[:<constraint2>]...] [options]';
+      '[options] [dev:]<package>[:descriptor] [[dev:]<package>[:descriptor] ...]';
   @override
   String get docUrl => 'https://dart.dev/tools/pub/cmd/pub-add';
-  @override
-  bool get isOffline => argResults['offline'];
-
-  bool get isDev => argResults['dev'];
-  bool get isDryRun => argResults['dry-run'];
-  String? get gitUrl => argResults['git-url'];
-  String? get gitPath => argResults['git-path'];
-  String? get gitRef => argResults['git-ref'];
-  String? get hostUrl => argResults['hosted-url'];
-  String? get path => argResults['path'];
-  String? get sdk => argResults['sdk'];
-
-  bool get hasGitOptions => gitUrl != null || gitRef != null || gitPath != null;
-  bool get hasHostOptions => hostUrl != null;
-
-  bool get isHosted => !hasGitOptions && path == null && path == null;
 
   AddCommand() {
-    argParser.addFlag('dev',
-        abbr: 'd',
-        negatable: false,
-        help: 'Adds to the development dependencies instead.');
-
-    argParser.addOption('git-url', help: 'Git URL of the package');
-    argParser.addOption('git-ref',
-        help: 'Git branch or commit to be retrieved');
-    argParser.addOption('git-path', help: 'Path of git package in repository');
-    argParser.addOption('hosted-url', help: 'URL of package host server');
-    argParser.addOption('path', help: 'Add package from local path');
-    argParser.addOption('sdk',
-        help: 'add package from SDK source',
-        allowed: ['flutter'],
-        valueHelp: '[flutter]');
     argParser.addFlag(
-      'example',
-      help:
-          'Also update dependencies in `example/` after modifying pubspec.yaml in the root package (if it exists).',
+      'dev',
+      abbr: 'd',
+      negatable: false,
+      help: 'Adds to the development dependencies instead.',
+      hide: true,
+    );
+
+    // Following options are hidden/deprecated in favor of the new syntax: [dev:]<package>[:descriptor] ...
+    // To avoid breaking changes we keep supporting them, but hide them from --help to discourage
+    // further use. Combining these with new syntax will fail.
+    argParser.addOption(
+      'git-url',
+      help: 'Git URL of the package',
+      hide: true,
+    );
+    argParser.addOption(
+      'git-ref',
+      help: 'Git branch or commit to be retrieved',
+      hide: true,
+    );
+    argParser.addOption(
+      'git-path',
+      help: 'Path of git package in repository',
+      hide: true,
+    );
+    argParser.addOption(
+      'hosted-url',
+      help: 'URL of package host server',
+      hide: true,
+    );
+    argParser.addOption(
+      'path',
+      help: 'Add package from local path',
+      hide: true,
+    );
+    argParser.addOption(
+      'sdk',
+      help: 'add package from SDK source',
+      allowed: ['flutter'],
+      valueHelp: '[flutter]',
       hide: true,
     );
 
@@ -94,20 +135,37 @@
         help: 'Build executables in immediate dependencies.');
     argParser.addOption('directory',
         abbr: 'C', help: 'Run this in the directory <dir>.', valueHelp: 'dir');
+    argParser.addFlag(
+      'example',
+      help:
+          'Also update dependencies in `example/` after modifying pubspec.yaml in the root package (if it exists).',
+      hide: true,
+    );
   }
 
   @override
   Future<void> runProtected() async {
+    if (argResults.rest.length > 1) {
+      if (argResults.gitUrl != null) {
+        usageException('''
+--git-url cannot be used with multiple packages.
+Specify multiple git packages with descriptors.''');
+      } else if (argResults.path != null) {
+        usageException('''
+--path cannot be used with multiple packages.
+Specify multiple path packages with descriptors.''');
+      } else if (argResults.sdk != null) {
+        usageException('''
+--sdk cannot be used with multiple packages.
+Specify multiple sdk packages with descriptors.''');
+      }
+    }
     if (argResults.rest.isEmpty) {
       usageException('Must specify at least one package to be added.');
-    } else if (argResults.rest.length > 1 && gitUrl != null) {
-      usageException('Can only add a single git package at a time.');
-    } else if (argResults.rest.length > 1 && path != null) {
-      usageException('Can only add a single local package at a time.');
     }
-    final languageVersion = entrypoint.root.pubspec.languageVersion;
+
     final updates =
-        argResults.rest.map((p) => _parsePackage(p, languageVersion)).toList();
+        argResults.rest.map((p) => _parsePackage(p, argResults)).toList();
 
     var updatedPubSpec = entrypoint.root.pubspec;
     for (final update in updates) {
@@ -154,7 +212,7 @@
         }
       }
     }
-    if (isDryRun) {
+    if (argResults.isDryRun) {
       /// Even if it is a dry run, run `acquireDependencies` so that the user
       /// gets a report on the other packages that might change version due
       /// to this new dependency.
@@ -165,7 +223,7 @@
           .acquireDependencies(
         SolveType.get,
         dryRun: true,
-        precompile: argResults['precompile'],
+        precompile: argResults.shouldPrecompile,
         analytics: analytics,
       );
     } else {
@@ -173,21 +231,24 @@
       /// ensure that the modification timestamp on `pubspec.lock` and
       /// `.dart_tool/package_config.json` is newer than `pubspec.yaml`,
       /// ensuring that [entrypoint.assertUptoDate] will pass.
-      _updatePubspec(solveResult.packages, updates, isDev);
+      _updatePubspec(
+        solveResult.packages,
+        updates,
+      );
 
       /// Create a new [Entrypoint] since we have to reprocess the updated
       /// pubspec file.
       final updatedEntrypoint = Entrypoint(directory, cache);
       await updatedEntrypoint.acquireDependencies(
         SolveType.get,
-        precompile: argResults['precompile'],
+        precompile: argResults.shouldPrecompile,
         analytics: analytics,
       );
 
-      if (argResults['example'] && entrypoint.example != null) {
+      if (argResults.example && entrypoint.example != null) {
         await entrypoint.example!.acquireDependencies(
           SolveType.get,
-          precompile: argResults['precompile'],
+          precompile: argResults.shouldPrecompile,
           onlyReportSuccessOrFailure: true,
           analytics: analytics,
         );
@@ -203,7 +264,9 @@
   /// Creates a new in-memory [Pubspec] by adding [package] to the
   /// dependencies of [original].
   Future<Pubspec> _addPackageToPubspec(
-      Pubspec original, _ParseResult package) async {
+    Pubspec original,
+    _ParseResult package,
+  ) async {
     final name = package.ref.name;
     final dependencies = [...original.dependencies.values];
     var devDependencies = [...original.devDependencies.values];
@@ -212,7 +275,7 @@
         devDependencies.map((devDependency) => devDependency.name);
     final range =
         package.ref.withConstraint(package.constraint ?? VersionConstraint.any);
-    if (isDev) {
+    if (package.isDev) {
       if (devDependencyNames.contains(name)) {
         log.message('"$name" is already in "dev_dependencies". '
             'Will try to update the constraint.');
@@ -259,12 +322,45 @@
     );
   }
 
-  /// Parse [package] to return the corresponding [PackageRange], as well as its
-  /// representation in `pubspec.yaml`.
+  /// Split [arg] on ':' and interpret it with the flags in [argResult] either as
+  /// an old-style or a new-style descriptor to produce a PackageRef].
+  _ParseResult _parsePackage(String arg, ArgResults argResults) {
+    var isDev = argResults['dev'] as bool;
+    if (arg.startsWith('dev:')) {
+      if (argResults.isDev) {
+        usageException("Cannot combine 'dev:' with --dev");
+      }
+      isDev = true;
+      arg = arg.substring('dev:'.length);
+    }
+    final nextColon = arg.indexOf(':');
+    final packageName = nextColon == -1 ? arg : arg.substring(0, nextColon);
+    if (!packageNameRegExp.hasMatch(packageName)) {
+      usageException('Not a valid package name: "$packageName"');
+    }
+    final descriptor = nextColon == -1 ? null : arg.substring(nextColon + 1);
+
+    final _PartialParseResult partial;
+    if (argResults.hasOldStyleOptions) {
+      partial = _parseDescriptorOldStyleArgs(
+        packageName,
+        descriptor,
+        argResults,
+      );
+    } else {
+      partial = _parseDescriptorNewStyle(packageName, descriptor);
+    }
+
+    return _ParseResult(partial.ref, partial.constraint, isDev: isDev);
+  }
+
+  /// Parse [descriptor] to return the corresponding [_ParseResult] using the
+  /// arguments given in [argResults] to configure the description.
   ///
-  /// [package] must be written in the format
-  /// `<package-name>[:<version-constraint>]`, where quotations should be used
-  /// if necessary.
+  /// [descriptor] should be a constraint as parsed by
+  /// [VersionConstraint.parse]. If it fails to parse as a version constraint
+  /// but could parse with [_parseDescriptorNewStyle()] a specific usage
+  /// description is issued.
   ///
   /// Examples:
   /// ```
@@ -278,14 +374,20 @@
   /// ```
   ///
   /// If a version constraint is provided when the `--path` or any of the
-  /// `--git-<option>` options are used, a [PackageParseError] will be thrown.
+  /// `--git-<option>` options are used, a [UsageException] will be thrown.
   ///
   /// Packages must either be a git, hosted, sdk, or path package. Mixing of
-  /// options is not allowed and will cause a [PackageParseError] to be thrown.
+  /// options is not allowed and will cause a [UsageException] to be thrown.
   ///
   /// If any of the other git options are defined when `--git-url` is not
   /// defined, an error will be thrown.
-  _ParseResult _parsePackage(String package, LanguageVersion languageVersion) {
+  ///
+  /// The returned [_PartialParseResult] will always have `ref!=null`.
+  _PartialParseResult _parseDescriptorOldStyleArgs(
+    String packageName,
+    String? descriptor,
+    ArgResults argResults,
+  ) {
     final conflictingFlagSets = [
       ['git-url', 'git-ref', 'git-path'],
       ['hosted-url'],
@@ -306,31 +408,34 @@
       }
     }
 
-    final splitPackage = package.split(':');
-    final packageName = splitPackage[0];
-
-    /// There shouldn't be more than one `:` in the package information
-    if (splitPackage.length > 2) {
-      usageException('Invalid package and version constraint: $package');
-    }
-
     /// We want to allow for [constraint] to take on a `null` value here to
     /// preserve the fact that the user did not specify a constraint.
     VersionConstraint? constraint;
-
     try {
-      constraint = splitPackage.length == 2
-          ? VersionConstraint.parse(splitPackage[1])
-          : null;
+      constraint =
+          descriptor == null ? null : VersionConstraint.parse(descriptor);
     } on FormatException catch (e) {
-      usageException('Invalid version constraint: ${e.message}');
+      var couldParseAsNewStyle = true;
+      try {
+        _parseDescriptorNewStyle(packageName, descriptor);
+        // If parsing the descriptor as a new-style descriptor succeeds we
+        // can give this more specific error message.
+      } catch (_) {
+        couldParseAsNewStyle = false;
+      }
+      if (couldParseAsNewStyle) {
+        usageException(
+            '--dev, --path, --sdk, --git-url, --git-path and --git-ref cannot be combined with a descriptor.');
+      } else {
+        usageException('Invalid version constraint: ${e.message}');
+      }
     }
 
     /// The package to be added.
     late final PackageRef ref;
-    final path = this.path;
-    if (hasGitOptions) {
-      final gitUrl = this.gitUrl;
+    final path = argResults.path;
+    if (argResults.hasGitOptions) {
+      final gitUrl = argResults.gitUrl;
       if (gitUrl == null) {
         usageException('The `--git-url` is required for git dependencies.');
       }
@@ -349,37 +454,141 @@
         GitDescription(
           url: parsed.toString(),
           containingDir: p.current,
-          ref: gitRef,
-          path: gitPath,
+          ref: argResults.gitRef,
+          path: argResults.gitPath,
         ),
       );
     } else if (path != null) {
       ref = PackageRef(
           packageName, PathDescription(p.absolute(path), p.isRelative(path)));
-    } else if (sdk != null) {
-      ref = cache.sdk.parseRef(packageName, sdk);
+    } else if (argResults.sdk != null) {
+      ref = cache.sdk.parseRef(packageName, argResults.sdk);
     } else {
       ref = PackageRef(
         packageName,
         HostedDescription(
           packageName,
-          hostUrl ?? cache.hosted.defaultUrl,
+          argResults.hostedUrl ?? cache.hosted.defaultUrl,
         ),
       );
     }
-    return _ParseResult(ref, constraint);
+    return _PartialParseResult(ref, constraint);
+  }
+
+  /// Parse [package] to return the corresponding [_ParseResult].
+  ///
+  /// [package] must be written in the format
+  /// `<package-name>[:descriptor>]`, where quotations should be used if
+  /// necessary.
+  ///
+  /// `descriptor` is what you would put in a pubspec.yaml in the dependencies
+  /// section.
+  ///
+  /// Assumes that none of '--git-url', '--git-ref', '--git-path', '--path' and
+  /// '--sdk' are present in [argResults].
+  ///
+  ///
+  /// Examples:
+  /// ```
+  /// retry
+  /// retry:2.0.0
+  /// dev:retry:^2.0.0
+  /// retry:'>=2.0.0'
+  /// retry:'>2.0.0 <3.0.1'
+  /// 'retry:>2.0.0 <3.0.1'
+  /// retry:any
+  /// 'retry:{"path":"../foo"}'
+  /// 'retry:{"git":{"url":"../foo","ref":"branchname"},"version":"^1.2.3"}'
+  /// 'retry:{"sdk":"flutter"}'
+  /// 'retry:{"hosted":"mypub.dev"}'
+  /// ```
+  ///
+  /// The --path --sdk and --git-<option> arguments cannot be combined with a
+  /// non-string descriptor.
+  ///
+  /// If a version constraint is provided when the `--path` or any of the
+  /// `--git-<option>` options are used, a [PackageParseError] will be thrown.
+  ///
+  /// Packages must either be a git, hosted, sdk, or path package. Mixing of
+  /// options is not allowed and will cause a [PackageParseError] to be thrown.
+  ///
+  /// If any of the other git options are defined when `--git-url` is not
+  /// defined, an error will be thrown.
+  ///
+  /// Returns a `ref` of `null` if the descriptor did not specify a source.
+  /// Then the source will be determined by the old-style arguments.
+  _PartialParseResult _parseDescriptorNewStyle(
+    String packageName,
+    String? descriptor,
+  ) {
+    /// We want to allow for [constraint] to take on a `null` value here to
+    /// preserve the fact that the user did not specify a constraint.
+    VersionConstraint? constraint;
+
+    /// The package to be added.
+    PackageRef? ref;
+
+    if (descriptor != null) {
+      try {
+        // An unquoted version constraint is not always valid yaml.
+        // But we want to allow it here anyways.
+        constraint = VersionConstraint.parse(descriptor);
+      } on FormatException {
+        final parsedDescriptor = loadYaml(descriptor);
+        // Use the pubspec parsing mechanism for parsing the descriptor.
+        final Pubspec dummyPubspec;
+        try {
+          dummyPubspec = Pubspec.fromMap({
+            'dependencies': {
+              packageName: parsedDescriptor,
+            }
+          }, cache.sources,
+              // Resolve relative paths relative to current, not where the pubspec.yaml is.
+              location: p.toUri(p.join(p.current, 'descriptor')));
+        } on FormatException catch (e) {
+          usageException('Failed parsing package specification: ${e.message}');
+        }
+        final range = dummyPubspec.dependencies[packageName]!;
+        if (parsedDescriptor is String) {
+          // Ref will be constructed by the default behavior below.
+          ref = null;
+        } else {
+          ref = range.toRef();
+        }
+        final hasExplicitConstraint = parsedDescriptor is String ||
+            (parsedDescriptor is Map &&
+                parsedDescriptor.containsKey('version'));
+        // If the descriptor has an explicit constraint, use that. Otherwise we
+        // infer it.
+        if (hasExplicitConstraint) {
+          constraint = range.constraint;
+        }
+      }
+    }
+    return _PartialParseResult(
+      ref ??
+          PackageRef(
+            packageName,
+            HostedDescription(
+              packageName,
+              argResults.hostedUrl ?? cache.hosted.defaultUrl,
+            ),
+          ),
+      constraint,
+    );
   }
 
   /// Writes the changes to the pubspec file.
-  void _updatePubspec(List<PackageId> resultPackages,
-      List<_ParseResult> updates, bool isDevelopment) {
+  void _updatePubspec(
+    List<PackageId> resultPackages,
+    List<_ParseResult> updates,
+  ) {
     final yamlEditor = YamlEditor(readTextFile(entrypoint.pubspecPath));
     log.io('Reading ${entrypoint.pubspecPath}.');
     log.fine('Contents:\n$yamlEditor');
 
-    final dependencyKey = isDevelopment ? 'dev_dependencies' : 'dependencies';
-
     for (final update in updates) {
+      final dependencyKey = update.isDev ? 'dev_dependencies' : 'dependencies';
       final constraint = update.constraint;
       final ref = update.ref;
       final name = ref.name;
@@ -420,7 +629,7 @@
 
       /// Remove the package from dev_dependencies if we are adding it to
       /// dependencies. Refer to [_addPackageToPubspec] for additional discussion.
-      if (!isDevelopment) {
+      if (!update.isDev) {
         final devDependenciesNode = yamlEditor
             .parseAt(['dev_dependencies'], orElse: () => YamlScalar.wrap(null));
 
@@ -442,8 +651,35 @@
   }
 }
 
+class _PartialParseResult {
+  final PackageRef ref;
+  final VersionConstraint? constraint;
+  _PartialParseResult(this.ref, this.constraint);
+}
+
 class _ParseResult {
-  PackageRef ref;
-  VersionConstraint? constraint;
-  _ParseResult(this.ref, this.constraint);
+  final PackageRef ref;
+  final VersionConstraint? constraint;
+  final bool isDev;
+  _ParseResult(this.ref, this.constraint, {required this.isDev});
+}
+
+extension on ArgResults {
+  bool get isDev => this['dev'];
+  bool get isDryRun => this['dry-run'];
+  String? get gitUrl => this['git-url'];
+  String? get gitPath => this['git-path'];
+  String? get gitRef => this['git-ref'];
+  String? get hostedUrl => this['hosted-url'];
+  String? get path => this['path'];
+  String? get sdk => this['sdk'];
+  bool get hasOldStyleOptions =>
+      hasGitOptions ||
+      path != null ||
+      sdk != null ||
+      hostedUrl != null ||
+      isDev;
+  bool get shouldPrecompile => this['precompile'];
+  bool get example => this['example'];
+  bool get hasGitOptions => gitUrl != null || gitRef != null || gitPath != null;
 }
diff --git a/test/add/common/add_test.dart b/test/add/common/add_test.dart
index 25f9d8d..87ac53b 100644
--- a/test/add/common/add_test.dart
+++ b/test/add/common/add_test.dart
@@ -20,13 +20,8 @@
 
     await pubAdd(
         args: ['bad name!:1.2.3'],
-        error: allOf([
-          contains(
-              "Because myapp depends on bad name! any which doesn't exist (could "
-              'not find package bad name! at http://localhost:'),
-          contains('), version solving failed.')
-        ]),
-        exitCode: exit_codes.DATA);
+        error: contains('Not a valid package name: "bad name!"'),
+        exitCode: exit_codes.USAGE);
 
     await d.appDir({}).validate();
 
@@ -84,7 +79,7 @@
       await d.dir(appPath, [
         d.file('pubspec.yaml', '''
           name: myapp
-          dependencies: 
+          dependencies:
 
           dev_dependencies:
 
@@ -370,7 +365,7 @@
 
         await pubAdd(
             args: ['foo:one-two-three'],
-            exitCode: exit_codes.USAGE,
+            exitCode: exit_codes.DATA,
             error: contains('Invalid version constraint: Could '
                 'not parse version "one-two-three".'));
 
@@ -492,6 +487,18 @@
     });
   });
 
+  test('Cannot combine descriptor with old-style args', () async {
+    await d.appDir().create();
+
+    await pubAdd(
+      args: ['foo:{"path":"../foo"}', '--path=../foo'],
+      error: contains(
+        '--dev, --path, --sdk, --git-url, --git-path and --git-ref cannot be combined',
+      ),
+      exitCode: exit_codes.USAGE,
+    );
+  });
+
   group('--dev', () {
     test('--dev adds packages to dev_dependencies instead', () async {
       final server = await servePackages();
@@ -515,6 +522,84 @@
       ]).validate();
     });
 
+    test('--dev cannot be used with a descriptor', () async {
+      await d.dir('foo', [d.libPubspec('foo', '1.2.3')]).create();
+
+      await d.dir(appPath, [
+        d.pubspec({'name': 'myapp', 'dev_dependencies': {}})
+      ]).create();
+
+      await pubAdd(
+        args: ['--dev', 'foo:{"path":../foo}'],
+        error: contains(
+          '--dev, --path, --sdk, --git-url, --git-path and --git-ref cannot be combined',
+        ),
+        exitCode: exit_codes.USAGE,
+      );
+    });
+
+    test('dev: adds packages to dev_dependencies instead without a descriptor',
+        () async {
+      final server = await servePackages();
+      server.serve('foo', '1.2.3');
+
+      await d.dir(appPath, [
+        d.pubspec({'name': 'myapp', 'dev_dependencies': {}})
+      ]).create();
+
+      await pubAdd(args: ['dev:foo:1.2.3']);
+
+      await d.appPackageConfigFile([
+        d.packageConfigEntry(name: 'foo', version: '1.2.3'),
+      ]).validate();
+
+      await d.dir(appPath, [
+        d.pubspec({
+          'name': 'myapp',
+          'dev_dependencies': {'foo': '1.2.3'}
+        })
+      ]).validate();
+    });
+
+    test('Cannot combine --dev with :dev', () async {
+      await d.dir('foo', [d.libPubspec('foo', '1.2.3')]).create();
+
+      await d.dir(appPath, [
+        d.pubspec({'name': 'myapp', 'dev_dependencies': {}})
+      ]).create();
+
+      await pubAdd(
+        args: ['--dev', 'dev:foo:1.2.3'],
+        error: contains("Cannot combine 'dev:' with --dev"),
+        exitCode: exit_codes.USAGE,
+      );
+    });
+
+    test('Can add both dev and regular dependencies', () async {
+      final server = await servePackages();
+      server.serve('foo', '1.2.3');
+      server.serve('bar', '1.2.3');
+
+      await d.dir(appPath, [
+        d.pubspec({'name': 'myapp', 'dev_dependencies': {}})
+      ]).create();
+
+      await pubAdd(args: ['dev:foo:1.2.3', 'bar:1.2.3']);
+
+      await d.appPackageConfigFile([
+        d.packageConfigEntry(name: 'foo', version: '1.2.3'),
+        d.packageConfigEntry(name: 'bar', version: '1.2.3'),
+      ]).validate();
+
+      await d.dir(appPath, [
+        d.pubspec({
+          'name': 'myapp',
+          'dependencies': {'bar': '1.2.3'},
+          'dev_dependencies': {'foo': '1.2.3'},
+        })
+      ]).validate();
+    });
+
     group('notifies user if package exists', () {
       test('if package is added without a version constraint', () async {
         await servePackages()
diff --git a/test/add/git/git_test.dart b/test/add/git/git_test.dart
index d976172..9890835 100644
--- a/test/add/git/git_test.dart
+++ b/test/add/git/git_test.dart
@@ -4,7 +4,6 @@
 
 import 'package:pub/src/exit_codes.dart' as exit_codes;
 import 'package:test/test.dart';
-
 import '../../descriptor.dart' as d;
 import '../../test_pub.dart';
 
@@ -190,14 +189,59 @@
   test('fails if multiple packages passed for git source', () async {
     ensureGit();
 
-    await d.git(
-        'foo.git', [d.libDir('foo'), d.libPubspec('foo', '1.0.0')]).create();
-
     await d.appDir({}).create();
 
     await pubAdd(
         args: ['foo', 'bar', 'baz', '--git-url', '../foo.git'],
         exitCode: exit_codes.USAGE,
-        error: contains('Can only add a single git package at a time.'));
+        error: contains('Specify multiple git packages with descriptors.'));
+  });
+
+  test('Can add a package with a git descriptor and relative path', () async {
+    await d.git('foo.git', [
+      d.dir('subdir', [d.libPubspec('foo', '1.2.3')])
+    ]).create();
+    await d.appDir({}).create();
+    await pubAdd(
+      args: [
+        '--directory',
+        appPath,
+        'foo:{"git": {"url":"foo.git", "path":"subdir"}}',
+      ],
+      workingDirectory: d.sandbox,
+      output: contains('Changed 1 dependency in myapp!'),
+    );
+
+    await d.appDir({
+      'foo': {
+        'git': {'url': '../foo.git', 'path': 'subdir'}
+      }
+    }).validate();
+  });
+
+  test('Can add multiple git packages using descriptors', () async {
+    ensureGit();
+
+    await d.git(
+        'foo.git', [d.libDir('foo'), d.libPubspec('foo', '1.0.0')]).create();
+    await d.git(
+        'bar.git', [d.libDir('foo'), d.libPubspec('bar', '1.0.0')]).create();
+
+    await d.appDir({}).create();
+
+    await pubAdd(args: [
+      'foo:{"git":"../foo.git"}',
+      'bar:{"git":"../bar.git"}',
+    ]);
+
+    await d.dir(appPath, [
+      d.pubspec({
+        'name': 'myapp',
+        'dependencies': {
+          'foo': {'git': '../foo.git'},
+          'bar': {'git': '../bar.git'},
+        },
+      })
+    ]).validate();
   });
 }
diff --git a/test/add/path/absolute_path_test.dart b/test/add/path/absolute_path_test.dart
index 5e63679..afdc3f7 100644
--- a/test/add/path/absolute_path_test.dart
+++ b/test/add/path/absolute_path_test.dart
@@ -54,7 +54,7 @@
 
     await pubAdd(
         args: ['foo:2.0.0', 'bar:0.1.3', 'baz:1.3.1', '--path', absolutePath],
-        error: contains('Can only add a single local package at a time.'),
+        error: contains('--path cannot be used with multiple packages.'),
         exitCode: exit_codes.USAGE);
 
     await d.appDir({}).validate();
diff --git a/test/add/path/relative_path_test.dart b/test/add/path/relative_path_test.dart
index e08ba3c..a25e557 100644
--- a/test/add/path/relative_path_test.dart
+++ b/test/add/path/relative_path_test.dart
@@ -28,6 +28,26 @@
     }).validate();
   });
 
+  test('can use relative path with a path descriptor', () async {
+    await d
+        .dir('foo', [d.libDir('foo'), d.libPubspec('foo', '1.2.3')]).create();
+
+    await d.appDir().create();
+
+    await pubAdd(
+      args: ['dev:foo:{"path":"../foo"}'],
+    );
+
+    await d.dir(appPath, [
+      d.pubspec({
+        'name': 'myapp',
+        'dev_dependencies': {
+          'foo': {'path': '../foo'}
+        }
+      })
+    ]).validate();
+  });
+
   test('can use relative path with --directory', () async {
     await d
         .dir('foo', [d.libDir('foo'), d.libPubspec('foo', '0.0.1')]).create();
@@ -135,4 +155,34 @@
       })
     ]).validate();
   });
+
+  test('Can add multiple path packages using descriptors', () async {
+    await d
+        .dir('foo', [d.libDir('foo'), d.libPubspec('foo', '0.0.1')]).create();
+    await d
+        .dir('bar', [d.libDir('bar'), d.libPubspec('bar', '0.0.1')]).create();
+
+    await d.appDir({}).create();
+
+    await pubAdd(
+      args: [
+        '--directory',
+        appPath,
+        'foo:{"path":"foo"}',
+        'bar:{"path":"bar"}',
+      ],
+      workingDirectory: d.sandbox,
+      output: contains('Changed 2 dependencies in myapp!'),
+    );
+
+    await d.appPackageConfigFile([
+      d.packageConfigEntry(name: 'foo', path: '../foo'),
+      d.packageConfigEntry(name: 'bar', path: '../bar'),
+    ]).validate();
+
+    await d.appDir({
+      'foo': {'path': '../foo'},
+      'bar': {'path': '../bar'},
+    }).validate();
+  });
 }
diff --git a/test/test_pub.dart b/test/test_pub.dart
index a932b6b..57dc3a0 100644
--- a/test/test_pub.dart
+++ b/test/test_pub.dart
@@ -871,9 +871,7 @@
       // Any paths in output should be relative to the sandbox and with forward
       // slashes to be stable across platforms.
       .map((line) {
-    line = line
-        .replaceAll(d.sandbox, r'$SANDBOX')
-        .replaceAll(Platform.pathSeparator, '/');
+    line = line.replaceAll(d.sandbox, r'$SANDBOX').replaceAll(r'\', '/');
     var port = _globalServer?.port;
     if (port != null) {
       line = line.replaceAll(port.toString(), '\$PORT');
diff --git a/test/testdata/goldens/embedding/embedding_test/--help.txt b/test/testdata/goldens/embedding/embedding_test/--help.txt
index 13647a0..780b29e 100644
--- a/test/testdata/goldens/embedding/embedding_test/--help.txt
+++ b/test/testdata/goldens/embedding/embedding_test/--help.txt
@@ -13,7 +13,7 @@
    (defaults to ".")
 
 Available subcommands:
-  add   Add dependencies to pubspec.yaml.
+  add   Add dependencies to `pubspec.yaml`.
   cache   Work with the system cache.
   deps   Print package dependencies.
   downgrade   Downgrade the current package's dependencies to oldest versions.
diff --git a/test/testdata/goldens/help_test/pub add --help.txt b/test/testdata/goldens/help_test/pub add --help.txt
index 2a37c21..9aa4016 100644
--- a/test/testdata/goldens/help_test/pub add --help.txt
+++ b/test/testdata/goldens/help_test/pub add --help.txt
@@ -2,18 +2,40 @@
 
 ## Section 0
 $ pub add --help
-Add dependencies to pubspec.yaml.
+Add dependencies to `pubspec.yaml`.
 
-Usage: pub add <package>[:<constraint>] [<package2>[:<constraint2>]...] [options]
+Invoking `dart pub add foo bar` will add `foo` and `bar` to `pubspec.yaml`
+with a default constraint derived from latest compatible version.
+
+Add to dev_dependencies by prefixing with "dev:".
+
+Add packages with specific constraints or other sources by giving a descriptor
+after a colon.
+
+For example:
+  * Add a hosted dependency at newest compatible stable version:
+    `$topLevelProgram pub add foo`
+  * Add a hosted dev dependency at newest compatible stable version:
+    `$topLevelProgram pub add dev:foo`
+  * Add a hosted dependency with the given constraint
+    `$topLevelProgram pub add foo:^1.2.3`
+  * Add multiple dependencies:
+    `$topLevelProgram pub add foo dev:bar`
+  * Add a path dependency:
+    `$topLevelProgram pub add 'foo{"path":"../foo"}'`
+  * Add a hosted dependency:
+    `$topLevelProgram pub add 'foo{"hosted":"my-pub.dev"}'`
+  * Add an sdk dependency:
+    `$topLevelProgram pub add 'foo{"sdk":"flutter"}'`
+  * Add a git dependency:
+    `$topLevelProgram pub add 'foo{"git":"https://github.com/foo/foo"}'`
+  * Add a git dependency with a path and ref specified:
+    `$topLevelProgram pub add /
+      'foo{"git":{"url":"../foo.git","ref":"branch","path":"subdir"}}'`
+
+Usage: pub add [options] [dev:]<package>[:descriptor] [[dev:]<package>[:descriptor]
+       ...]
 -h, --help               Print this usage information.
--d, --dev                Adds to the development dependencies instead.
-    --git-url            Git URL of the package
-    --git-ref            Git branch or commit to be retrieved
-    --git-path           Path of git package in repository
-    --hosted-url         URL of package host server
-    --path               Add package from local path
-    --sdk=<[flutter]>    add package from SDK source
-                         [flutter]
     --[no-]offline       Use cached packages instead of accessing the network.
 -n, --dry-run            Report what dependencies would change but don't change
                          any.