diff --git a/src/main/java/de/prob2/jupyter/Command.java b/src/main/java/de/prob2/jupyter/Command.java
index ebeca7c7a170a289a70b6b56aa747f3fbf51f137..ee1361accd0fa0a7a9984fe7c81acafbd06dad36 100644
--- a/src/main/java/de/prob2/jupyter/Command.java
+++ b/src/main/java/de/prob2/jupyter/Command.java
@@ -9,6 +9,8 @@ import org.jetbrains.annotations.Nullable;
 public interface Command {
 	public abstract @NotNull String getName();
 	
+	public abstract @NotNull Parameters getParameters();
+	
 	public abstract @NotNull String getSyntax();
 	
 	public abstract @NotNull String getShortHelp();
@@ -39,7 +41,7 @@ public interface Command {
 		return result;
 	}
 	
-	public abstract @Nullable DisplayData run(final @NotNull String argString);
+	public abstract @Nullable DisplayData run(final @NotNull ParsedArguments args);
 	
 	public abstract @Nullable DisplayData inspect(final @NotNull String argString, final int at);
 	
diff --git a/src/main/java/de/prob2/jupyter/CommandUtils.java b/src/main/java/de/prob2/jupyter/CommandUtils.java
index e2b855a9907397ab9000ac1e64f9161a5e9bd991..3c83d96eb6a868411d65c2ff03b0d2141f36c78c 100644
--- a/src/main/java/de/prob2/jupyter/CommandUtils.java
+++ b/src/main/java/de/prob2/jupyter/CommandUtils.java
@@ -2,6 +2,7 @@ package de.prob2.jupyter;
 
 import java.util.ArrayList;
 import java.util.Arrays;
+import java.util.Collections;
 import java.util.HashMap;
 import java.util.LinkedHashSet;
 import java.util.List;
@@ -101,6 +102,41 @@ public final class CommandUtils {
 		return splitArgs(args, 0);
 	}
 	
+	private static <T> @NotNull String parseSingleArg(final @NotNull ParsedArguments parsed, final @NotNull String remainingArgs, final @NotNull PositionalParameter<T> param) {
+		final Parameter.ParseResult<T> parsedSingleArg = param.parse(remainingArgs);
+		parsed.put(param, parsedSingleArg.getParsedArg());
+		return parsedSingleArg.getRemainingArgString();
+	}
+	
+	private static <T> void checkParsedParameter(final @NotNull ParsedArguments parsed, final @NotNull PositionalParameter<T> param) {
+		if (!parsed.containsKey(param)) {
+			if (param.isOptional()) {
+				parsed.put(param, param.getDefaultValue());
+			} else {
+				throw new UserErrorException("Missing required parameter " + param.getIdentifier());
+			}
+		}
+	}
+	
+	public static @NotNull ParsedArguments parseArgs(final @NotNull Parameters parameters, final @NotNull String argString) {
+		final ParsedArguments parsed = new ParsedArguments(Collections.emptyMap());
+		String remainingArgs = argString;
+		for (final PositionalParameter<?> param : parameters.getPositionalParameters()) {
+			if (remainingArgs.isEmpty()) {
+				break;
+			}
+			
+			remainingArgs = parseSingleArg(parsed, remainingArgs, param);
+		}
+		if (!remainingArgs.isEmpty()) {
+			throw new UserErrorException("Expected at most " + parameters.getPositionalParameters().size() + " arguments, got extra argument: " + remainingArgs);
+		}
+		for (final PositionalParameter<?> param : parameters.getPositionalParameters()) {
+			checkParsedParameter(parsed, param);
+		}
+		return parsed;
+	}
+	
 	public static @NotNull Map<@NotNull String, @NotNull String> parsePreferences(final @NotNull List<@NotNull String> args) {
 		final Map<String, String> preferences = new HashMap<>();
 		for (final String arg : args) {
diff --git a/src/main/java/de/prob2/jupyter/Parameter.java b/src/main/java/de/prob2/jupyter/Parameter.java
new file mode 100644
index 0000000000000000000000000000000000000000..bb1fcb93a474bfe18f1b44919d2eb60692d3a84e
--- /dev/null
+++ b/src/main/java/de/prob2/jupyter/Parameter.java
@@ -0,0 +1,52 @@
+package de.prob2.jupyter;
+
+import com.google.common.base.MoreObjects;
+
+import org.jetbrains.annotations.NotNull;
+
+public abstract class Parameter<T> {
+	public static final class ParseResult<T> {
+		private final T parsedArg;
+		private final @NotNull String remainingArgString;
+		
+		public ParseResult(final T parsedArg, final @NotNull String remainingArgString) {
+			super();
+			
+			this.parsedArg = parsedArg;
+			this.remainingArgString = remainingArgString;
+		}
+		
+		public T getParsedArg() {
+			return this.parsedArg;
+		}
+		
+		public @NotNull String getRemainingArgString() {
+			return this.remainingArgString;
+		}
+	}
+	
+	private final @NotNull String identifier;
+	
+	protected Parameter(final @NotNull String identifier) {
+		super();
+		
+		this.identifier = identifier;
+	}
+	
+	public @NotNull String getIdentifier() {
+		return this.identifier;
+	}
+	
+	public abstract boolean isOptional();
+	
+	public abstract T getDefaultValue();
+	
+	public abstract Parameter.ParseResult<T> parse(final @NotNull String argString);
+	
+	@Override
+	public String toString() {
+		return MoreObjects.toStringHelper(this)
+			.add("identifier", this.getIdentifier())
+			.toString();
+	}
+}
diff --git a/src/main/java/de/prob2/jupyter/Parameters.java b/src/main/java/de/prob2/jupyter/Parameters.java
new file mode 100644
index 0000000000000000000000000000000000000000..0ef54274217db75f79a4a4970ddf06e02b439223
--- /dev/null
+++ b/src/main/java/de/prob2/jupyter/Parameters.java
@@ -0,0 +1,37 @@
+package de.prob2.jupyter;
+
+import java.util.Collections;
+import java.util.List;
+
+import org.jetbrains.annotations.NotNull;
+
+public final class Parameters {
+	public static final @NotNull Parameters NONE = new Parameters(Collections.emptyList());
+	
+	private final @NotNull List<PositionalParameter<?>> positionalParameters;
+	
+	public Parameters(final @NotNull List<PositionalParameter<?>> positionalParameters) {
+		super();
+		
+		this.positionalParameters = positionalParameters;
+		
+		boolean seenOptional = false;
+		boolean seenOnlyLast = false;
+		for (final PositionalParameter<?> param : positionalParameters) {
+			final boolean isOptional = param instanceof PositionalParameter.OptionalSingle || param instanceof PositionalParameter.OptionalRemainder;
+			final boolean isOnlyLast = param instanceof PositionalParameter.RequiredRemainder || param instanceof PositionalParameter.OptionalRemainder;
+			if (seenOnlyLast) {
+				throw new IllegalArgumentException("A remainder positional parameter cannot be followed by any more positional parameters");
+			}
+			if (seenOptional && isOptional) {
+				throw new IllegalArgumentException("Required positional parameter " + param + " cannot follow an optional positional parameter");
+			}
+			seenOptional |= isOptional;
+			seenOnlyLast |= isOnlyLast;
+		}
+	}
+	
+	public @NotNull List<PositionalParameter<?>> getPositionalParameters() {
+		return this.positionalParameters;
+	}
+}
diff --git a/src/main/java/de/prob2/jupyter/ParsedArguments.java b/src/main/java/de/prob2/jupyter/ParsedArguments.java
new file mode 100644
index 0000000000000000000000000000000000000000..3d8a81e85297b3af4c681a59d972cc4ed3c30045
--- /dev/null
+++ b/src/main/java/de/prob2/jupyter/ParsedArguments.java
@@ -0,0 +1,42 @@
+package de.prob2.jupyter;
+
+import java.util.HashMap;
+import java.util.Map;
+
+import com.google.common.base.MoreObjects;
+
+import org.jetbrains.annotations.NotNull;
+
+public final class ParsedArguments {
+	private final @NotNull Map<@NotNull Parameter<?>, Object> values;
+	
+	public ParsedArguments(final @NotNull Map<@NotNull Parameter<?>, Object> values) {
+		super();
+		
+		this.values = new HashMap<>(values);
+	}
+	
+	public boolean containsKey(final @NotNull Parameter<?> parameter) {
+		return this.values.containsKey(parameter);
+	}
+	
+	public <T> T get(final @NotNull Parameter<T> parameter) {
+		if (!this.containsKey(parameter)) {
+			throw new IllegalArgumentException("No value present for parameter " + parameter);
+		}
+		@SuppressWarnings("unchecked")
+		final T value = (T)this.values.get(parameter);
+		return value;
+	}
+	
+	public <T> void put(final @NotNull Parameter<T> parameter, final T value) {
+		this.values.put(parameter, value);
+	}
+	
+	@Override
+	public String toString() {
+		return MoreObjects.toStringHelper(this)
+			.add("values", this.values)
+			.toString();
+	}
+}
diff --git a/src/main/java/de/prob2/jupyter/PositionalParameter.java b/src/main/java/de/prob2/jupyter/PositionalParameter.java
new file mode 100644
index 0000000000000000000000000000000000000000..f127f49cafb75f93e004d6b671af1dca1b95cfd0
--- /dev/null
+++ b/src/main/java/de/prob2/jupyter/PositionalParameter.java
@@ -0,0 +1,98 @@
+package de.prob2.jupyter;
+
+import java.util.Optional;
+
+import org.jetbrains.annotations.NotNull;
+
+public abstract class PositionalParameter<T> extends Parameter<T> {
+	public static final class RequiredSingle extends PositionalParameter<@NotNull String> {
+		public RequiredSingle(final @NotNull String identifier) {
+			super(identifier);
+		}
+		
+		@Override
+		public boolean isOptional() {
+			return false;
+		}
+		
+		@Override
+		public @NotNull String getDefaultValue() {
+			throw new UnsupportedOperationException("Not an optional parameter");
+		}
+		
+		@Override
+		public @NotNull Parameter.ParseResult<@NotNull String> parse(final @NotNull String argString) {
+			final String[] split = CommandUtils.ARG_SPLIT_PATTERN.split(argString, 2);
+			return new Parameter.ParseResult<>(split[0], split.length > 1 ? split[1] : "");
+		}
+	}
+	
+	public static final class OptionalSingle extends PositionalParameter<@NotNull Optional<String>> {
+		public OptionalSingle(final @NotNull String identifier) {
+			super(identifier);
+		}
+		
+		@Override
+		public boolean isOptional() {
+			return true;
+		}
+		
+		@Override
+		public @NotNull Optional<String> getDefaultValue() {
+			return Optional.empty();
+		}
+		
+		@Override
+		public @NotNull Parameter.ParseResult<@NotNull Optional<String>> parse(final @NotNull String argString) {
+			final String[] split = CommandUtils.ARG_SPLIT_PATTERN.split(argString, 2);
+			return new Parameter.ParseResult<>(Optional.of(split[0]), split.length > 1 ? split[1] : "");
+		}
+	}
+	
+	public static final class RequiredRemainder extends PositionalParameter<@NotNull String> {
+		public RequiredRemainder(final @NotNull String identifier) {
+			super(identifier);
+		}
+		
+		@Override
+		public boolean isOptional() {
+			return false;
+		}
+		
+		@Override
+		public @NotNull String getDefaultValue() {
+			throw new UnsupportedOperationException("Not an optional parameter");
+		}
+		
+		@Override
+		public Parameter.ParseResult<@NotNull String> parse(final @NotNull String argString) {
+			return new Parameter.ParseResult<>(argString, "");
+		}
+	}
+	
+	public static final class OptionalRemainder extends PositionalParameter<@NotNull Optional<String>> {
+		public OptionalRemainder(final @NotNull String identifier) {
+			super(identifier);
+		}
+		
+		@Override
+		public boolean isOptional() {
+			return true;
+		}
+		
+		@Override
+		public @NotNull Optional<String> getDefaultValue() {
+			return Optional.empty();
+		}
+		
+		@Override
+		public Parameter.ParseResult<@NotNull Optional<String>> parse(final @NotNull String argString) {
+			return new Parameter.ParseResult<>(Optional.of(argString), "");
+		}
+	}
+	
+	protected PositionalParameter(final @NotNull String identifier) {
+		super(identifier);
+	}
+	
+}
diff --git a/src/main/java/de/prob2/jupyter/ProBKernel.java b/src/main/java/de/prob2/jupyter/ProBKernel.java
index d32646d1cb65d6132ecc0d0cc97b909dc36fe995..f126d0214f94548036ed0bf332a7d090f643b4d1 100644
--- a/src/main/java/de/prob2/jupyter/ProBKernel.java
+++ b/src/main/java/de/prob2/jupyter/ProBKernel.java
@@ -297,7 +297,7 @@ public final class ProBKernel extends BaseKernel {
 		}
 		final DisplayData result;
 		try {
-			result = command.run(argString);
+			result = command.run(CommandUtils.parseArgs(command.getParameters(), argString));
 		} catch (final UserErrorException e) {
 			throw new CommandExecutionException(name, e);
 		}
diff --git a/src/main/java/de/prob2/jupyter/commands/AssertCommand.java b/src/main/java/de/prob2/jupyter/commands/AssertCommand.java
index 0d79d7ac62b1f02ccf67a5700a7ad2adf12225fb..55574b0cbf91a053b46f035f95e7ccc373aa7860 100644
--- a/src/main/java/de/prob2/jupyter/commands/AssertCommand.java
+++ b/src/main/java/de/prob2/jupyter/commands/AssertCommand.java
@@ -1,5 +1,7 @@
 package de.prob2.jupyter.commands;
 
+import java.util.Collections;
+
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 
@@ -9,6 +11,9 @@ import de.prob.animator.domainobjects.FormulaExpand;
 import de.prob.statespace.AnimationSelector;
 import de.prob2.jupyter.Command;
 import de.prob2.jupyter.CommandUtils;
+import de.prob2.jupyter.Parameters;
+import de.prob2.jupyter.ParsedArguments;
+import de.prob2.jupyter.PositionalParameter;
 import de.prob2.jupyter.ProBKernel;
 import de.prob2.jupyter.UserErrorException;
 
@@ -20,6 +25,8 @@ import org.jetbrains.annotations.NotNull;
 import org.jetbrains.annotations.Nullable;
 
 public final class AssertCommand implements Command {
+	private static final @NotNull PositionalParameter.RequiredRemainder FORMULA_PARAM = new PositionalParameter.RequiredRemainder("formula");
+	
 	private final @NotNull Provider<@NotNull ProBKernel> kernelProvider;
 	private final @NotNull AnimationSelector animationSelector;
 	
@@ -36,6 +43,11 @@ public final class AssertCommand implements Command {
 		return ":assert";
 	}
 	
+	@Override
+	public @NotNull Parameters getParameters() {
+		return new Parameters(Collections.singletonList(FORMULA_PARAM));
+	}
+	
 	@Override
 	public @NotNull String getSyntax() {
 		return ":assert PREDICATE";
@@ -54,8 +66,8 @@ public final class AssertCommand implements Command {
 	}
 	
 	@Override
-	public @NotNull DisplayData run(final @NotNull String argString) {
-		final String code = this.kernelProvider.get().insertLetVariables(argString);
+	public @NotNull DisplayData run(final @NotNull ParsedArguments args) {
+		final String code = this.kernelProvider.get().insertLetVariables(args.get(FORMULA_PARAM));
 		final AbstractEvalResult result = CommandUtils.withSourceCode(code, () -> this.animationSelector.getCurrentTrace().evalCurrent(code, FormulaExpand.TRUNCATE));
 		if (result instanceof EvalResult && "TRUE".equals(((EvalResult)result).getValue())) {
 			// Use EvalResult.TRUE instead of the real result so that solution variables are not displayed.
diff --git a/src/main/java/de/prob2/jupyter/commands/BrowseCommand.java b/src/main/java/de/prob2/jupyter/commands/BrowseCommand.java
index 6ec6593be8c7687d38ebfbd72e2d362f4e700d11..28ca4af4f592ac2a5d775c85e28c6f22ba0966fc 100644
--- a/src/main/java/de/prob2/jupyter/commands/BrowseCommand.java
+++ b/src/main/java/de/prob2/jupyter/commands/BrowseCommand.java
@@ -11,9 +11,9 @@ import de.prob.statespace.AnimationSelector;
 import de.prob.statespace.LoadedMachine;
 import de.prob.statespace.Trace;
 import de.prob.statespace.Transition;
-
 import de.prob2.jupyter.Command;
-import de.prob2.jupyter.UserErrorException;
+import de.prob2.jupyter.Parameters;
+import de.prob2.jupyter.ParsedArguments;
 
 import io.github.spencerpark.jupyter.kernel.ReplacementOptions;
 import io.github.spencerpark.jupyter.kernel.display.DisplayData;
@@ -38,6 +38,11 @@ public final class BrowseCommand implements Command {
 		return ":browse";
 	}
 	
+	@Override
+	public @NotNull Parameters getParameters() {
+		return Parameters.NONE;
+	}
+	
 	@Override
 	public @NotNull String getSyntax() {
 		return ":browse";
@@ -58,11 +63,7 @@ public final class BrowseCommand implements Command {
 	}
 	
 	@Override
-	public @NotNull DisplayData run(final @NotNull String argString) {
-		if (!argString.isEmpty()) {
-			throw new UserErrorException("Unexpected argument: " + argString);
-		}
-		
+	public @NotNull DisplayData run(final @NotNull ParsedArguments args) {
 		final Trace trace = this.animationSelector.getCurrentTrace();
 		final StringBuilder sb = new StringBuilder("Machine: ");
 		sb.append(trace.getStateSpace().getMainComponent());
diff --git a/src/main/java/de/prob2/jupyter/commands/BsymbCommand.java b/src/main/java/de/prob2/jupyter/commands/BsymbCommand.java
index 07bcee83a6a0343eb84a4175d5795924c1e1939e..e799a475c480be6557364617ddad5e80987e767a 100644
--- a/src/main/java/de/prob2/jupyter/commands/BsymbCommand.java
+++ b/src/main/java/de/prob2/jupyter/commands/BsymbCommand.java
@@ -3,6 +3,8 @@ package de.prob2.jupyter.commands;
 import com.google.inject.Inject;
 
 import de.prob2.jupyter.Command;
+import de.prob2.jupyter.Parameters;
+import de.prob2.jupyter.ParsedArguments;
 
 import io.github.spencerpark.jupyter.kernel.ReplacementOptions;
 import io.github.spencerpark.jupyter.kernel.display.DisplayData;
@@ -21,6 +23,11 @@ public final class BsymbCommand implements Command {
 		return ":bsymb";
 	}
 	
+	@Override
+	public @NotNull Parameters getParameters() {
+		return Parameters.NONE;
+	}
+	
 	@Override
 	public @NotNull String getSyntax() {
 		return ":bsymb";
@@ -37,7 +44,7 @@ public final class BsymbCommand implements Command {
 	}
 	
 	@Override
-	public @NotNull DisplayData run(final @NotNull String argString) {
+	public @NotNull DisplayData run(final @NotNull ParsedArguments args) {
 		final DisplayData data = new DisplayData("Your current environment uses plain text output; the bsymb.sty LaTeX commands will not be loaded.");
 		// The actual bsymb definitions are added by the ProBKernel class when it processes the command result, and the error message below will be replaced.
 		// If this error message is visible to the user, it means that the definitions were not added correctly.
diff --git a/src/main/java/de/prob2/jupyter/commands/CheckCommand.java b/src/main/java/de/prob2/jupyter/commands/CheckCommand.java
index 7f06044eecab3161f2ab11c2ce816a0f848d79f5..088dcce1a2bc911bac6c688cf24b3ea8a991358d 100644
--- a/src/main/java/de/prob2/jupyter/commands/CheckCommand.java
+++ b/src/main/java/de/prob2/jupyter/commands/CheckCommand.java
@@ -22,6 +22,9 @@ import de.prob.statespace.Trace;
 import de.prob.unicode.UnicodeTranslator;
 import de.prob2.jupyter.Command;
 import de.prob2.jupyter.CommandUtils;
+import de.prob2.jupyter.Parameters;
+import de.prob2.jupyter.ParsedArguments;
+import de.prob2.jupyter.PositionalParameter;
 import de.prob2.jupyter.UserErrorException;
 
 import io.github.spencerpark.jupyter.kernel.ReplacementOptions;
@@ -31,6 +34,8 @@ import org.jetbrains.annotations.NotNull;
 import org.jetbrains.annotations.Nullable;
 
 public final class CheckCommand implements Command {
+	private static final @NotNull PositionalParameter.RequiredSingle WHAT_PARAM = new PositionalParameter.RequiredSingle("what");
+	
 	private static final @NotNull Map<@NotNull String, @NotNull Class<? extends AbstractTheoremElement>> CHILDREN_BASE_CLASS_MAP;
 	static {
 		final Map<String, Class<? extends AbstractTheoremElement>> childrenBaseClassMap = new HashMap<>();
@@ -54,6 +59,11 @@ public final class CheckCommand implements Command {
 		return ":check";
 	}
 	
+	@Override
+	public @NotNull Parameters getParameters() {
+		return new Parameters(Collections.singletonList(WHAT_PARAM));
+	}
+	
 	@Override
 	public @NotNull String getSyntax() {
 		return ":check WHAT";
@@ -70,11 +80,12 @@ public final class CheckCommand implements Command {
 	}
 	
 	@Override
-	public @NotNull DisplayData run(final @NotNull String argString) {
-		if (!CHILDREN_BASE_CLASS_MAP.containsKey(argString)) {
-			throw new UserErrorException("Don't know how to check " + argString);
+	public @NotNull DisplayData run(final @NotNull ParsedArguments args) {
+		final String what = args.get(WHAT_PARAM);
+		if (!CHILDREN_BASE_CLASS_MAP.containsKey(what)) {
+			throw new UserErrorException("Don't know how to check " + what);
 		}
-		final Class<? extends AbstractTheoremElement> childrenBaseClass = CHILDREN_BASE_CLASS_MAP.get(argString);
+		final Class<? extends AbstractTheoremElement> childrenBaseClass = CHILDREN_BASE_CLASS_MAP.get(what);
 		final Trace trace = this.animationSelector.getCurrentTrace();
 		// Find all children of a subclass of childrenBaseClass.
 		// This needs to be done manually, because getChildrenOfType only returns children whose class *exactly* matches the given class.
diff --git a/src/main/java/de/prob2/jupyter/commands/ConstantsCommand.java b/src/main/java/de/prob2/jupyter/commands/ConstantsCommand.java
index 264a3fef76ac7953ccd7a9326c81bc1275d46598..1dc90cce6f547ca0bc1dc8aad7109ce4fae3a343 100644
--- a/src/main/java/de/prob2/jupyter/commands/ConstantsCommand.java
+++ b/src/main/java/de/prob2/jupyter/commands/ConstantsCommand.java
@@ -12,6 +12,9 @@ import de.prob.statespace.Trace;
 import de.prob.statespace.Transition;
 import de.prob2.jupyter.Command;
 import de.prob2.jupyter.CommandUtils;
+import de.prob2.jupyter.Parameters;
+import de.prob2.jupyter.ParsedArguments;
+import de.prob2.jupyter.PositionalParameter;
 import de.prob2.jupyter.ProBKernel;
 
 import io.github.spencerpark.jupyter.kernel.ReplacementOptions;
@@ -21,6 +24,8 @@ import org.jetbrains.annotations.NotNull;
 import org.jetbrains.annotations.Nullable;
 
 public final class ConstantsCommand implements Command {
+	private static final @NotNull PositionalParameter.OptionalRemainder PREDICATE_PARAM = new PositionalParameter.OptionalRemainder("predicate");
+	
 	private final @NotNull Provider<@NotNull ProBKernel> kernelProvider;
 	private final @NotNull AnimationSelector animationSelector;
 	
@@ -37,6 +42,11 @@ public final class ConstantsCommand implements Command {
 		return ":constants";
 	}
 	
+	@Override
+	public @NotNull Parameters getParameters() {
+		return new Parameters(Collections.singletonList(PREDICATE_PARAM));
+	}
+	
 	@Override
 	public @NotNull String getSyntax() {
 		return ":constants [PREDICATE]";
@@ -53,13 +63,13 @@ public final class ConstantsCommand implements Command {
 	}
 	
 	@Override
-	public @NotNull DisplayData run(final @NotNull String argString) {
+	public @NotNull DisplayData run(final @NotNull ParsedArguments args) {
 		final Trace trace = this.animationSelector.getCurrentTrace();
 		final String predicate;
-		if (argString.isEmpty()) {
+		if (!args.get(PREDICATE_PARAM).isPresent()) {
 			predicate = "1=1";
 		} else {
-			predicate = this.kernelProvider.get().insertLetVariables(argString);
+			predicate = this.kernelProvider.get().insertLetVariables(args.get(PREDICATE_PARAM).get());
 		}
 		final List<Transition> ops = trace.getStateSpace().transitionFromPredicate(trace.getCurrentState(), "$setup_constants", predicate, 1);
 		assert !ops.isEmpty();
diff --git a/src/main/java/de/prob2/jupyter/commands/DotCommand.java b/src/main/java/de/prob2/jupyter/commands/DotCommand.java
index 1e10001b57aa64f40916ee43d06dd7976ab357e2..75e48d972e130cb4529689a6572ea5e5616075b8 100644
--- a/src/main/java/de/prob2/jupyter/commands/DotCommand.java
+++ b/src/main/java/de/prob2/jupyter/commands/DotCommand.java
@@ -4,6 +4,7 @@ import java.io.IOException;
 import java.io.UncheckedIOException;
 import java.nio.file.Files;
 import java.nio.file.Path;
+import java.util.Arrays;
 import java.util.Collections;
 import java.util.List;
 import java.util.stream.Collectors;
@@ -21,6 +22,9 @@ import de.prob.statespace.AnimationSelector;
 import de.prob.statespace.Trace;
 import de.prob2.jupyter.Command;
 import de.prob2.jupyter.CommandUtils;
+import de.prob2.jupyter.Parameters;
+import de.prob2.jupyter.ParsedArguments;
+import de.prob2.jupyter.PositionalParameter;
 import de.prob2.jupyter.ProBKernel;
 import de.prob2.jupyter.UserErrorException;
 
@@ -31,6 +35,9 @@ import org.jetbrains.annotations.NotNull;
 import org.jetbrains.annotations.Nullable;
 
 public final class DotCommand implements Command {
+	private static final @NotNull PositionalParameter.RequiredSingle COMMAND_PARAM = new PositionalParameter.RequiredSingle("command");
+	private static final @NotNull PositionalParameter.OptionalRemainder FORMULA_PARAM = new PositionalParameter.OptionalRemainder("formula");
+	
 	private final @NotNull Provider<@NotNull ProBKernel> kernelProvider;
 	private final @NotNull AnimationSelector animationSelector;
 	
@@ -47,6 +54,11 @@ public final class DotCommand implements Command {
 		return ":dot";
 	}
 	
+	@Override
+	public @NotNull Parameters getParameters() {
+		return new Parameters(Arrays.asList(COMMAND_PARAM, FORMULA_PARAM));
+	}
+	
 	@Override
 	public @NotNull String getSyntax() {
 		return ":dot COMMAND [FORMULA]";
@@ -76,19 +88,17 @@ public final class DotCommand implements Command {
 	}
 	
 	@Override
-	public @NotNull DisplayData run(final @NotNull String argString) {
-		final List<String> split = CommandUtils.splitArgs(argString, 2);
-		assert !split.isEmpty();
-		final String command = split.get(0);
-		final List<IEvalElement> args;
+	public @NotNull DisplayData run(final @NotNull ParsedArguments args) {
+		final String command = args.get(COMMAND_PARAM);
+		final List<IEvalElement> dotCommandArgs;
 		final String code;
-		if (split.size() > 1) {
-			code = this.kernelProvider.get().insertLetVariables(split.get(1));
+		if (args.get(FORMULA_PARAM).isPresent()) {
+			code = this.kernelProvider.get().insertLetVariables(args.get(FORMULA_PARAM).get());
 			final IEvalElement formula = CommandUtils.withSourceCode(code, () -> this.animationSelector.getCurrentTrace().getModel().parseFormula(code, FormulaExpand.EXPAND));
-			args = Collections.singletonList(formula);
+			dotCommandArgs = Collections.singletonList(formula);
 		} else {
 			code = null;
-			args = Collections.emptyList();
+			dotCommandArgs = Collections.emptyList();
 		}
 		
 		final Trace trace = this.animationSelector.getCurrentTrace();
@@ -105,7 +115,7 @@ public final class DotCommand implements Command {
 		} catch (final IOException e) {
 			throw new UncheckedIOException("Failed to create temp file", e);
 		}
-		final GetSvgForVisualizationCommand cmd2 = new GetSvgForVisualizationCommand(trace.getCurrentState(), item, outPath.toFile(), args);
+		final GetSvgForVisualizationCommand cmd2 = new GetSvgForVisualizationCommand(trace.getCurrentState(), item, outPath.toFile(), dotCommandArgs);
 		// Provide source code (if any) to error highlighter
 		final Runnable execute = () -> trace.getStateSpace().execute(cmd2);
 		if (code != null) {
@@ -119,7 +129,7 @@ public final class DotCommand implements Command {
 		} catch (final IOException e) {
 			throw new UncheckedIOException("Failed to read dot output", e);
 		}
-		final DisplayData result = new DisplayData(String.format("<Dot visualization: %s %s>", command, args));
+		final DisplayData result = new DisplayData(String.format("<Dot visualization: %s %s>", command, dotCommandArgs));
 		result.putSVG(svg);
 		return result;
 	}
diff --git a/src/main/java/de/prob2/jupyter/commands/EvalCommand.java b/src/main/java/de/prob2/jupyter/commands/EvalCommand.java
index 74c47cf90ab4a5f1331d0341954c811245bae60c..30de5cd08f16111c5852bcaa566a1aa6006f0713 100644
--- a/src/main/java/de/prob2/jupyter/commands/EvalCommand.java
+++ b/src/main/java/de/prob2/jupyter/commands/EvalCommand.java
@@ -1,5 +1,7 @@
 package de.prob2.jupyter.commands;
 
+import java.util.Collections;
+
 import com.google.inject.Inject;
 import com.google.inject.Injector;
 
@@ -7,6 +9,9 @@ import de.prob.animator.domainobjects.FormulaExpand;
 import de.prob.statespace.AnimationSelector;
 import de.prob2.jupyter.Command;
 import de.prob2.jupyter.CommandUtils;
+import de.prob2.jupyter.Parameters;
+import de.prob2.jupyter.ParsedArguments;
+import de.prob2.jupyter.PositionalParameter;
 import de.prob2.jupyter.ProBKernel;
 
 import io.github.spencerpark.jupyter.kernel.ReplacementOptions;
@@ -16,6 +21,8 @@ import org.jetbrains.annotations.NotNull;
 import org.jetbrains.annotations.Nullable;
 
 public final class EvalCommand implements Command {
+	private static final @NotNull PositionalParameter.RequiredRemainder FORMULA_PARAM = new PositionalParameter.RequiredRemainder("formula");
+	
 	private final @NotNull Injector injector;
 	private final @NotNull AnimationSelector animationSelector;
 	
@@ -32,6 +39,11 @@ public final class EvalCommand implements Command {
 		return ":eval";
 	}
 	
+	@Override
+	public @NotNull Parameters getParameters() {
+		return new Parameters(Collections.singletonList(FORMULA_PARAM));
+	}
+	
 	@Override
 	public @NotNull String getSyntax() {
 		return "FORMULA\n// or\n:eval FORMULA";
@@ -49,8 +61,8 @@ public final class EvalCommand implements Command {
 	}
 	
 	@Override
-	public @NotNull DisplayData run(final @NotNull String argString) {
-		final String code = this.injector.getInstance(ProBKernel.class).insertLetVariables(argString);
+	public @NotNull DisplayData run(final @NotNull ParsedArguments args) {
+		final String code = this.injector.getInstance(ProBKernel.class).insertLetVariables(args.get(FORMULA_PARAM));
 		return CommandUtils.displayDataForEvalResult(CommandUtils.withSourceCode(code, () -> this.animationSelector.getCurrentTrace().evalCurrent(code, FormulaExpand.EXPAND)));
 	}
 	
diff --git a/src/main/java/de/prob2/jupyter/commands/ExecCommand.java b/src/main/java/de/prob2/jupyter/commands/ExecCommand.java
index 3135e0126b026a497f1cf07aab2f924de1f5f1e3..931a5ca1f68275c869fe107db37736861c3b0eb7 100644
--- a/src/main/java/de/prob2/jupyter/commands/ExecCommand.java
+++ b/src/main/java/de/prob2/jupyter/commands/ExecCommand.java
@@ -1,5 +1,6 @@
 package de.prob2.jupyter.commands;
 
+import java.util.Arrays;
 import java.util.Collections;
 import java.util.List;
 import java.util.stream.Collectors;
@@ -13,6 +14,9 @@ import de.prob.statespace.Trace;
 import de.prob.statespace.Transition;
 import de.prob2.jupyter.Command;
 import de.prob2.jupyter.CommandUtils;
+import de.prob2.jupyter.Parameters;
+import de.prob2.jupyter.ParsedArguments;
+import de.prob2.jupyter.PositionalParameter;
 import de.prob2.jupyter.ProBKernel;
 
 import io.github.spencerpark.jupyter.kernel.ReplacementOptions;
@@ -22,6 +26,9 @@ import org.jetbrains.annotations.NotNull;
 import org.jetbrains.annotations.Nullable;
 
 public final class ExecCommand implements Command {
+	private static final @NotNull PositionalParameter.RequiredSingle OPERATION_PARAM = new PositionalParameter.RequiredSingle("operation");
+	private static final @NotNull PositionalParameter.OptionalRemainder PREDICATE_PARAM = new PositionalParameter.OptionalRemainder("predicate");
+	
 	private final @NotNull Provider<@NotNull ProBKernel> kernelProvider;
 	private final @NotNull AnimationSelector animationSelector;
 	
@@ -38,6 +45,11 @@ public final class ExecCommand implements Command {
 		return ":exec";
 	}
 	
+	@Override
+	public @NotNull Parameters getParameters() {
+		return new Parameters(Arrays.asList(OPERATION_PARAM, PREDICATE_PARAM));
+	}
+	
 	@Override
 	public @NotNull String getSyntax() {
 		return ":exec OPERATION [PREDICATE]";
@@ -55,17 +67,14 @@ public final class ExecCommand implements Command {
 	}
 	
 	@Override
-	public @NotNull DisplayData run(final @NotNull String argString) {
-		final List<String> split = CommandUtils.splitArgs(argString, 2);
-		assert !split.isEmpty();
-		
+	public @NotNull DisplayData run(final @NotNull ParsedArguments args) {
 		final Trace trace = this.animationSelector.getCurrentTrace();
-		final String translatedOpName = CommandUtils.unprettyOperationName(split.get(0));
+		final String translatedOpName = CommandUtils.unprettyOperationName(args.get(OPERATION_PARAM));
 		final String predicate;
-		if (split.size() < 2) {
+		if (!args.get(PREDICATE_PARAM).isPresent()) {
 			predicate = "1=1";
 		} else {
-			predicate = this.kernelProvider.get().insertLetVariables(split.get(1));
+			predicate = this.kernelProvider.get().insertLetVariables(args.get(PREDICATE_PARAM).get());
 		}
 		final List<Transition> ops = trace.getStateSpace().transitionFromPredicate(trace.getCurrentState(), translatedOpName, predicate, 1);
 		assert !ops.isEmpty();
diff --git a/src/main/java/de/prob2/jupyter/commands/FindCommand.java b/src/main/java/de/prob2/jupyter/commands/FindCommand.java
index 0664e959b86ca1b0ed65eb270e1afe877ca033a8..e1aae326b416ad06dd22743d485a0dc8d6d9a829 100644
--- a/src/main/java/de/prob2/jupyter/commands/FindCommand.java
+++ b/src/main/java/de/prob2/jupyter/commands/FindCommand.java
@@ -1,5 +1,7 @@
 package de.prob2.jupyter.commands;
 
+import java.util.Collections;
+
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 
@@ -9,6 +11,9 @@ import de.prob.statespace.AnimationSelector;
 import de.prob.statespace.Trace;
 import de.prob2.jupyter.Command;
 import de.prob2.jupyter.CommandUtils;
+import de.prob2.jupyter.Parameters;
+import de.prob2.jupyter.ParsedArguments;
+import de.prob2.jupyter.PositionalParameter;
 import de.prob2.jupyter.ProBKernel;
 
 import io.github.spencerpark.jupyter.kernel.ReplacementOptions;
@@ -18,6 +23,8 @@ import org.jetbrains.annotations.NotNull;
 import org.jetbrains.annotations.Nullable;
 
 public final class FindCommand implements Command {
+	private static final @NotNull PositionalParameter.RequiredRemainder PREDICATE_PARAM = new PositionalParameter.RequiredRemainder("predicate");
+	
 	private final @NotNull Provider<@NotNull ProBKernel> kernelProvider;
 	private final @NotNull AnimationSelector animationSelector;
 	
@@ -34,6 +41,11 @@ public final class FindCommand implements Command {
 		return ":find";
 	}
 	
+	@Override
+	public @NotNull Parameters getParameters() {
+		return new Parameters(Collections.singletonList(PREDICATE_PARAM));
+	}
+	
 	@Override
 	public @NotNull String getSyntax() {
 		return ":find PREDICATE";
@@ -51,9 +63,9 @@ public final class FindCommand implements Command {
 	}
 	
 	@Override
-	public @NotNull DisplayData run(final @NotNull String argString) {
+	public @NotNull DisplayData run(final @NotNull ParsedArguments args) {
 		final Trace trace = this.animationSelector.getCurrentTrace();
-		final String code = this.kernelProvider.get().insertLetVariables(argString);
+		final String code = this.kernelProvider.get().insertLetVariables(args.get(PREDICATE_PARAM));
 		final Trace newTrace = CommandUtils.withSourceCode(code, () -> {
 			final IEvalElement pred = trace.getModel().parseFormula(code, FormulaExpand.EXPAND);
 			return trace.getStateSpace().getTraceToState(pred);
diff --git a/src/main/java/de/prob2/jupyter/commands/GotoCommand.java b/src/main/java/de/prob2/jupyter/commands/GotoCommand.java
index 14c07f32731c64d8a504fd0567cbcc17a6625fec..78770f246d78b5e71501def594846a7ef1a2f265 100644
--- a/src/main/java/de/prob2/jupyter/commands/GotoCommand.java
+++ b/src/main/java/de/prob2/jupyter/commands/GotoCommand.java
@@ -1,11 +1,16 @@
 package de.prob2.jupyter.commands;
 
+import java.util.Collections;
+
 import com.google.inject.Inject;
 
 import de.prob.statespace.AnimationSelector;
 import de.prob.statespace.Trace;
 
 import de.prob2.jupyter.Command;
+import de.prob2.jupyter.Parameters;
+import de.prob2.jupyter.ParsedArguments;
+import de.prob2.jupyter.PositionalParameter;
 import de.prob2.jupyter.UserErrorException;
 
 import io.github.spencerpark.jupyter.kernel.ReplacementOptions;
@@ -15,6 +20,8 @@ import org.jetbrains.annotations.NotNull;
 import org.jetbrains.annotations.Nullable;
 
 public final class GotoCommand implements Command {
+	private static final @NotNull PositionalParameter.RequiredSingle INDEX_PARAM = new PositionalParameter.RequiredSingle("index");
+	
 	private final @NotNull AnimationSelector animationSelector;
 	
 	@Inject
@@ -29,6 +36,11 @@ public final class GotoCommand implements Command {
 		return ":goto";
 	}
 	
+	@Override
+	public @NotNull Parameters getParameters() {
+		return new Parameters(Collections.singletonList(INDEX_PARAM));
+	}
+	
 	@Override
 	public @NotNull String getSyntax() {
 		return ":goto INDEX";
@@ -46,8 +58,8 @@ public final class GotoCommand implements Command {
 	}
 	
 	@Override
-	public @NotNull DisplayData run(final @NotNull String argString) {
-		final int index = Integer.parseInt(argString);
+	public @NotNull DisplayData run(final @NotNull ParsedArguments args) {
+		final int index = Integer.parseInt(args.get(INDEX_PARAM));
 		final Trace trace = this.animationSelector.getCurrentTrace();
 		if (index < -1 || index >= trace.size()) {
 			throw new UserErrorException(String.format("Invalid trace index %d, must be in -1..%d", index, trace.size()-1));
diff --git a/src/main/java/de/prob2/jupyter/commands/GroovyCommand.java b/src/main/java/de/prob2/jupyter/commands/GroovyCommand.java
index ad1e99df64392316d690070c1d4a7d19f041359c..43bfe4f67b5daa8dfb97fce8484c8bd5a0570202 100644
--- a/src/main/java/de/prob2/jupyter/commands/GroovyCommand.java
+++ b/src/main/java/de/prob2/jupyter/commands/GroovyCommand.java
@@ -1,5 +1,6 @@
 package de.prob2.jupyter.commands;
 
+import java.util.Collections;
 import java.util.Objects;
 
 import javax.script.ScriptEngine;
@@ -11,6 +12,9 @@ import com.google.inject.Injector;
 import de.prob.scripting.ScriptEngineProvider;
 
 import de.prob2.jupyter.Command;
+import de.prob2.jupyter.Parameters;
+import de.prob2.jupyter.ParsedArguments;
+import de.prob2.jupyter.PositionalParameter;
 import de.prob2.jupyter.ProBKernel;
 
 import io.github.spencerpark.jupyter.kernel.ReplacementOptions;
@@ -20,6 +24,8 @@ import org.jetbrains.annotations.NotNull;
 import org.jetbrains.annotations.Nullable;
 
 public final class GroovyCommand implements Command {
+	private static final @NotNull PositionalParameter.RequiredRemainder EXPRESSION_PARAM = new PositionalParameter.RequiredRemainder("expression");
+	
 	private final @NotNull Injector injector;
 	private final @NotNull ScriptEngine groovyScriptEngine;
 	
@@ -36,6 +42,11 @@ public final class GroovyCommand implements Command {
 		return ":groovy";
 	}
 	
+	@Override
+	public @NotNull Parameters getParameters() {
+		return new Parameters(Collections.singletonList(EXPRESSION_PARAM));
+	}
+	
 	@Override
 	public @NotNull String getSyntax() {
 		return ":groovy EXPRESSION";
@@ -52,11 +63,11 @@ public final class GroovyCommand implements Command {
 	}
 	
 	@Override
-	public @NotNull DisplayData run(final @NotNull String argString) {
+	public @NotNull DisplayData run(final @NotNull ParsedArguments args) {
 		this.groovyScriptEngine.put("__console", this.injector.getInstance(ProBKernel.class).getIO().out);
 		final Object result;
 		try {
-			result = this.groovyScriptEngine.eval(argString);
+			result = this.groovyScriptEngine.eval(args.get(EXPRESSION_PARAM));
 		} catch (ScriptException e) {
 			throw new RuntimeException(e);
 		} finally {
diff --git a/src/main/java/de/prob2/jupyter/commands/HelpCommand.java b/src/main/java/de/prob2/jupyter/commands/HelpCommand.java
index 5203da148a898072b781950311475b589d1d21cf..487e63a9d8c672221912f1edf3c41074fc1da2de 100644
--- a/src/main/java/de/prob2/jupyter/commands/HelpCommand.java
+++ b/src/main/java/de/prob2/jupyter/commands/HelpCommand.java
@@ -13,7 +13,9 @@ import com.google.inject.Inject;
 import com.google.inject.Injector;
 
 import de.prob2.jupyter.Command;
-import de.prob2.jupyter.CommandUtils;
+import de.prob2.jupyter.Parameters;
+import de.prob2.jupyter.ParsedArguments;
+import de.prob2.jupyter.PositionalParameter;
 import de.prob2.jupyter.ProBKernel;
 import de.prob2.jupyter.UserErrorException;
 
@@ -24,6 +26,8 @@ import org.jetbrains.annotations.NotNull;
 import org.jetbrains.annotations.Nullable;
 
 public final class HelpCommand implements Command {
+	private static final @NotNull PositionalParameter.OptionalSingle COMMAND_NAME_PARAM = new PositionalParameter.OptionalSingle("commandName");
+	
 	private static final @NotNull Map<@NotNull String, @NotNull List<@NotNull Class<? extends Command>>> COMMAND_CLASS_CATEGORIES;
 	static {
 		final Map<String, List<Class<? extends Command>>> commandCategories = new LinkedHashMap<>();
@@ -73,6 +77,11 @@ public final class HelpCommand implements Command {
 		return ":help";
 	}
 	
+	@Override
+	public @NotNull Parameters getParameters() {
+		return new Parameters(Collections.singletonList(COMMAND_NAME_PARAM));
+	}
+	
 	@Override
 	public @NotNull String getSyntax() {
 		return ":help [COMMAND]";
@@ -89,10 +98,9 @@ public final class HelpCommand implements Command {
 	}
 	
 	@Override
-	public @NotNull DisplayData run(final @NotNull String argString) {
+	public @NotNull DisplayData run(final @NotNull ParsedArguments args) {
 		final ProBKernel kernel = this.injector.getInstance(ProBKernel.class);
-		final List<String> args = CommandUtils.splitArgs(argString);
-		if (args.isEmpty()) {
+		if (!args.get(COMMAND_NAME_PARAM).isPresent()) {
 			final StringBuilder sb = new StringBuilder("Enter a B expression or predicate to evaluate it. To load a B machine, enter its source code directly, or use :load to load an external machine file.\nYou can also use any of the following commands. For more help on a particular command, run :help commandname.\n");
 			final StringBuilder sbMarkdown = new StringBuilder("Enter a B expression or predicate to evaluate it. To load a B machine, enter its source code directly, or use `:load` to load an external machine file.\n\nYou can also use any of the following commands. For more help on a particular command, run `:help commandname`.\n");
 			
@@ -141,8 +149,8 @@ public final class HelpCommand implements Command {
 			final DisplayData result = new DisplayData(sb.toString());
 			result.putMarkdown(sbMarkdown.toString());
 			return result;
-		} else if (args.size() == 1) {
-			String commandName = args.get(0);
+		} else {
+			String commandName = args.get(COMMAND_NAME_PARAM).get();
 			// If the user entered a command name without colons, add one or two colons as appropriate.
 			// (If the command cannot be found, no colons are added, because we'll error out later anyway.)
 			if (!commandName.startsWith(":")) {
@@ -158,8 +166,6 @@ public final class HelpCommand implements Command {
 				throw new UserErrorException(String.format("Cannot display help for unknown command \"%s\"", commandName));
 			}
 			return command.renderHelp();
-		} else {
-			throw new UserErrorException("Expected at most 1 argument, got " + args.size());
 		}
 	}
 	
diff --git a/src/main/java/de/prob2/jupyter/commands/InitialiseCommand.java b/src/main/java/de/prob2/jupyter/commands/InitialiseCommand.java
index 1599a1d3e879061b0a6c9bd7c03328704c0dae56..b7bf1555d896452bd6a7559bf3e6d7c3aa1c96fa 100644
--- a/src/main/java/de/prob2/jupyter/commands/InitialiseCommand.java
+++ b/src/main/java/de/prob2/jupyter/commands/InitialiseCommand.java
@@ -12,6 +12,9 @@ import de.prob.statespace.Trace;
 import de.prob.statespace.Transition;
 import de.prob2.jupyter.Command;
 import de.prob2.jupyter.CommandUtils;
+import de.prob2.jupyter.Parameters;
+import de.prob2.jupyter.ParsedArguments;
+import de.prob2.jupyter.PositionalParameter;
 import de.prob2.jupyter.ProBKernel;
 
 import io.github.spencerpark.jupyter.kernel.ReplacementOptions;
@@ -21,6 +24,8 @@ import org.jetbrains.annotations.NotNull;
 import org.jetbrains.annotations.Nullable;
 
 public final class InitialiseCommand implements Command {
+	private static final @NotNull PositionalParameter.OptionalRemainder PREDICATE_PARAM = new PositionalParameter.OptionalRemainder("predicate");
+	
 	private final @NotNull Provider<@NotNull ProBKernel> kernelProvider;
 	private final @NotNull AnimationSelector animationSelector;
 	
@@ -37,6 +42,11 @@ public final class InitialiseCommand implements Command {
 		return ":init";
 	}
 	
+	@Override
+	public @NotNull Parameters getParameters() {
+		return new Parameters(Collections.singletonList(PREDICATE_PARAM));
+	}
+	
 	@Override
 	public @NotNull String getSyntax() {
 		return ":init [PREDICATE]";
@@ -53,13 +63,13 @@ public final class InitialiseCommand implements Command {
 	}
 	
 	@Override
-	public @NotNull DisplayData run(final @NotNull String argString) {
+	public @NotNull DisplayData run(final @NotNull ParsedArguments args) {
 		final Trace trace = this.animationSelector.getCurrentTrace();
 		final String predicate;
-		if (argString.isEmpty()) {
+		if (!args.get(PREDICATE_PARAM).isPresent()) {
 			predicate = "1=1";
 		} else {
-			predicate = this.kernelProvider.get().insertLetVariables(argString);
+			predicate = this.kernelProvider.get().insertLetVariables(args.get(PREDICATE_PARAM).get());
 		}
 		final List<Transition> ops = trace.getStateSpace().transitionFromPredicate(trace.getCurrentState(), "$initialise_machine", predicate, 1);
 		assert !ops.isEmpty();
diff --git a/src/main/java/de/prob2/jupyter/commands/LetCommand.java b/src/main/java/de/prob2/jupyter/commands/LetCommand.java
index 92332774df85f93dff0f492c6ac30414bd9266cb..4d7d6826188e007c080cd830d07e033c04c3fe71 100644
--- a/src/main/java/de/prob2/jupyter/commands/LetCommand.java
+++ b/src/main/java/de/prob2/jupyter/commands/LetCommand.java
@@ -1,6 +1,6 @@
 package de.prob2.jupyter.commands;
 
-import java.util.List;
+import java.util.Arrays;
 
 import com.google.inject.Inject;
 import com.google.inject.Provider;
@@ -11,8 +11,10 @@ import de.prob.animator.domainobjects.FormulaExpand;
 import de.prob.statespace.AnimationSelector;
 import de.prob2.jupyter.Command;
 import de.prob2.jupyter.CommandUtils;
+import de.prob2.jupyter.Parameters;
+import de.prob2.jupyter.ParsedArguments;
+import de.prob2.jupyter.PositionalParameter;
 import de.prob2.jupyter.ProBKernel;
-import de.prob2.jupyter.UserErrorException;
 
 import io.github.spencerpark.jupyter.kernel.ReplacementOptions;
 import io.github.spencerpark.jupyter.kernel.display.DisplayData;
@@ -21,6 +23,9 @@ import org.jetbrains.annotations.NotNull;
 import org.jetbrains.annotations.Nullable;
 
 public final class LetCommand implements Command {
+	private static final @NotNull PositionalParameter.RequiredSingle NAME_PARAM = new PositionalParameter.RequiredSingle("name");
+	private static final @NotNull PositionalParameter.RequiredRemainder EXPRESSION_PARAM = new PositionalParameter.RequiredRemainder("expression");
+	
 	private final @NotNull Provider<@NotNull ProBKernel> kernelProvider;
 	private final @NotNull AnimationSelector animationSelector;
 	
@@ -37,6 +42,11 @@ public final class LetCommand implements Command {
 		return ":let";
 	}
 	
+	@Override
+	public @NotNull Parameters getParameters() {
+		return new Parameters(Arrays.asList(NAME_PARAM, EXPRESSION_PARAM));
+	}
+	
 	@Override
 	public @NotNull String getSyntax() {
 		return ":let NAME EXPR";
@@ -55,13 +65,9 @@ public final class LetCommand implements Command {
 	}
 	
 	@Override
-	public @NotNull DisplayData run(final @NotNull String argString) {
-		final List<String> split = CommandUtils.splitArgs(argString, 2);
-		if (split.size() != 2) {
-			throw new UserErrorException("Expected 2 arguments, not " + split.size());
-		}
-		final String name = split.get(0);
-		final String expr = this.kernelProvider.get().insertLetVariables(split.get(1));
+	public @NotNull DisplayData run(final @NotNull ParsedArguments args) {
+		final String name = args.get(NAME_PARAM);
+		final String expr = this.kernelProvider.get().insertLetVariables(args.get(EXPRESSION_PARAM));
 		final AbstractEvalResult evaluated = CommandUtils.withSourceCode(expr, () -> this.animationSelector.getCurrentTrace().evalCurrent(expr, FormulaExpand.EXPAND));
 		if (evaluated instanceof EvalResult) {
 			this.kernelProvider.get().getVariables().put(name, evaluated.toString());
diff --git a/src/main/java/de/prob2/jupyter/commands/LoadCellCommand.java b/src/main/java/de/prob2/jupyter/commands/LoadCellCommand.java
index e08a3c0fb1b5ab19ce67a0dc823b7fa4c16c30c4..3f115cb5de179fae11f89ac8c5b2384a9a71a8fd 100644
--- a/src/main/java/de/prob2/jupyter/commands/LoadCellCommand.java
+++ b/src/main/java/de/prob2/jupyter/commands/LoadCellCommand.java
@@ -1,6 +1,7 @@
 package de.prob2.jupyter.commands;
 
 import java.nio.file.Paths;
+import java.util.Collections;
 import java.util.List;
 import java.util.Map;
 
@@ -12,6 +13,9 @@ import de.prob.statespace.AnimationSelector;
 import de.prob.statespace.Trace;
 import de.prob2.jupyter.Command;
 import de.prob2.jupyter.CommandUtils;
+import de.prob2.jupyter.Parameters;
+import de.prob2.jupyter.ParsedArguments;
+import de.prob2.jupyter.PositionalParameter;
 import de.prob2.jupyter.ProBKernel;
 import de.prob2.jupyter.UserErrorException;
 
@@ -22,6 +26,8 @@ import org.jetbrains.annotations.NotNull;
 import org.jetbrains.annotations.Nullable;
 
 public final class LoadCellCommand implements Command {
+	private static final @NotNull PositionalParameter.RequiredRemainder PREFS_AND_CODE_PARAM = new PositionalParameter.RequiredRemainder("prefsAndCode");
+	
 	private final @NotNull ClassicalBFactory classicalBFactory;
 	private final @NotNull AnimationSelector animationSelector;
 	private final @NotNull Provider<ProBKernel> proBKernelProvider;
@@ -44,6 +50,11 @@ public final class LoadCellCommand implements Command {
 		return "::load";
 	}
 	
+	@Override
+	public @NotNull Parameters getParameters() {
+		return new Parameters(Collections.singletonList(PREFS_AND_CODE_PARAM));
+	}
+	
 	@Override
 	public @NotNull String getSyntax() {
 		return "MACHINE\n...\nEND\n\n// or\n\n::load [PREF=VALUE ...]\nMACHINE\n...\nEND";
@@ -62,15 +73,15 @@ public final class LoadCellCommand implements Command {
 	}
 	
 	@Override
-	public @NotNull DisplayData run(final @NotNull String argString) {
-		final String[] split = argString.split("\n", 2);
+	public @NotNull DisplayData run(final @NotNull ParsedArguments args) {
+		final String[] split = args.get(PREFS_AND_CODE_PARAM).split("\n", 2);
 		if (split.length != 2) {
 			throw new UserErrorException("Missing command body");
 		}
 		final String prefsString = split[0];
 		final String body = split[1];
-		final List<String> args = CommandUtils.splitArgs(prefsString);
-		final Map<String, String> preferences = CommandUtils.parsePreferences(args);
+		final List<String> prefsSplit = CommandUtils.splitArgs(prefsString);
+		final Map<String, String> preferences = CommandUtils.parsePreferences(prefsSplit);
 		
 		this.animationSelector.changeCurrentAnimation(new Trace(CommandUtils.withSourceCode(body, () ->
 			this.classicalBFactory.create("(machine from Jupyter cell)", body).load(preferences)
diff --git a/src/main/java/de/prob2/jupyter/commands/LoadFileCommand.java b/src/main/java/de/prob2/jupyter/commands/LoadFileCommand.java
index 5d14a5de3c73f1f22eea81c8ad8e918adbc3af62..4d2155efd15607e716214ac36aef306265397a4d 100644
--- a/src/main/java/de/prob2/jupyter/commands/LoadFileCommand.java
+++ b/src/main/java/de/prob2/jupyter/commands/LoadFileCommand.java
@@ -4,6 +4,8 @@ import java.io.IOException;
 import java.nio.file.Files;
 import java.nio.file.Path;
 import java.nio.file.Paths;
+import java.util.Arrays;
+import java.util.Collections;
 import java.util.List;
 import java.util.Map;
 import java.util.stream.Collectors;
@@ -20,6 +22,9 @@ import de.prob.statespace.AnimationSelector;
 import de.prob.statespace.Trace;
 import de.prob2.jupyter.Command;
 import de.prob2.jupyter.CommandUtils;
+import de.prob2.jupyter.Parameters;
+import de.prob2.jupyter.ParsedArguments;
+import de.prob2.jupyter.PositionalParameter;
 import de.prob2.jupyter.ProBKernel;
 import de.prob2.jupyter.UserErrorException;
 
@@ -34,6 +39,9 @@ import org.slf4j.LoggerFactory;
 public final class LoadFileCommand implements Command {
 	private static final @NotNull Logger LOGGER = LoggerFactory.getLogger(LoadFileCommand.class);
 	
+	private static final @NotNull PositionalParameter.RequiredSingle FILE_NAME_PARAM = new PositionalParameter.RequiredSingle("fileName");
+	private static final @NotNull PositionalParameter.OptionalRemainder PREFS_PARAM = new PositionalParameter.OptionalRemainder("prefs");
+	
 	private final @NotNull Injector injector;
 	private final @NotNull AnimationSelector animationSelector;
 	private final @NotNull Provider<ProBKernel> proBKernelProvider;
@@ -56,6 +64,11 @@ public final class LoadFileCommand implements Command {
 		return ":load";
 	}
 	
+	@Override
+	public @NotNull Parameters getParameters() {
+		return new Parameters(Arrays.asList(FILE_NAME_PARAM, PREFS_PARAM));
+	}
+	
 	@Override
 	public @NotNull String getSyntax() {
 		return ":load FILENAME [PREF=VALUE ...]";
@@ -73,13 +86,8 @@ public final class LoadFileCommand implements Command {
 	}
 	
 	@Override
-	public @NotNull DisplayData run(final @NotNull String argString) {
-		final List<String> args = CommandUtils.splitArgs(argString);
-		if (args.isEmpty()) {
-			throw new UserErrorException("Missing machine file name");
-		}
-		
-		final Path machineFilePath = Paths.get(args.get(0));
+	public @NotNull DisplayData run(final @NotNull ParsedArguments args) {
+		final Path machineFilePath = Paths.get(args.get(FILE_NAME_PARAM));
 		final String machineFileName = machineFilePath.getFileName().toString();
 		final Path machineFileDirectory = machineFilePath.getParent() == null ? Paths.get("") : machineFilePath.getParent();
 		final int dotIndex = machineFileName.lastIndexOf('.');
@@ -90,7 +98,12 @@ public final class LoadFileCommand implements Command {
 		if (!FactoryProvider.isExtensionKnown(extension)) {
 			throw new UserErrorException("Unsupported file type: ." + extension);
 		}
-		final Map<String, String> preferences = CommandUtils.parsePreferences(args.subList(1, args.size()));
+		final Map<String, String> preferences;
+		if (args.get(PREFS_PARAM).isPresent()) {
+			preferences = CommandUtils.parsePreferences(CommandUtils.splitArgs(args.get(PREFS_PARAM).get()));
+		} else {
+			preferences = Collections.emptyMap();
+		}
 		
 		try {
 			final ModelFactory<?> factory = this.injector.getInstance(FactoryProvider.factoryClassFromExtension(extension));
diff --git a/src/main/java/de/prob2/jupyter/commands/ModelCheckCommand.java b/src/main/java/de/prob2/jupyter/commands/ModelCheckCommand.java
index 7356c6e89b23894ca5e2110e697ea22df110e3c1..8c45b168e80d43c306b85abb001d0e4a9c49bef4 100644
--- a/src/main/java/de/prob2/jupyter/commands/ModelCheckCommand.java
+++ b/src/main/java/de/prob2/jupyter/commands/ModelCheckCommand.java
@@ -16,6 +16,8 @@ import de.prob.check.StateSpaceStats;
 import de.prob.statespace.AnimationSelector;
 import de.prob.statespace.StateSpace;
 import de.prob2.jupyter.Command;
+import de.prob2.jupyter.Parameters;
+import de.prob2.jupyter.ParsedArguments;
 import de.prob2.jupyter.ProBKernel;
 import de.prob2.jupyter.UserErrorException;
 
@@ -45,6 +47,11 @@ public final class ModelCheckCommand implements Command {
 		return ":modelcheck";
 	}
 	
+	@Override
+	public @NotNull Parameters getParameters() {
+		return Parameters.NONE;
+	}
+	
 	@Override
 	public @NotNull String getSyntax() {
 		return ":modelcheck";
@@ -80,11 +87,7 @@ public final class ModelCheckCommand implements Command {
 	}
 	
 	@Override
-	public @NotNull DisplayData run(final @NotNull String argString) {
-		if (!argString.isEmpty()) {
-			throw new UserErrorException("Unexpected argument: " + argString);
-		}
-		
+	public @NotNull DisplayData run(final @NotNull ParsedArguments args) {
 		final StateSpace stateSpace = this.animationSelector.getCurrentTrace().getStateSpace();
 		
 		final JupyterIO io = this.kernelProvider.get().getIO();
diff --git a/src/main/java/de/prob2/jupyter/commands/PrefCommand.java b/src/main/java/de/prob2/jupyter/commands/PrefCommand.java
index 2be853717d8dc08dba01c75ae01808b7950103f1..89df593cd20dca25f67ea375ad1df8a8364889f4 100644
--- a/src/main/java/de/prob2/jupyter/commands/PrefCommand.java
+++ b/src/main/java/de/prob2/jupyter/commands/PrefCommand.java
@@ -1,6 +1,7 @@
 package de.prob2.jupyter.commands;
 
 import java.util.ArrayList;
+import java.util.Collections;
 import java.util.List;
 import java.util.TreeMap;
 
@@ -14,6 +15,9 @@ import de.prob.statespace.AnimationSelector;
 
 import de.prob2.jupyter.Command;
 import de.prob2.jupyter.CommandUtils;
+import de.prob2.jupyter.Parameters;
+import de.prob2.jupyter.ParsedArguments;
+import de.prob2.jupyter.PositionalParameter;
 import de.prob2.jupyter.UserErrorException;
 
 import io.github.spencerpark.jupyter.kernel.ReplacementOptions;
@@ -23,6 +27,8 @@ import org.jetbrains.annotations.NotNull;
 import org.jetbrains.annotations.Nullable;
 
 public final class PrefCommand implements Command {
+	private static final @NotNull PositionalParameter.OptionalRemainder PREFS_PARAM = new PositionalParameter.OptionalRemainder("prefs");
+	
 	private final @NotNull AnimationSelector animationSelector;
 	
 	@Inject
@@ -37,6 +43,11 @@ public final class PrefCommand implements Command {
 		return ":pref";
 	}
 	
+	@Override
+	public @NotNull Parameters getParameters() {
+		return new Parameters(Collections.singletonList(PREFS_PARAM));
+	}
+	
 	@Override
 	public @NotNull String getSyntax() {
 		return ":pref [NAME ...]\n// or\n:pref NAME=VALUE [NAME=VALUE ...]";
@@ -54,10 +65,9 @@ public final class PrefCommand implements Command {
 	}
 	
 	@Override
-	public @NotNull DisplayData run(final @NotNull String argString) {
-		final List<String> args = CommandUtils.splitArgs(argString);
+	public @NotNull DisplayData run(final @NotNull ParsedArguments args) {
 		final StringBuilder sb = new StringBuilder();
-		if (args.isEmpty()) {
+		if (!args.get(PREFS_PARAM).isPresent()) {
 			final GetCurrentPreferencesCommand cmd = new GetCurrentPreferencesCommand();
 			this.animationSelector.getCurrentTrace().getStateSpace().execute(cmd);
 			// TreeMap is used to sort the preferences by name.
@@ -67,31 +77,34 @@ public final class PrefCommand implements Command {
 				sb.append(v);
 				sb.append('\n');
 			});
-		} else if (args.get(0).contains("=")) {
-			final List<SetPreferenceCommand> cmds = new ArrayList<>();
-			CommandUtils.parsePreferences(args).forEach((pref, value) -> {
-				cmds.add(new SetPreferenceCommand(pref, value));
-				sb.append("Preference changed: ");
-				sb.append(pref);
-				sb.append(" = ");
-				sb.append(value);
-				sb.append('\n');
-			});
-			this.animationSelector.getCurrentTrace().getStateSpace().execute(new ComposedCommand(cmds));
 		} else {
-			final List<GetPreferenceCommand> cmds = new ArrayList<>();
-			for (final String arg : args) {
-				if (arg.contains("=")) {
-					throw new UserErrorException(String.format("Cannot view and change preferences in the same command (attempted to assign preference %s)", arg));
+			final List<String> prefsSplit = CommandUtils.splitArgs(args.get(PREFS_PARAM).get());
+			if (prefsSplit.get(0).contains("=")) {
+				final List<SetPreferenceCommand> cmds = new ArrayList<>();
+				CommandUtils.parsePreferences(prefsSplit).forEach((pref, value) -> {
+					cmds.add(new SetPreferenceCommand(pref, value));
+					sb.append("Preference changed: ");
+					sb.append(pref);
+					sb.append(" = ");
+					sb.append(value);
+					sb.append('\n');
+				});
+				this.animationSelector.getCurrentTrace().getStateSpace().execute(new ComposedCommand(cmds));
+			} else {
+				final List<GetPreferenceCommand> cmds = new ArrayList<>();
+				for (final String arg : prefsSplit) {
+					if (arg.contains("=")) {
+						throw new UserErrorException(String.format("Cannot view and change preferences in the same command (attempted to assign preference %s)", arg));
+					}
+					cmds.add(new GetPreferenceCommand(arg));
+				}
+				this.animationSelector.getCurrentTrace().getStateSpace().execute(new ComposedCommand(cmds));
+				for (final GetPreferenceCommand cmd : cmds) {
+					sb.append(cmd.getKey());
+					sb.append(" = ");
+					sb.append(cmd.getValue());
+					sb.append('\n');
 				}
-				cmds.add(new GetPreferenceCommand(arg));
-			}
-			this.animationSelector.getCurrentTrace().getStateSpace().execute(new ComposedCommand(cmds));
-			for (final GetPreferenceCommand cmd : cmds) {
-				sb.append(cmd.getKey());
-				sb.append(" = ");
-				sb.append(cmd.getValue());
-				sb.append('\n');
 			}
 		}
 		return new DisplayData(sb.toString());
diff --git a/src/main/java/de/prob2/jupyter/commands/PrettyPrintCommand.java b/src/main/java/de/prob2/jupyter/commands/PrettyPrintCommand.java
index fc0a2a257efd4dbeb44dedc5e68f3cc19f76fb58..a6fe79afe337eb2df2017c68a935788e56e1584c 100644
--- a/src/main/java/de/prob2/jupyter/commands/PrettyPrintCommand.java
+++ b/src/main/java/de/prob2/jupyter/commands/PrettyPrintCommand.java
@@ -1,5 +1,7 @@
 package de.prob2.jupyter.commands;
 
+import java.util.Collections;
+
 import com.google.inject.Inject;
 
 import de.prob.animator.command.PrettyPrintFormulaCommand;
@@ -8,6 +10,9 @@ import de.prob.animator.domainobjects.IEvalElement;
 import de.prob.statespace.AnimationSelector;
 import de.prob2.jupyter.Command;
 import de.prob2.jupyter.CommandUtils;
+import de.prob2.jupyter.Parameters;
+import de.prob2.jupyter.ParsedArguments;
+import de.prob2.jupyter.PositionalParameter;
 
 import io.github.spencerpark.jupyter.kernel.ReplacementOptions;
 import io.github.spencerpark.jupyter.kernel.display.DisplayData;
@@ -16,6 +21,8 @@ import org.jetbrains.annotations.NotNull;
 import org.jetbrains.annotations.Nullable;
 
 public final class PrettyPrintCommand implements Command {
+	private static final @NotNull PositionalParameter.RequiredRemainder PREDICATE_PARAM = new PositionalParameter.RequiredRemainder("predicate");
+	
 	private final AnimationSelector animationSelector;
 	
 	@Inject
@@ -30,6 +37,11 @@ public final class PrettyPrintCommand implements Command {
 		return ":prettyprint";
 	}
 	
+	@Override
+	public @NotNull Parameters getParameters() {
+		return new Parameters(Collections.singletonList(PREDICATE_PARAM));
+	}
+	
 	@Override
 	public @NotNull String getSyntax() {
 		return ":prettyprint PREDICATE";
@@ -47,14 +59,15 @@ public final class PrettyPrintCommand implements Command {
 	}
 	
 	@Override
-	public @NotNull DisplayData run(final @NotNull String argString) {
-		final IEvalElement formula = this.animationSelector.getCurrentTrace().getModel().parseFormula(argString, FormulaExpand.EXPAND);
+	public @NotNull DisplayData run(final @NotNull ParsedArguments args) {
+		final String code = args.get(PREDICATE_PARAM);
+		final IEvalElement formula = this.animationSelector.getCurrentTrace().getModel().parseFormula(code, FormulaExpand.EXPAND);
 		
 		final PrettyPrintFormulaCommand cmdUnicode = new PrettyPrintFormulaCommand(formula, PrettyPrintFormulaCommand.Mode.UNICODE);
 		cmdUnicode.setOptimize(false);
 		final PrettyPrintFormulaCommand cmdLatex = new PrettyPrintFormulaCommand(formula, PrettyPrintFormulaCommand.Mode.LATEX);
 		cmdLatex.setOptimize(false);
-		CommandUtils.withSourceCode(argString, () -> this.animationSelector.getCurrentTrace().getStateSpace().execute(cmdUnicode, cmdLatex));
+		CommandUtils.withSourceCode(code, () -> this.animationSelector.getCurrentTrace().getStateSpace().execute(cmdUnicode, cmdLatex));
 		
 		final DisplayData ret = new DisplayData(cmdUnicode.getPrettyPrint());
 		ret.putLatex('$' + cmdLatex.getPrettyPrint() + '$');
diff --git a/src/main/java/de/prob2/jupyter/commands/RenderCommand.java b/src/main/java/de/prob2/jupyter/commands/RenderCommand.java
index 51d77d6961275e27156b94e882768f7d595fe4c3..4e3785592479efbe57ba51d6fcc805452ce26887 100644
--- a/src/main/java/de/prob2/jupyter/commands/RenderCommand.java
+++ b/src/main/java/de/prob2/jupyter/commands/RenderCommand.java
@@ -1,8 +1,13 @@
 package de.prob2.jupyter.commands;
 
+import java.util.Collections;
+
 import com.google.inject.Inject;
 
 import de.prob2.jupyter.Command;
+import de.prob2.jupyter.Parameters;
+import de.prob2.jupyter.ParsedArguments;
+import de.prob2.jupyter.PositionalParameter;
 import de.prob2.jupyter.UserErrorException;
 
 import io.github.spencerpark.jupyter.kernel.ReplacementOptions;
@@ -12,6 +17,8 @@ import org.jetbrains.annotations.NotNull;
 import org.jetbrains.annotations.Nullable;
 
 public final class RenderCommand implements Command {
+	private static final @NotNull PositionalParameter.RequiredRemainder MIME_TYPE_AND_CONTENT_PARAM = new PositionalParameter.RequiredRemainder("mimeTypeAndContent");
+	
 	@Inject
 	private RenderCommand() {
 		super();
@@ -22,6 +29,11 @@ public final class RenderCommand implements Command {
 		return "::render";
 	}
 	
+	@Override
+	public @NotNull Parameters getParameters() {
+		return new Parameters(Collections.singletonList(MIME_TYPE_AND_CONTENT_PARAM));
+	}
+	
 	@Override
 	public @NotNull String getSyntax() {
 		return "::render MIMETYPE\nCONTENT";
@@ -39,8 +51,8 @@ public final class RenderCommand implements Command {
 	}
 	
 	@Override
-	public @NotNull DisplayData run(final @NotNull String argString) {
-		final String[] split = argString.split("\n", 2);
+	public @NotNull DisplayData run(final @NotNull ParsedArguments args) {
+		final String[] split = args.get(MIME_TYPE_AND_CONTENT_PARAM).split("\n", 2);
 		if (split.length != 2) {
 			throw new UserErrorException("Missing content (the content cannot be placed on the same line as the command)");
 		}
diff --git a/src/main/java/de/prob2/jupyter/commands/ShowCommand.java b/src/main/java/de/prob2/jupyter/commands/ShowCommand.java
index 592ba3786150c106aa64b1181dee92e9294b3ec9..84441d3612e742607cc4e30ef14f4f6eed8bd71c 100644
--- a/src/main/java/de/prob2/jupyter/commands/ShowCommand.java
+++ b/src/main/java/de/prob2/jupyter/commands/ShowCommand.java
@@ -16,6 +16,8 @@ import de.prob.animator.domainobjects.AnimationMatrixEntry;
 import de.prob.statespace.AnimationSelector;
 import de.prob.statespace.Trace;
 import de.prob2.jupyter.Command;
+import de.prob2.jupyter.Parameters;
+import de.prob2.jupyter.ParsedArguments;
 import de.prob2.jupyter.ProBKernel;
 import de.prob2.jupyter.UserErrorException;
 
@@ -42,6 +44,11 @@ public final class ShowCommand implements Command {
 		return ":show";
 	}
 	
+	@Override
+	public @NotNull Parameters getParameters() {
+		return Parameters.NONE;
+	}
+	
 	@Override
 	public @NotNull String getSyntax() {
 		return ":show";
@@ -58,11 +65,7 @@ public final class ShowCommand implements Command {
 	}
 	
 	@Override
-	public @NotNull DisplayData run(final @NotNull String argString) {
-		if (!argString.isEmpty()) {
-			throw new UserErrorException("Expected no arguments");
-		}
-		
+	public @NotNull DisplayData run(final @NotNull ParsedArguments args) {
 		final Trace trace = this.animationSelector.getCurrentTrace();
 		
 		if (!trace.getCurrentState().isInitialised()) {
diff --git a/src/main/java/de/prob2/jupyter/commands/SolveCommand.java b/src/main/java/de/prob2/jupyter/commands/SolveCommand.java
index f89ee34f5378666a3bd62731b3c299a16b28deba..d172344c5970dfdf5b90ef8f7ecc68a3168c37a1 100644
--- a/src/main/java/de/prob2/jupyter/commands/SolveCommand.java
+++ b/src/main/java/de/prob2/jupyter/commands/SolveCommand.java
@@ -16,6 +16,9 @@ import de.prob.statespace.Trace;
 
 import de.prob2.jupyter.Command;
 import de.prob2.jupyter.CommandUtils;
+import de.prob2.jupyter.Parameters;
+import de.prob2.jupyter.ParsedArguments;
+import de.prob2.jupyter.PositionalParameter;
 import de.prob2.jupyter.ProBKernel;
 import de.prob2.jupyter.UserErrorException;
 
@@ -26,6 +29,9 @@ import org.jetbrains.annotations.NotNull;
 import org.jetbrains.annotations.Nullable;
 
 public final class SolveCommand implements Command {
+	private static final @NotNull PositionalParameter.RequiredSingle SOLVER_PARAM = new PositionalParameter.RequiredSingle("solver");
+	private static final @NotNull PositionalParameter.RequiredRemainder PREDICATE_PARAM = new PositionalParameter.RequiredRemainder("predicate");
+	
 	private static final @NotNull Map<@NotNull String, CbcSolveCommand.@NotNull Solvers> SOLVERS = Arrays.stream(CbcSolveCommand.Solvers.values())
 		.collect(Collectors.toMap(s -> s.name().toLowerCase(), s -> s));
 	
@@ -45,6 +51,11 @@ public final class SolveCommand implements Command {
 		return ":solve";
 	}
 	
+	@Override
+	public @NotNull Parameters getParameters() {
+		return new Parameters(Arrays.asList(SOLVER_PARAM, PREDICATE_PARAM));
+	}
+	
 	@Override
 	public @NotNull String getSyntax() {
 		return ":solve SOLVER PREDICATE";
@@ -67,18 +78,13 @@ public final class SolveCommand implements Command {
 	}
 	
 	@Override
-	public @NotNull DisplayData run(final @NotNull String argString) {
-		final List<String> split = CommandUtils.splitArgs(argString, 2);
-		if (split.size() != 2) {
-			throw new UserErrorException("Expected 2 arguments, got 1");
-		}
-		
+	public @NotNull DisplayData run(final @NotNull ParsedArguments args) {
 		final Trace trace = this.animationSelector.getCurrentTrace();
-		final CbcSolveCommand.Solvers solver = SOLVERS.get(split.get(0));
+		final CbcSolveCommand.Solvers solver = SOLVERS.get(args.get(SOLVER_PARAM));
 		if (solver == null) {
-			throw new UserErrorException("Unknown solver: " + split.get(0));
+			throw new UserErrorException("Unknown solver: " + args.get(SOLVER_PARAM));
 		}
-		final String code = this.kernelProvider.get().insertLetVariables(split.get(1));
+		final String code = this.kernelProvider.get().insertLetVariables(args.get(PREDICATE_PARAM));
 		final IEvalElement predicate = CommandUtils.withSourceCode(code, () -> trace.getModel().parseFormula(code, FormulaExpand.EXPAND));
 		
 		final CbcSolveCommand cmd = new CbcSolveCommand(predicate, solver, this.animationSelector.getCurrentTrace().getCurrentState());
diff --git a/src/main/java/de/prob2/jupyter/commands/StatsCommand.java b/src/main/java/de/prob2/jupyter/commands/StatsCommand.java
index 212fd82609d86c3a6028b13c44935eb62e378e55..8c180dc3ebd6f9d74035aab0fd77827b6a5d2dd3 100644
--- a/src/main/java/de/prob2/jupyter/commands/StatsCommand.java
+++ b/src/main/java/de/prob2/jupyter/commands/StatsCommand.java
@@ -5,9 +5,9 @@ import com.google.inject.Inject;
 import de.prob.animator.command.ComputeStateSpaceStatsCommand;
 import de.prob.check.StateSpaceStats;
 import de.prob.statespace.AnimationSelector;
-
 import de.prob2.jupyter.Command;
-import de.prob2.jupyter.UserErrorException;
+import de.prob2.jupyter.Parameters;
+import de.prob2.jupyter.ParsedArguments;
 
 import io.github.spencerpark.jupyter.kernel.ReplacementOptions;
 import io.github.spencerpark.jupyter.kernel.display.DisplayData;
@@ -30,6 +30,11 @@ public final class StatsCommand implements Command {
 		return ":stats";
 	}
 	
+	@Override
+	public @NotNull Parameters getParameters() {
+		return Parameters.NONE;
+	}
+	
 	@Override
 	public @NotNull String getSyntax() {
 		return ":stats";
@@ -46,10 +51,7 @@ public final class StatsCommand implements Command {
 	}
 	
 	@Override
-	public @NotNull DisplayData run(final @NotNull String argString) {
-		if (!argString.isEmpty()) {
-			throw new UserErrorException("Expected no arguments");
-		}
+	public @NotNull DisplayData run(final @NotNull ParsedArguments args) {
 		final ComputeStateSpaceStatsCommand cmd = new ComputeStateSpaceStatsCommand();
 		this.animationSelector.getCurrentTrace().getStateSpace().execute(cmd);
 		final StateSpaceStats stats = cmd.getResult();
diff --git a/src/main/java/de/prob2/jupyter/commands/TableCommand.java b/src/main/java/de/prob2/jupyter/commands/TableCommand.java
index 9a4e3a8800a8066ae469baca0f8b63ab75603db3..c43bb8c83564671f15739665d34de1f74e9afd0b 100644
--- a/src/main/java/de/prob2/jupyter/commands/TableCommand.java
+++ b/src/main/java/de/prob2/jupyter/commands/TableCommand.java
@@ -18,6 +18,9 @@ import de.prob.statespace.Trace;
 import de.prob.unicode.UnicodeTranslator;
 import de.prob2.jupyter.Command;
 import de.prob2.jupyter.CommandUtils;
+import de.prob2.jupyter.Parameters;
+import de.prob2.jupyter.ParsedArguments;
+import de.prob2.jupyter.PositionalParameter;
 import de.prob2.jupyter.ProBKernel;
 
 import io.github.spencerpark.jupyter.kernel.ReplacementOptions;
@@ -27,6 +30,8 @@ import org.jetbrains.annotations.NotNull;
 import org.jetbrains.annotations.Nullable;
 
 public final class TableCommand implements Command {
+	private static final @NotNull PositionalParameter.RequiredRemainder EXPRESSION_PARAM = new PositionalParameter.RequiredRemainder("expression");
+	
 	private final @NotNull Provider<@NotNull ProBKernel> kernelProvider;
 	private final @NotNull AnimationSelector animationSelector;
 	
@@ -43,6 +48,11 @@ public final class TableCommand implements Command {
 		return ":table";
 	}
 	
+	@Override
+	public @NotNull Parameters getParameters() {
+		return new Parameters(Collections.singletonList(EXPRESSION_PARAM));
+	}
+	
 	@Override
 	public @NotNull String getSyntax() {
 		return ":table EXPRESSION";
@@ -59,9 +69,9 @@ public final class TableCommand implements Command {
 	}
 	
 	@Override
-	public @NotNull DisplayData run(final @NotNull String argString) {
+	public @NotNull DisplayData run(final @NotNull ParsedArguments args) {
 		final Trace trace = this.animationSelector.getCurrentTrace();
-		final String code = this.kernelProvider.get().insertLetVariables(argString);
+		final String code = this.kernelProvider.get().insertLetVariables(args.get(EXPRESSION_PARAM));
 		final IEvalElement formula = CommandUtils.withSourceCode(code, () -> trace.getModel().parseFormula(code, FormulaExpand.EXPAND));
 		
 		final GetAllTableCommands cmd1 = new GetAllTableCommands(trace.getCurrentState());
diff --git a/src/main/java/de/prob2/jupyter/commands/TimeCommand.java b/src/main/java/de/prob2/jupyter/commands/TimeCommand.java
index ac426637127262327d04e4882a3b2a72bb0f7deb..aacd756df8ccb57d3805be0d45567c96c983018b 100644
--- a/src/main/java/de/prob2/jupyter/commands/TimeCommand.java
+++ b/src/main/java/de/prob2/jupyter/commands/TimeCommand.java
@@ -2,12 +2,16 @@ package de.prob2.jupyter.commands;
 
 import java.time.Duration;
 import java.time.temporal.ChronoUnit;
+import java.util.Collections;
 
 import com.google.common.base.Stopwatch;
 import com.google.inject.Inject;
 import com.google.inject.Injector;
 
 import de.prob2.jupyter.Command;
+import de.prob2.jupyter.Parameters;
+import de.prob2.jupyter.ParsedArguments;
+import de.prob2.jupyter.PositionalParameter;
 import de.prob2.jupyter.ProBKernel;
 
 import io.github.spencerpark.jupyter.kernel.ReplacementOptions;
@@ -17,6 +21,8 @@ import org.jetbrains.annotations.NotNull;
 import org.jetbrains.annotations.Nullable;
 
 public final class TimeCommand implements Command {
+	private static final @NotNull PositionalParameter.RequiredRemainder COMMAND_AND_ARGS_PARAM = new PositionalParameter.RequiredRemainder("commandAndArgs");
+	
 	private final @NotNull Injector injector;
 	
 	@Inject
@@ -31,6 +37,11 @@ public final class TimeCommand implements Command {
 		return ":time";
 	}
 	
+	@Override
+	public @NotNull Parameters getParameters() {
+		return new Parameters(Collections.singletonList(COMMAND_AND_ARGS_PARAM));
+	}
+	
 	@Override
 	public @NotNull String getSyntax() {
 		return ":time COMMAND [ARGS ...]";
@@ -48,10 +59,10 @@ public final class TimeCommand implements Command {
 	}
 	
 	@Override
-	public @Nullable DisplayData run(final @NotNull String argString) {
+	public @Nullable DisplayData run(final @NotNull ParsedArguments args) {
 		final ProBKernel kernel = this.injector.getInstance(ProBKernel.class);
 		final Stopwatch stopwatch = Stopwatch.createStarted();
-		final DisplayData result = kernel.eval(argString);
+		final DisplayData result = kernel.eval(args.get(COMMAND_AND_ARGS_PARAM));
 		stopwatch.stop();
 		final Duration elapsed = stopwatch.elapsed();
 		final String text = String.format("Execution time: %d.%09d seconds", elapsed.get(ChronoUnit.SECONDS), elapsed.get(ChronoUnit.NANOS));
diff --git a/src/main/java/de/prob2/jupyter/commands/TraceCommand.java b/src/main/java/de/prob2/jupyter/commands/TraceCommand.java
index 2cb417c9031cbcb72b52f06d7b6990d18d4307d2..b37af724e9cf22c59eb7ec66b28bd8558db21c93 100644
--- a/src/main/java/de/prob2/jupyter/commands/TraceCommand.java
+++ b/src/main/java/de/prob2/jupyter/commands/TraceCommand.java
@@ -7,9 +7,9 @@ import com.google.inject.Inject;
 import de.prob.statespace.AnimationSelector;
 import de.prob.statespace.Trace;
 import de.prob.statespace.Transition;
-
 import de.prob2.jupyter.Command;
-import de.prob2.jupyter.UserErrorException;
+import de.prob2.jupyter.Parameters;
+import de.prob2.jupyter.ParsedArguments;
 
 import io.github.spencerpark.jupyter.kernel.ReplacementOptions;
 import io.github.spencerpark.jupyter.kernel.display.DisplayData;
@@ -32,6 +32,11 @@ public final class TraceCommand implements Command {
 		return ":trace";
 	}
 	
+	@Override
+	public @NotNull Parameters getParameters() {
+		return Parameters.NONE;
+	}
+	
 	@Override
 	public @NotNull String getSyntax() {
 		return ":trace";
@@ -49,11 +54,7 @@ public final class TraceCommand implements Command {
 	}
 	
 	@Override
-	public @NotNull DisplayData run(final @NotNull String argString) {
-		if (!argString.isEmpty()) {
-			throw new UserErrorException("Expected no arguments");
-		}
-		
+	public @NotNull DisplayData run(final @NotNull ParsedArguments args) {
 		final Trace trace = this.animationSelector.getCurrentTrace();
 		final StringBuilder sbPlain = new StringBuilder("-1: Root state");
 		final StringBuilder sbMarkdown = new StringBuilder("* -1: Root state");
diff --git a/src/main/java/de/prob2/jupyter/commands/TypeCommand.java b/src/main/java/de/prob2/jupyter/commands/TypeCommand.java
index 8c900cffebe4285d854c3783a9e7d315856ac213..d8a9236dfc393379584514b24f3ad5b4c240e302 100644
--- a/src/main/java/de/prob2/jupyter/commands/TypeCommand.java
+++ b/src/main/java/de/prob2/jupyter/commands/TypeCommand.java
@@ -1,5 +1,7 @@
 package de.prob2.jupyter.commands;
 
+import java.util.Collections;
+
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 
@@ -11,6 +13,9 @@ import de.prob.statespace.AnimationSelector;
 import de.prob.statespace.Trace;
 import de.prob2.jupyter.Command;
 import de.prob2.jupyter.CommandUtils;
+import de.prob2.jupyter.Parameters;
+import de.prob2.jupyter.ParsedArguments;
+import de.prob2.jupyter.PositionalParameter;
 import de.prob2.jupyter.ProBKernel;
 
 import io.github.spencerpark.jupyter.kernel.ReplacementOptions;
@@ -20,6 +25,8 @@ import org.jetbrains.annotations.NotNull;
 import org.jetbrains.annotations.Nullable;
 
 public final class TypeCommand implements Command {
+	private static final @NotNull PositionalParameter.RequiredRemainder FORMULA_PARAM = new PositionalParameter.RequiredRemainder("formula");
+	
 	private final @NotNull Provider<@NotNull ProBKernel> kernelProvider;
 	private final @NotNull AnimationSelector animationSelector;
 	
@@ -36,6 +43,11 @@ public final class TypeCommand implements Command {
 		return ":type";
 	}
 	
+	@Override
+	public @NotNull Parameters getParameters() {
+		return new Parameters(Collections.singletonList(FORMULA_PARAM));
+	}
+	
 	@Override
 	public @NotNull String getSyntax() {
 		return ":type FORMULA";
@@ -52,9 +64,9 @@ public final class TypeCommand implements Command {
 	}
 	
 	@Override
-	public @NotNull DisplayData run(final @NotNull String argString) {
+	public @NotNull DisplayData run(final @NotNull ParsedArguments args) {
 		final Trace trace = this.animationSelector.getCurrentTrace();
-		final String code = this.kernelProvider.get().insertLetVariables(argString);
+		final String code = this.kernelProvider.get().insertLetVariables(args.get(FORMULA_PARAM));
 		final IEvalElement formula = CommandUtils.withSourceCode(code, () -> trace.getModel().parseFormula(code, FormulaExpand.EXPAND));
 		final TypeCheckResult result = trace.getStateSpace().typeCheck(formula);
 		if (result.isOk()) {
diff --git a/src/main/java/de/prob2/jupyter/commands/UnletCommand.java b/src/main/java/de/prob2/jupyter/commands/UnletCommand.java
index b2f7f1e37976d890cea1ac7bd6a9828c3950edb2..f3da397f8590a5a8e877664b66b728a4226cf140 100644
--- a/src/main/java/de/prob2/jupyter/commands/UnletCommand.java
+++ b/src/main/java/de/prob2/jupyter/commands/UnletCommand.java
@@ -1,11 +1,15 @@
 package de.prob2.jupyter.commands;
 
+import java.util.Collections;
 import java.util.Map;
 
 import com.google.inject.Inject;
 import com.google.inject.Injector;
 
 import de.prob2.jupyter.Command;
+import de.prob2.jupyter.Parameters;
+import de.prob2.jupyter.ParsedArguments;
+import de.prob2.jupyter.PositionalParameter;
 import de.prob2.jupyter.ProBKernel;
 import de.prob2.jupyter.UserErrorException;
 
@@ -16,6 +20,8 @@ import org.jetbrains.annotations.NotNull;
 import org.jetbrains.annotations.Nullable;
 
 public final class UnletCommand implements Command {
+	private static final @NotNull PositionalParameter.RequiredSingle NAME_PARAM = new PositionalParameter.RequiredSingle("name");
+	
 	private final @NotNull Injector injector;
 	
 	@Inject
@@ -30,6 +36,11 @@ public final class UnletCommand implements Command {
 		return ":unlet";
 	}
 	
+	@Override
+	public @NotNull Parameters getParameters() {
+		return new Parameters(Collections.singletonList(NAME_PARAM));
+	}
+	
 	@Override
 	public @NotNull String getSyntax() {
 		return ":unlet NAME";
@@ -46,12 +57,13 @@ public final class UnletCommand implements Command {
 	}
 	
 	@Override
-	public @Nullable DisplayData run(final @NotNull String argString) {
+	public @Nullable DisplayData run(final @NotNull ParsedArguments args) {
 		final Map<String, String> variables = this.injector.getInstance(ProBKernel.class).getVariables();
-		if (!variables.containsKey(argString)) {
-			throw new UserErrorException("There is no local variable " + argString);
+		final String name = args.get(NAME_PARAM);
+		if (!variables.containsKey(name)) {
+			throw new UserErrorException("There is no local variable " + name);
 		}
-		variables.remove(argString);
+		variables.remove(name);
 		return null;
 	}
 	
diff --git a/src/main/java/de/prob2/jupyter/commands/VersionCommand.java b/src/main/java/de/prob2/jupyter/commands/VersionCommand.java
index c22c7f04d149e6d0b65dc868380080818ccb9cfc..d900dba62ec528c9898b3f5b6761d2f4c598861d 100644
--- a/src/main/java/de/prob2/jupyter/commands/VersionCommand.java
+++ b/src/main/java/de/prob2/jupyter/commands/VersionCommand.java
@@ -6,6 +6,8 @@ import de.prob.Main;
 import de.prob.animator.command.GetVersionCommand;
 import de.prob.statespace.AnimationSelector;
 import de.prob2.jupyter.Command;
+import de.prob2.jupyter.Parameters;
+import de.prob2.jupyter.ParsedArguments;
 import de.prob2.jupyter.ProBKernel;
 
 import io.github.spencerpark.jupyter.kernel.ReplacementOptions;
@@ -29,6 +31,11 @@ public final class VersionCommand implements Command {
 		return ":version";
 	}
 	
+	@Override
+	public @NotNull Parameters getParameters() {
+		return Parameters.NONE;
+	}
+	
 	@Override
 	public @NotNull String getSyntax() {
 		return ":version";
@@ -45,7 +52,7 @@ public final class VersionCommand implements Command {
 	}
 	
 	@Override
-	public @NotNull DisplayData run(final @NotNull String argString) {
+	public @NotNull DisplayData run(final @NotNull ParsedArguments args) {
 		final StringBuilder sb = new StringBuilder("ProB 2 Jupyter kernel: ");
 		sb.append(ProBKernel.getVersion());
 		sb.append(" (");