Repository: ontodev/robot
Pull request #246 (2018-02-12 16:02:41)

Improve error reporting
See #239.

Error messages are now constants at the beginning of classes to keep track of number IDs. All known error messages are referenced in the appropriate doc page, including a new "errors" doc page. I tried to keep all error reporting in `robot-command`, but there are a few exceptions that had to stay in the core classes. `IOHelper` and `CommandLineHelper` also have detailed error messages for general operations.

I went through each specific command and tried to find areas where we could give better feedback.
Error messages returned in the console include a link to the error message in the documentation.

I enforced checks on `--input` (or `--input-iri`) options (typically one input allowed, unless merging or unmerging).

I added support for wildcards with an `--inputs` option in merge and unmerge (see #237). The patterns must include ? or * and be quoted. If they are not, the user will get an error message (which also has info in the docs). e.g.:
```
WILDCARD ERROR --inputs argument must be a quoted wildcard pattern
See http://robot.obolibrary.org/errors#input-9 for more details
```

Template-specific convenience methods have been moved from `IOHelper` to a new `TemplateHelper` class to reduce size and complexity.

Whew, I think that pretty much sums it up, but I would appreciate any review (sorry it's massive!).


Patch:
diff --git a/docs/_layouts/default.html b/docs/_layouts/default.html
index 6980c1b2..a944d5f7 100644
--- a/docs/_layouts/default.html
+++ b/docs/_layouts/default.html
@@ -23,6 +23,7 @@
           - - - - - - - - - -<br>
           <a href="{{ site.github.repository_url }}">view on github</a><br>
           <a href="/">usage</a><br>
+          <a href="/errors">common errors</a><br>
           <a href="/chaining">chaining commands</a><br>
           <a href="/prefixes">prefixes</a><br>
           <a href="/make">makefile</a><br>
diff --git a/docs/annotate.md b/docs/annotate.md
index 3eedcaba..1b9dd273 100644
--- a/docs/annotate.md
+++ b/docs/annotate.md
@@ -18,4 +18,34 @@ Including at least the following annotations is recommended:
   * Ontology & version IRIs
   * Title (<a href="http://purl.org/dc/terms/title" target="_blank">`dcterms:title`</a>)
   * Description (<a href="http://purl.org/dc/terms/description" target="_blank">`dcterms:description`</a>)
-  * License (<a href="http://purl.org/dc/terms/license" target="_blank">`dcterms:license`</a>)
\ No newline at end of file
+  * License (<a href="http://purl.org/dc/terms/license" target="_blank">`dcterms:license`</a>)
+
+---
+
+## Error Messages
+
+### Annotation Format Error
+
+Arguments to `--annotation`  or `--axiom-annotation` option must follow the `PROP VALUE` format:
+```
+--annotation rdfs:comment "this is a comment"`
+```
+
+Arguments to `--link-annotation` option must follow the `PROP LINK` format:
+```
+--link-annotation dc:license https://creativecommons.org/publicdomain/zero/1.0/
+```
+
+Arguments to `--language-annotation` option must follow the `PROP VALUE LANG` format:
+```
+--language-annotation rdfs:label "label" en
+```
+
+Arguments to `--typed-annotation` option must follow the `PROP VALUE TYPE` format:
+```
+--typed-annotation rdfs:label "label" xsd:string
+```
+
+### Missing Annotation Error
+
+No annotations were provided for the `annotate` command. The `--remove-annotations` option is also accepted in place of annotations.
diff --git a/docs/convert.md b/docs/convert.md
index 5d087fca..65246b82 100644
--- a/docs/convert.md
+++ b/docs/convert.md
@@ -11,4 +11,26 @@ The file format is determined by the extension of the output file (e.g. `.obo`),
   - omn - [Manchester](https://www.w3.org/TR/owl2-manchester-syntax/)
   - owl - [RDF/XML](https://www.w3.org/TR/rdf-syntax-grammar/)
   - owx - [OWL/XML](https://www.w3.org/TR/owl2-xml-serialization/)
-  - ttl - [Turtle](https://www.w3.org/TR/turtle/)
\ No newline at end of file
+  - ttl - [Turtle](https://www.w3.org/TR/turtle/)
+
+---
+
+## Error Messages
+
+### Output Error
+
+`convert` requires exactly one `--output`. If you do not specify the `--output`, or specify more than one, ROBOT cannot proceed. If chaining commands, place `convert` last.
+
+### Format Error
+
+The `convert` command expects either the `--output` file name to include an extension, or a format specified by `--format`.
+
+Correct:
+```
+--output release.owl
+--format owl --output release
+```
+Incorrect:
+```
+--output release
+```
diff --git a/docs/diff.md b/docs/diff.md
index d1f600e8..2aa3f82a 100644
--- a/docs/diff.md
+++ b/docs/diff.md
@@ -20,4 +20,23 @@ If `--output` is provided then a report will be written with any differences bet
       --right release.owl \
       --output results/release-diff.txt
 
-See <a href="/examples/release-diff.txt" target="_blank">`release-diff.txt`</a> for an example.
\ No newline at end of file
+See <a href="/examples/release-diff.txt" target="_blank">`release-diff.txt`</a> for an example.
+
+You can also compare ontologies by IRI with `--left-iri` and `--right-iri`. You may want to compare a local file to a release, in which case:
+<!-- DO NOT TEST -->
+```
+robot diff --left edit.owl \
+  --right-iri http://purl.obolibrary.org/obo/release.owl
+```
+
+---
+
+## Error Messages
+
+### Double Input Error
+
+You may specify the input with either `--left`/`--right` or `--left-iri`/`--right-iri`, but you may not use both option methods for one side.
+
+### Missing Input Error
+
+Both `--left` and `--right` input ontologies are required.
diff --git a/docs/errors.md b/docs/errors.md
new file mode 100644
index 00000000..84136c33
--- /dev/null
+++ b/docs/errors.md
@@ -0,0 +1,89 @@
+# Common Error Messages
+
+For command-specific error messages, see the command's doc page.
+
+If you run into any other errors or exceptions not listed, or have any issues with the existing errors, please head over to our <a href="https://github.com/ontodev/robot/issues" target="_blank">GitHub Issues</a> for assistance!
+
+---
+
+## Command & Option Errors
+
+### Chained Input Error
+
+[Chained commands](/chaining) take the output of the last command as the input of the next. Because of this, `--input` should only be used with the first command in the chain. This excludes [merge](/merge) and [unmerge](/unmerge), which allow multiple inputs.
+
+### Invalid Format Error
+
+When specifying the `--output` (or `--format` for converting), make sure the file has a valid extension. See [convert](/convert) for a current list of ontology formats.
+
+### Invalid IRI Error
+
+Many commands involve creating IRIs from provided string representations of the <a href="https://www.w3.org/TR/2010/NOTE-curie-20101216/" target="_blank">CURIE</a> or full IRI. If the provided field is not a string in valid CURIE or IRI format, the actual IRI cannot be created.
+
+When using CURIEs, make sure the prefix is defined, or add it with `--prefix`.
+
+### Invalid Prefix Error
+
+Prefixes (added with `--prefix`) should be strings in the following format: `"foo: http://foo/bar#"`. See [Prefixes](/prefixes) for more details on adding prefixes.
+
+### Invalid Reasoner Error
+
+[Reason](/reason), [materialize](/materialize), and [reduce](/reduce) all expect `--reasoner` options. All three commands support `structural`, `hermit`, `jfact`, and `elk`. Only the reason command supports `emr`. Click on the command for more details
+
+### Missing Command Error
+
+A valid command must be provided, running just `robot` will not perform any action.
+
+### Missing Requirement Error
+
+Some commands require certain options to be present. See the documentation for the specific command.
+
+### Missing Input Error
+
+An input ontology must be specified with `--input`. In the case of [merge](/merge) and [unmerge](/unmerge), at least one input is required.
+
+### Missing Term(s) Error
+
+Some commands ([extract](/extract) and [filter](/filter)) require terms as input in addition to the ontology. Click on the commands for more details.
+
+### Multiple Inputs Error
+
+For all commands other than [merge](/merge) and [unmerge](/unmerge), only one `--input` may be specified.
+
+### Options Error
+
+Each command requires a set of options, although it is more common to get a `MISSING INPUT ERROR`. See the command-specific documentation for more details.
+
+### Unknown Arg Error
+
+This error message may appear for one of two common reasons:
+1. A command or option (the argument) was typed incorrectly, or it not exist.
+
+2. Multiple arguments have been provided to an option that only accepts one argument. For example, the `--term` option for [extract](/extract) only accepts one argument. The following would **not** work:
+```
+$ robot extract --input foo.owl --term foo:0000001 foo:0000002
+UNKNOWN ARG ERROR unknown command or option: foo:0000002
+```
+Instead, use this (or a `--term-file`):
+```
+robot extract --input foo.owl --term foo:0000001 --term foo:0000002
+```
+
+### Wildcard Error
+
+Any pattern specified with `--inputs` for [merge](/merge) and [unmerge](/unmerge) must be a wildcard pattern, including either `*` (to match any number of characters) or `?` (to match any single character).
+
+---
+
+## Ontology Errors
+
+### Axiom Type Error
+
+Currently, ROBOT can only annotate subClassOf axioms.<br>
+See <a href="" target="_blank">GitHub issue #67</a> for more details.
+
+### Empty Terms Error
+
+For commands that take input terms, ROBOT will check the ontology to ensure those terms exist before proceeding. If the terms do not exist, then there are no tasks to complete so an error message is returned.
+
+If at least one term exists in the ontology, the task can still proceed, but the results may not be as expected. If you run into problems, run these types of commands with the `--verbose` flag, as warnings will be issued when the ontology does not contain a term.
diff --git a/docs/examples/edit2.owl b/docs/examples/edit2.owl
new file mode 100644
index 00000000..8508b6a3
--- /dev/null
+++ b/docs/examples/edit2.owl
@@ -0,0 +1,19 @@
+<?xml version="1.0"?>
+<rdf:RDF xmlns="http://example.com#"
+     xml:base="http://example.com"
+     xmlns:obo="http://purl.obolibrary.org/obo/"
+     xmlns:rdfs="http://www.w3.org/2000/01/rdf-schema#"
+     xmlns:foaf="http://xmlns.com/foaf/0.1/"
+     xmlns:owl="http://www.w3.org/2002/07/owl#"
+     xmlns:xsd="http://www.w3.org/2001/XMLSchema#"
+     xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+     xmlns:oboInOwl="http://www.geneontology.org/formats/oboInOwl#">
+    <owl:Ontology rdf:about="https://github.com/ontodev/robot/examples/edit2.owl"/>
+
+    <!-- https://github.com/ontodev/robot/examples/foo.owl#test -->
+
+    <owl:Class rdf:about="https://github.com/ontodev/robot/examples/foo.owl#test">
+        <rdfs:label rdf:datatype="http://www.w3.org/2001/XMLSchema#string">test</rdfs:label>
+    </owl:Class>
+
+</rdf:RDF>
diff --git a/docs/extract.md b/docs/extract.md
index b4ec5e63..ca6e72d7 100644
--- a/docs/extract.md
+++ b/docs/extract.md
@@ -34,3 +34,26 @@ For more details see:
 - <a href="http://dx.doi.org/10.3233/AO-2011-0087" target="_blank">MIREOT</a>
 - <a href="http://owlcs.github.io/owlapi/apidocs_4/uk/ac/manchester/cs/owlapi/modularity/SyntacticLocalityModuleExtractor.html" target="_blank">SLME</a>
 - <a href="http://owlcs.github.io/owlapi/apidocs_4/uk/ac/manchester/cs/owlapi/modularity/ModuleType.html" target="_blank">ModuleType</a>
+
+---
+
+## Error Messages
+
+### Missing MIREOT Term(s) Error
+
+MIREOT requires either `--lower-term` or `--branch-from-term` to proceed. `--upper-term` is optional.
+
+### Missing Lower Term(s) Error
+
+If an `--upper-term` is specified for MIREOT, `--lower-term` (or terms) must also be specified.
+
+### Invalid Method Error
+
+The `--method` option only accepts: MIREOT, STAR, TOP, and BOT.
+
+### Invalid Option Error
+
+The following flags *should not* be used with STAR, TOP, or BOT methods:
+* `--upper-term` & `--upper-terms`
+* `--lower-term` & `--lower-terms`
+* `--branch-from-term` & `--branch-from-terms`
diff --git a/docs/materialize.md b/docs/materialize.md
index 842c406a..dcdfe9db 100644
--- a/docs/materialize.md
+++ b/docs/materialize.md
@@ -15,4 +15,6 @@ This can be [chained](/chaining) with [filter](/filter) and [reduce](/reduce) to
       --input emr_example.obo \
       --term BFO:0000050 \
       filter -t BFO:0000050 \
-      reduce --output results/emr_reduced.obo
\ No newline at end of file
+      reduce --output results/emr_reduced.obo
+
+See [reason](/reason) for details on supported reasoners (EMR is not supported in `materialize`).
diff --git a/docs/merge.md b/docs/merge.md
index 497fdb61..8cd9756e 100644
--- a/docs/merge.md
+++ b/docs/merge.md
@@ -6,6 +6,12 @@ OWL ontologies are often divided into several `.owl` files, with `owl:imports` s
 
 You don't need `owl:import` statements: you can merge any number of ontologies by using multiple `--input` arguments. All the ontologies and their imports are merged into the first ontology.
 
-    robot merge --input edit.owl --input foo.owl --output results/merged2.owl
+    robot merge --input edit.owl --input edit2.owl --output results/merged2.owl
+
+You can also specify merging of multiple files that match a pattern with `--inputs`. The argument to `--inputs` must be a quoted wildcard pattern. This option supports `?` to match any single character, or `*` to match any number of characters.
+
+This command will merge `edit.owl` and `edit2.owl`:
+
+    robot merge --inputs "edit*.owl" --output results/merged2.owl
 
 A merge can be 'undone' with [unmerge](/unmerge).
\ No newline at end of file
diff --git a/docs/prefixes.md b/docs/prefixes.md
index dfe5a614..05ac7bb0 100644
--- a/docs/prefixes.md
+++ b/docs/prefixes.md
@@ -2,7 +2,7 @@
 
 Terms in OBO and OWL are identified using <a href="https://en.wikipedia.org/wiki/Internationalized_resource_identifier" target="_blank">IRIs</a> (Internationalized Resource Identifiers), which generalize the familiar addresses for web pages. IRIs have many advantages, but one of their disadvantages is that they can be pretty long. So we have standard ways to abbreviate IRIs in a particular context by specifying **prefixes**. For example, Turtle files start with `@prefix` statements, SPARQL queries start with `PREFIX` statements, and JSON-LD data includes a `@context` with prefixes.
 
-For robot we use the JSON-LD format. See `robot-core/src/main/resources/obo_context.jsonld` for the JSON-LD context that is used by default. It includes common, general linked-data prefixes, and prefixes for all the OBO library projects.
+For robot we use the JSON-LD format. See <a href="https://github.com/ontodev/robot/blob/master/robot-core/src/main/resources/obo_context.jsonld" target="_blank">`obo_context.jsonld`</a> for the JSON-LD context that is used by default. It includes common, general linked-data prefixes, and prefixes for all the OBO library projects.
 
 If you do not want to use the defaults, you can use the `--noprefixes` option. If you want to replace the defaults, use the `--prefixes` option and specify your JSON-LD file. Whatever your choice, you can add more prefixes using the `--prefix` option, as many times as you like. Finally, you can print or save the current prefixes using the `export-prefixes` command. Here are some examples:
 
@@ -16,4 +16,12 @@ The various prefix options can be used with any command. When chaining commands,
 
     robot --noprefixes --prefix "foo: http://foo#" \
       export-prefixes --prefix "bar: http://bar#" \
-      export-prefixes
\ No newline at end of file
+      export-prefixes
+
+---
+
+## Error Messages
+
+### JDON-LD Error
+
+ROBOT encounterd a problem while writing the given prefixes to JSON-LD.
diff --git a/docs/query.md b/docs/query.md
index f74e5da2..9f1eeaa9 100644
--- a/docs/query.md
+++ b/docs/query.md
@@ -27,3 +27,15 @@ Instead of specifying one or more pairs (query file, output file), you can speci
     robot query --input nucleus.owl \
       --queries cell_part_ask.sparql \
       --output-dir results/
+
+---
+
+## Error Messages
+
+### Missing Query Error
+
+You must specify a query to execute with `--query` or `--queries`.
+
+### Query Type Error
+
+Each SPARQL query should be a SELECT, ASK, DESCRIBE, or CONSTRUCT.
diff --git a/docs/reduce.md b/docs/reduce.md
index 57c20f04..9d5fb601 100644
--- a/docs/reduce.md
+++ b/docs/reduce.md
@@ -6,4 +6,4 @@ ROBOT can be used to remove redundant subClassOf axioms:
       --input ribosome.owl \
       --output results/reduced.owl
 
-See [reason](/reason) for details on supported reasoners (currently JFact and EMR are not supported in `reduce`).
\ No newline at end of file
+See [reason](/reason) for details on supported reasoners (EMR is not supported in `reduce`).
diff --git a/docs/template.md b/docs/template.md
index 9c8d659e..015e934a 100644
--- a/docs/template.md
+++ b/docs/template.md
@@ -1,4 +1,4 @@
-# ROBOT Template
+# Template
 
 ROBOT can convert tables to OWL format using templates. See <a href="/examples/template.csv" target="_blank">`template.csv`</a> for an example. The approach extends the QTT method described in <a href="http://dx.doi.org/10.3233/AO-2011-0086" target="_blank">Overcoming the ontology enrichment bottleneck with Quick Term Templates</a>.
 
@@ -17,7 +17,6 @@ Each template file must be set up in the following format:
 
 The `template` command accepts an optional input ontology, either using the `--input` option or from the previous command in a chain. If an input ontology is given, its RDFS labels will be used when parsing the template. The `--template` or `-t` option specified the CSV or TSV template file. Multiple templates are allowed, and the order of templates is significant. You can also specify the normal `--prefix` options, the `--output-iri` and `--version-iri`, and the usual `--output` options. See below for the three different merge options, and details on how they control the output of the command.
 
-
 ## Template Strings
 
 - `ID`: Every term must have an IRI to identify it. This can be specified with an `ID` column. Usually this will be a prefixed ID like `GO:12345`. See the `--prefix` options for details. Rows with an empty ID cell will be skipped.
@@ -60,3 +59,89 @@ no merge (default) | result     | result
 - `merge-after`: any `--output` options apply to the result ontology, then result ontology is merged into the input ontology, and the output of the command is the merged ontology
 
 These three options are particularly useful when chaining commands. For instance, the `merge-after` option lets you save the result ontology separately, then send the merged ontology to the next command.
+
+---
+
+## Error Messages
+
+### Missing Template Error
+
+You must specify at least one template with `--template` to proceed.
+
+### Annotation Property Error
+
+The annotation property provided could not be resolved. Check your template to ensure the provided annotation property is in a correct IRI or CURIE format. For legibility, using CURIEs is recommended, but you must ensure that the prefix is defined.
+```
+A rdfs:label
+A http://www.w3.org/2000/01/rdf-schema#label
+```
+
+### Datatype Error
+
+The datatype provided in an `AT` template string could not be resolved. Check your template to ensure the provided datatype is in a correct IRI or CURIE format. For legibility, using CURIEs is recommended, but you must ensure that the prefix is defined.
+```
+AT rdfs:label^^xsd:string
+AT rdfs:label^^http://www.w3.org/2001/XMLSchema#string
+```
+
+### File Type Error
+
+The `--template` option accepts the following file types: CSV, TSV, or TAB.
+
+### ID Error
+
+Each template must have an ID column. Keep in mind that if the template has an ID column, but it is not filled in for a row, that row will be skipped.
+
+### IRI Error
+
+The IRI provided as the value (in a row) to an `AI` template string could not be resolved as an IRI. Check your template to ensure the provided value is in a correct IRI or CURIE format. If using CURIEs, remember to ensure the prefix is defined.
+
+### Language Format Error
+
+The template string for an `AL` annotation must always include `@`.
+```
+AL rdfs:label@en
+```
+
+### Template File Error
+
+The template cannot be found in the current directory. Make sure the file exists and your path is correct.
+
+### Typed Format Error
+
+The template string for an `AT` annotation must always include `^^`.
+```
+AT rdfs:label^^xsd:string
+```
+
+### Axiom Annotation Error
+
+An axiom annotation is an annotation on an axiom, either a class axiom or another annotation. Because of this, any time `>A` is used, an annotation must be in the previous column. Any time `>C` is used, a class expression must be in the previous column.
+```
+A rdfs:label,>A rdfs:comment
+C %,>C rdfs:comment
+```
+
+### Column Mismatch Error
+
+There number of header columns (first row) must be equal to the number of template string columns (second row).
+
+### Missing Type Error
+
+If no `CLASS_TYPE` column is included, ROBOT will default to using `subclass`. If a `CLASS_TYPE` column is included, though, each row must include a specified class type. If the `CLASS_TYPE` is left empty, this error message will be returned.
+
+### Null ID Error
+
+An IRI cannot be created from the provided ID. This is most likely because the ID is not formatted properly, as an IRI or a CURIE.
+
+### Parse Error
+
+The content of a class expression cell (`C`) was not able to be parsed by OWLAPI. Check the formatting of the cell and the template string to ensure you are using valid CURIEs and/or IRIs.
+
+### Unknown Template Error
+
+Valid template strings are limited to the <a href="#template-strings">described above</a>. If a different template string is provided, this error message will be returned.
+
+### Unknown Type Error
+
+The `CLASS_TYPE` values are limited to `subclass` or `equivalent`. Anything else placed in that column will result in this error message.
diff --git a/docs/validate.md b/docs/validate.md
index d7bc9c0e..cc41399b 100644
--- a/docs/validate.md
+++ b/docs/validate.md
@@ -4,4 +4,28 @@ OWL 2 has a number of <a href="https://www.w3.org/TR/owl2-profiles/" target="_bl
 
     robot validate-profile --profile EL \
       --input merged.owl \
-      --output results/merged-validation.txt
\ No newline at end of file
+      --output results/merged-validation.txt
+
+## Profiles
+
+* <a href="https://www.w3.org/2007/OWL/wiki/Primer#OWL_2_EL" target="_blank">EL</a>
+* <a href="https://www.w3.org/2007/OWL/wiki/Primer#OWL_2_RL" target="_blank">RL</a>
+* <a href="https://www.w3.org/2007/OWL/wiki/Primer#OWL_2_QL">QL</a>
+* <a href="https://www.w3.org/2007/OWL/wiki/Primer#OWL_2_DL_and_OWL_2_Full" target="_blank">DL</a>
+* <a href="https://www.w3.org/2007/OWL/wiki/Primer#OWL_2_DL_and_OWL_2_Full" target="_blank">Full</a>
+
+---
+
+## Error Messages
+
+### Missing Profile Error
+
+Occurs when a `--profile` option is not provided.
+
+### Invalid Profile Error
+
+Occurs when the argument to `--profile` is not one of the following: EL, DL, RL, QL, or Full. See the above documentation for more details.
+
+### Profile Violation Error
+
+Occurs when the `--input` ontology does not conform to the `--profile`. See the profile descriptions for more details.
diff --git a/robot-command/src/main/java/org/obolibrary/robot/AnnotateCommand.java b/robot-command/src/main/java/org/obolibrary/robot/AnnotateCommand.java
index b85eeac6..fcb75747 100644
--- a/robot-command/src/main/java/org/obolibrary/robot/AnnotateCommand.java
+++ b/robot-command/src/main/java/org/obolibrary/robot/AnnotateCommand.java
@@ -5,6 +5,7 @@ import java.util.List;
 import org.apache.commons.cli.CommandLine;
 import org.apache.commons.cli.Option;
 import org.apache.commons.cli.Options;
+import org.semanticweb.owlapi.model.IRI;
 import org.semanticweb.owlapi.model.OWLOntology;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
@@ -18,6 +19,35 @@ public class AnnotateCommand implements Command {
   /** Logger. */
   private static final Logger logger = LoggerFactory.getLogger(AnnotateCommand.class);
 
+  /** Namespace for error messages. */
+  private static final String NS = "annotate#";
+
+  /** Error message when --annotation is not a valid PROP VALUE. */
+  private static final String annotationFormatError =
+      NS + "ANNOTATION FORMAT ERROR each annotation must include PROP VALUE";
+
+  /** Error message when --link-annotation is not a valid PROP LINK. */
+  private static final String linkAnnotationFormatError =
+      NS + "ANNOTATION FORMAT ERROR each link annotation must include PROP LINK";
+
+  /** Error message when --language-annotation is not a valid PROP VALUE LANG. */
+  private static final String langAnnotationFormatError =
+      NS + "ANNOTATION FORMAT ERROR " + "each language annotation must include PROP VALUE LANG";
+
+  /** Error message when --typed-annotation is not a valid PROP VALUE TYPE. */
+  private static final String typedAnnotationFormatError =
+      NS + "ANNOTATION FORMAT ERROR " + "each typed annotation must include PROP VALUE TYPE";
+
+  /** Error message when --axiom-annotation is not a valid PROP VALUE. */
+  private static final String axiomAnnotationFormatError =
+      NS + "ANNOTATION FORMAT ERROR each axiom annotation must include PROP VALUE";
+
+  /** Error message when there are no annotation inputs. */
+  private static final String missingAnnotationError =
+      NS
+          + "MISSING ANNOTATION ERROR "
+          + "at least one annotation option or annotation file is required";
+
   /** Store the command-line options for the command. */
   private Options options;
 
@@ -133,60 +163,96 @@ public class AnnotateCommand implements Command {
     IOHelper ioHelper = CommandLineHelper.getIOHelper(line);
     state = CommandLineHelper.updateInputOntology(ioHelper, state, line);
     OWLOntology ontology = state.getOntology();
+    boolean hasAnnotation = false;
 
     // Remove all annotation on the ontology
     if (line.hasOption("remove-annotations")) {
+      hasAnnotation = true;
       OntologyHelper.removeOntologyAnnotations(ontology);
     }
 
-    // Add annotations
-    List<String> items;
+    String property = null;
+    String value = null;
 
     // Add annotations with PROP VALUE
-    items = CommandLineHelper.getOptionValues(line, "annotation");
-    while (items.size() > 0) {
-      String property = items.remove(0);
-      String value = items.remove(0);
-      OntologyHelper.addOntologyAnnotation(
-          ontology, ioHelper.createIRI(property), ioHelper.createLiteral(value));
+    List<String> annotationItems = CommandLineHelper.getOptionValues(line, "annotation");
+    while (annotationItems.size() > 0) {
+      hasAnnotation = true;
+      // Check for valid input
+      try {
+        property = annotationItems.remove(0);
+        value = annotationItems.remove(0);
+      } catch (IndexOutOfBoundsException e) {
+        throw new IllegalArgumentException(annotationFormatError);
+      }
+      IRI iri = CommandLineHelper.maybeCreateIRI(ioHelper, property, "property");
+      OntologyHelper.addOntologyAnnotation(ontology, iri, IOHelper.createLiteral(value));
     }
 
     // Add link-annotations with PROP LINK
-    items = CommandLineHelper.getOptionValues(line, "link-annotation");
-    while (items.size() > 0) {
-      String property = items.remove(0);
-      String value = items.remove(0);
-      OntologyHelper.addOntologyAnnotation(
-          ontology, ioHelper.createIRI(property), ioHelper.createIRI(value));
+    List<String> linkItems = CommandLineHelper.getOptionValues(line, "link-annotation");
+    while (linkItems.size() > 0) {
+      hasAnnotation = true;
+      // Check for valid input
+      try {
+        property = linkItems.remove(0);
+        value = linkItems.remove(0);
+      } catch (IndexOutOfBoundsException e) {
+        throw new IllegalArgumentException(linkAnnotationFormatError);
+      }
+      IRI propIRI = CommandLineHelper.maybeCreateIRI(ioHelper, property, "property");
+      IRI valueIRI = CommandLineHelper.maybeCreateIRI(ioHelper, value, "value");
+      OntologyHelper.addOntologyAnnotation(ontology, propIRI, valueIRI);
     }
 
     // Add language-annotations with PROP VALUE LANG
-    items = CommandLineHelper.getOptionValues(line, "language-annotation");
-    while (items.size() > 0) {
-      String property = items.remove(0);
-      String value = items.remove(0);
-      String lang = items.remove(0);
+    List<String> langItems = CommandLineHelper.getOptionValues(line, "language-annotation");
+    while (langItems.size() > 0) {
+      hasAnnotation = true;
+      // Check for valid input
+      String lang = null;
+      try {
+        property = langItems.remove(0);
+        value = langItems.remove(0);
+        lang = langItems.remove(0);
+      } catch (IndexOutOfBoundsException e) {
+        throw new IllegalArgumentException(langAnnotationFormatError);
+      }
+      IRI iri = CommandLineHelper.maybeCreateIRI(ioHelper, property, "property");
       OntologyHelper.addOntologyAnnotation(
-          ontology, ioHelper.createIRI(property), ioHelper.createTaggedLiteral(value, lang));
+          ontology, iri, IOHelper.createTaggedLiteral(value, lang));
     }
 
     // Add typed-annotations with PROP VALUE TYPE
-    items = CommandLineHelper.getOptionValues(line, "typed-annotation");
-    while (items.size() > 0) {
-      String property = items.remove(0);
-      String value = items.remove(0);
-      String type = items.remove(0);
-      OntologyHelper.addOntologyAnnotation(
-          ontology, ioHelper.createIRI(property), ioHelper.createTypedLiteral(value, type));
+    List<String> typedItems = CommandLineHelper.getOptionValues(line, "typed-annotation");
+    while (typedItems.size() > 0) {
+      hasAnnotation = true;
+      // Check for valid input
+      String type = null;
+      try {
+        property = typedItems.remove(0);
+        value = typedItems.remove(0);
+        type = typedItems.remove(0);
+      } catch (IndexOutOfBoundsException e) {
+        throw new IllegalArgumentException(typedAnnotationFormatError);
+      }
+      IRI iri = CommandLineHelper.maybeCreateIRI(ioHelper, property, "property");
+      OntologyHelper.addOntologyAnnotation(ontology, iri, ioHelper.createTypedLiteral(value, type));
     }
 
     // Add annotations with PROP VALUE
-    items = CommandLineHelper.getOptionValues(line, "axiom-annotation");
-    while (items.size() > 0) {
-      String property = items.remove(0);
-      String value = items.remove(0);
-      OntologyHelper.addAxiomAnnotations(
-          ontology, ioHelper.createIRI(property), ioHelper.createLiteral(value));
+    List<String> axiomItems = CommandLineHelper.getOptionValues(line, "axiom-annotation");
+    while (axiomItems.size() > 0) {
+      hasAnnotation = true;
+      // Check for valid input
+      try {
+        property = axiomItems.remove(0);
+        value = axiomItems.remove(0);
+      } catch (IndexOutOfBoundsException e) {
+        throw new IllegalArgumentException(axiomAnnotationFormatError);
+      }
+      IRI iri = CommandLineHelper.maybeCreateIRI(ioHelper, property, "property");
+      OntologyHelper.addAxiomAnnotations(ontology, iri, IOHelper.createLiteral(value));
     }
 
     // Load any annotation files as ontologies and merge them in
@@ -196,6 +262,7 @@ public class AnnotateCommand implements Command {
       ontologies.add(ioHelper.loadOntology(path));
     }
     if (ontologies.size() > 0) {
+      hasAnnotation = true;
       MergeOperation.mergeInto(ontologies, ontology, true);
     }
 
@@ -203,9 +270,15 @@ public class AnnotateCommand implements Command {
     String ontologyIRI = CommandLineHelper.getOptionalValue(line, "ontology-iri");
     String versionIRI = CommandLineHelper.getOptionalValue(line, "version-iri");
     if (ontologyIRI != null || versionIRI != null) {
+      hasAnnotation = true;
       OntologyHelper.setOntologyIRI(ontology, ontologyIRI, versionIRI);
     }
 
+    // Validate that annotations were provided
+    if (!hasAnnotation) {
+      throw new IllegalArgumentException(missingAnnotationError);
+    }
+
     CommandLineHelper.maybeSaveOutput(line, ontology);
 
     return state;
diff --git a/robot-command/src/main/java/org/obolibrary/robot/CommandLineHelper.java b/robot-command/src/main/java/org/obolibrary/robot/CommandLineHelper.java
index ad5584fd..a0141a6b 100644
--- a/robot-command/src/main/java/org/obolibrary/robot/CommandLineHelper.java
+++ b/robot-command/src/main/java/org/obolibrary/robot/CommandLineHelper.java
@@ -1,6 +1,7 @@
 package org.obolibrary.robot;
 
 import java.io.File;
+import java.io.FileFilter;
 import java.io.IOException;
 import java.util.ArrayList;
 import java.util.Arrays;
@@ -16,16 +17,71 @@ import org.apache.commons.cli.Options;
 import org.apache.commons.cli.ParseException;
 import org.apache.commons.cli.PosixParser;
 import org.apache.commons.io.FileUtils;
+import org.apache.commons.io.filefilter.WildcardFileFilter;
+import org.geneontology.reasoner.ExpressionMaterializingReasonerFactory;
+import org.semanticweb.elk.owlapi.ElkReasonerFactory;
 import org.semanticweb.owlapi.model.IRI;
 import org.semanticweb.owlapi.model.OWLOntology;
+import org.semanticweb.owlapi.reasoner.OWLReasonerFactory;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
+import uk.ac.manchester.cs.jfact.JFactFactory;
 
 /** Convenience methods for working with command line options. */
 public class CommandLineHelper {
   /** Logger. */
   private static final Logger logger = LoggerFactory.getLogger(CommandLineHelper.class);
 
+  /** Namespace for general input error messages. */
+  private static final String NS = "errors#";
+
+  /** Error message when --input is provided in a chained command. */
+  private static final String chainedInputError =
+      NS + "CHAINED INPUT ERROR do not use an --input option for chained commands";
+
+  /**
+   * Error message when an invalid extension is provided (file format). Expects file format. This
+   * message is duplicated in IOHelper without the NS and ID.
+   */
+  private static final String invalidFormatError = NS + "INVALID FORMAT ERROR unknown format: %s";
+
+  /** Error message when an invalid IRI is provided. Expects the entry field and term. */
+  private static final String invalidIRIError =
+      NS + "INVALID IRI ERROR %1$s \"%2$s\" is not a valid CURIE or IRI";
+
+  /**
+   * Error message when an invalid prefix is provided. Expects the combined prefix. This message is
+   * duplicated in IOHelper without the NS and ID.
+   */
+  private static final String invalidPrefixError =
+      NS + "INVALID PREFIX ERROR invalid prefix string: %s";
+
+  /** Error message when user provides an invalid reasoner. Expects reasonerName in formatting. */
+  private static final String invalidReasonerError =
+      NS + "INVALID REASONER ERROR unknown reasoner: %s";
+
+  /** Error message when no input ontology is provided for methods that accept multiple inputs. */
+  private static final String missingRequirementError = NS + "MISSING REQUIREMENT ERROR %s";
+
+  /** Error message when no input ontology is provided. */
+  private static final String missingInputError = NS + "MISSING INPUT ERROR an --input is required";
+
+  /** Error message when no input ontology is provided for methods that accept multiple inputs. */
+  private static final String missingInputsError =
+      NS + "MISSING INPUT ERROR at least one --input is required";
+
+  /** Error message when input terms are not provided. */
+  private static final String missingTermsError =
+      NS + "MISSING TERMS ERROR term(s) are required with --term or --term-file";
+
+  /** Error message when more than one --input is specified, excluding merge and unmerge. */
+  private static final String multipleInputsError =
+      NS + "MULITPLE INPUTS ERROR only one --input is allowed";
+
+  /** Error message when the --inputs pattern does not include * or ?, or is not quoted */
+  private static final String wildcardError =
+      NS + "WILDCARD ERROR --inputs argument must be a quoted wildcard pattern";
+
   /**
    * Given a single string, return a list of strings split at whitespace but allowing for quoted
    * values, as a command-line parser does.
@@ -57,7 +113,7 @@ public class CommandLineHelper {
     final int inDoubleQuote = 2;
     int state = normal;
     StringTokenizer tok = new StringTokenizer(toProcess, "\"\' ", true);
-    Vector v = new Vector();
+    Vector<String> v = new Vector<String>();
     StringBuffer current = new StringBuffer();
     boolean lastTokenHasBeenQuoted = false;
 
@@ -212,6 +268,7 @@ public class CommandLineHelper {
   public static String getDefaultValue(CommandLine line, String name, String defaultValue) {
     String result = defaultValue;
     if (line.hasOption(name)) {
+      // Only one arg should be passed per option
       result = line.getOptionValue(name);
     }
     return result;
@@ -231,7 +288,7 @@ public class CommandLineHelper {
       throws IllegalArgumentException {
     String result = getOptionalValue(line, name);
     if (result == null) {
-      throw new IllegalArgumentException(message);
+      throw new IllegalArgumentException(String.format(missingRequirementError, message));
     }
     return result;
   }
@@ -253,7 +310,11 @@ public class CommandLineHelper {
     }
 
     for (String prefix : getOptionalValues(line, "prefix")) {
-      ioHelper.addPrefix(prefix);
+      try {
+        ioHelper.addPrefix(prefix);
+      } catch (IllegalArgumentException e) {
+        throw new IllegalArgumentException(String.format(invalidPrefixError, prefix));
+      }
     }
 
     ioHelper.setXMLEntityFlag(line.hasOption("xml-entities"));
@@ -274,18 +335,83 @@ public class CommandLineHelper {
   public static OWLOntology getInputOntology(IOHelper ioHelper, CommandLine line)
       throws IllegalArgumentException, IOException {
     OWLOntology inputOntology = null;
-    String inputOntologyPath = getOptionalValue(line, "input");
-    String inputOntologyIRI = getOptionalValue(line, "input-iri");
-    if (inputOntologyPath != null) {
-      inputOntology = ioHelper.loadOntology(inputOntologyPath);
-    } else if (inputOntologyIRI != null) {
-      inputOntology = ioHelper.loadOntology(IRI.create(inputOntologyIRI));
+    // Check for multiple inputs
+    List<String> inputOntologyPaths = getOptionalValues(line, "input");
+    List<String> inputOntologyIRIs = getOptionalValues(line, "input-iri");
+    Integer check = inputOntologyPaths.size() + inputOntologyIRIs.size();
+    if (check > 1) {
+      throw new IllegalArgumentException(multipleInputsError);
+    }
+
+    if (!inputOntologyPaths.isEmpty()) {
+      inputOntology = ioHelper.loadOntology(inputOntologyPaths.get(0));
+    } else if (!inputOntologyIRIs.isEmpty()) {
+      inputOntology = ioHelper.loadOntology(IRI.create(inputOntologyIRIs.get(0)));
     } else {
-      throw new IllegalArgumentException("inputOntology must be specified");
+      // Both input options are empty
+      throw new IllegalArgumentException(missingInputError);
     }
+
     return inputOntology;
   }
 
+  /**
+   * Given an IOHelper and a command line, check for required options and return a list of loaded
+   * input ontologies. Currently handles --input, --input-iri, and --inputs options.
+   *
+   * @param ioHelper the IOHelper to load the ontology with
+   * @param line the command line to use
+   * @param allowEmpty if an empty list may be returned (when chaining commands, an input was
+   *     already provided)
+   * @return the list of input ontologies
+   * @throws IllegalArgumentException if requires options are missing
+   * @throws IOException if the ontology cannot be loaded
+   */
+  public static List<OWLOntology> getInputOntologies(
+      IOHelper ioHelper, CommandLine line, boolean allowEmpty)
+      throws IllegalArgumentException, IOException {
+    List<OWLOntology> inputOntologies = new ArrayList<OWLOntology>();
+
+    // Check for input files
+    List<String> inputOntologyPaths = getOptionalValues(line, "input");
+    for (String inputOntologyPath : inputOntologyPaths) {
+      inputOntologies.add(ioHelper.loadOntology(inputOntologyPath));
+    }
+
+    // Check for input IRIs
+    List<String> inputOntologyIRIs = getOptionalValues(line, "input-iri");
+    for (String inputOntologyIRI : inputOntologyIRIs) {
+      inputOntologies.add(ioHelper.loadOntology(IRI.create(inputOntologyIRI)));
+    }
+
+    // Check for input patterns (wildcard)
+    List<String> inputOntologyPatterns = getOptionalValues(line, "inputs");
+    for (String inputOntologyPattern : inputOntologyPatterns) {
+      if (!inputOntologyPattern.contains("*") && !inputOntologyPattern.contains("?")) {
+        throw new IllegalArgumentException(wildcardError);
+      }
+      FileFilter fileFilter = new WildcardFileFilter(inputOntologyPattern);
+      File[] inputOntologyFiles = new File(".").listFiles(fileFilter);
+      if (inputOntologyFiles.length < 1) {
+        // Warn user, but continue (empty input checked later)
+        logger.error("No files match pattern: {}", inputOntologyPattern);
+      }
+      System.out.println("Loading matches to \"" + inputOntologyPattern + "\":");
+      int counter = 0;
+      for (File inputOntologyFile : inputOntologyFiles) {
+        counter++;
+        System.out.println(counter + ". " + inputOntologyFile.getName());
+        inputOntologies.add(ioHelper.loadOntology(inputOntologyFile));
+      }
+    }
+
+    if (inputOntologies.isEmpty() && !allowEmpty) {
+      throw new IllegalArgumentException(missingInputsError);
+    }
+
+    return inputOntologies;
+  }
+
   /**
    * Given an IOHelper, a state object, and a command line, update the state with the ontology.
    *
@@ -322,7 +448,7 @@ public class CommandLineHelper {
       throws IllegalArgumentException, IOException {
     if (state != null && state.getOntology() != null) {
       if (line.hasOption("input") || line.hasOption("input-IRI")) {
-        throw new IllegalArgumentException("Do not use an --input option for chained commands");
+        throw new IllegalArgumentException(chainedInputError);
       } else {
         return state;
       }
@@ -333,8 +459,8 @@ public class CommandLineHelper {
       ontology = getInputOntology(ioHelper, line);
     } catch (Exception e) {
       if (required) {
-        e.printStackTrace(System.err);
-        throw new IllegalArgumentException("A valid input ontology must be specified");
+        // Throw message from IOHelper
+        throw new IllegalArgumentException(e);
       }
     }
 
@@ -345,32 +471,6 @@ public class CommandLineHelper {
     return state;
   }
 
-  /**
-   * Given an IOHelper and a command line, check for required options and return a list of loaded
-   * input ontologies. Currently handles --input and --input-iri options.
-   *
-   * @param ioHelper the IOHelper to load the ontology with
-   * @param line the command line to use
-   * @return the list of input ontologies
-   * @throws IllegalArgumentException if requires options are missing
-   * @throws IOException if the ontology cannot be loaded
-   */
-  public static List<OWLOntology> getInputOntologies(IOHelper ioHelper, CommandLine line)
-      throws IllegalArgumentException, IOException {
-    List<OWLOntology> inputOntologies = new ArrayList<OWLOntology>();
-
-    List<String> inputOntologyPaths = getOptionalValues(line, "input");
-    for (String inputOntologyPath : inputOntologyPaths) {
-      inputOntologies.add(ioHelper.loadOntology(inputOntologyPath));
-    }
-
-    List<String> inputOntologyIRIs = getOptionalValues(line, "input-iri");
-    for (String inputOntologyIRI : inputOntologyIRIs) {
-      inputOntologies.add(ioHelper.loadOntology(IRI.create(inputOntologyIRI)));
-    }
-    return inputOntologies;
-  }
-
   /**
    * Given a command line, check for the required options and return a File for saving data.
    *
@@ -414,10 +514,32 @@ public class CommandLineHelper {
   public static void maybeSaveOutput(CommandLine line, OWLOntology ontology) throws IOException {
     IOHelper ioHelper = getIOHelper(line);
     for (String path : getOptionValues(line, "output")) {
-      ioHelper.saveOntology(ontology, path);
+      try {
+        ioHelper.saveOntology(ontology, path);
+      } catch (IllegalArgumentException e) {
+        // Exception from getFormat -- invalid format
+        throw new IllegalArgumentException(
+            String.format(invalidFormatError, path.substring(path.lastIndexOf(".") + 1)));
+      }
     }
   }
 
+  /**
+   * Try to create an IRI from a string input. If the term is not in a valid format (null), an
+   * IllegalArgumentException is thrown to prevent null from being passed into other methods.
+   *
+   * @param term the term to convert to an IRI
+   * @param field the field in which the term was entered, for reporting
+   * @return the new IRI if successful
+   */
+  public static IRI maybeCreateIRI(IOHelper ioHelper, String term, String field) {
+    IRI iri = ioHelper.createIRI(term);
+    if (iri == null) {
+      throw new IllegalArgumentException(String.format(invalidIRIError, field, term));
+    }
+    return iri;
+  }
+
   /**
    * Given an IOHelper and a command line, check for the required options and return a set of IRIs
    * for terms. Handles --terms and --term-file options.
@@ -448,7 +570,7 @@ public class CommandLineHelper {
     Set<IRI> terms = getTerms(ioHelper, line, "term", "term-file");
 
     if (terms.size() == 0 && !allowEmpty) {
-      throw new IllegalArgumentException("Must specify terms to extract.");
+      throw new IllegalArgumentException(missingTermsError);
     }
     return terms;
   }
@@ -456,6 +578,7 @@ public class CommandLineHelper {
   /**
    * Given an IOHelper and a command line, and the names of two options, check for the required
    * options and return a set of IRIs for terms. Handles single term options and term-file options.
+   * Allows empty returns.
    *
    * @param ioHelper the IOHelper to use for loading the terms
    * @param line the command line to use
@@ -485,6 +608,47 @@ public class CommandLineHelper {
     return terms;
   }
 
+  /**
+   * Given a string of a reasoner name from user input, return the reasoner factory. If the user
+   * input is not valid, throw IllegalArgumentExcepiton. By default, EMR is not allowed.
+   *
+   * @param line the command line to use
+   * @return OWLReasonerFactory if successful
+   */
+  public static OWLReasonerFactory getReasonerFactory(CommandLine line) {
+    return getReasonerFactory(line, false);
+  }
+
+  /**
+   * Given a string of a reasoner name from user input, return the reasoner factory. If the user
+   * input is not valid, throw IllegalArgumentExcepiton.
+   *
+   * @param line the command line to use
+   * @param allowEMR boolean specifying if EMR can be returned
+   * @return OWLReasonerFactory if successful
+   */
+  public static OWLReasonerFactory getReasonerFactory(CommandLine line, boolean allowEMR) {
+    // ELK is the default reasoner
+    String reasonerName = getDefaultValue(line, "reasoner", "ELK").trim().toLowerCase();
+    logger.info("Reasoner: " + reasonerName);
+
+    if (reasonerName.equals("structural")) {
+      return new org.semanticweb.owlapi.reasoner.structural.StructuralReasonerFactory();
+    } else if (reasonerName.equals("hermit")) {
+      return new org.semanticweb.HermiT.Reasoner.ReasonerFactory();
+    } else if (reasonerName.equals("jfact")) {
+      return new JFactFactory();
+      // Reason must change behavior with EMR, so not all commands can use it
+    } else if (reasonerName.equals("emr") && allowEMR) {
+      ElkReasonerFactory innerReasonerFactory = new org.semanticweb.elk.owlapi.ElkReasonerFactory();
+      return new ExpressionMaterializingReasonerFactory(innerReasonerFactory);
+    } else if (reasonerName.equals("elk")) {
+      return new org.semanticweb.elk.owlapi.ElkReasonerFactory();
+    } else {
+      throw new IllegalArgumentException(String.format(invalidReasonerError, reasonerName));
+    }
+  }
+
   /**
    * Print a help message for a command.
    *
@@ -513,7 +677,7 @@ public class CommandLineHelper {
     o.addOption("V", "version", false, "print version information");
     o.addOption("v", "verbose", false, "increased logging");
     o.addOption("vv", "very-verbose", false, "high logging");
-    o.addOption("vvv", "very-very-verbose", false, "maximum logging");
+    o.addOption("vvv", "very-very-verbose", false, "maximum logging, including stack traces");
     o.addOption("p", "prefix", true, "add a prefix 'foo: http://bar'");
     o.addOption("P", "prefixes", true, "use prefixes from JSON-LD file");
     o.addOption("noprefixes", false, "do not use default prefixes");
@@ -522,19 +686,19 @@ public class CommandLineHelper {
   }
 
   /**
-   * Parse the command line, handle help and other common options, (May exit!) and return a
-   * CommandLine.
+   * Parse the command line, handle help and other common options, and return null or a CommandLine.
    *
    * @param usage the usage string for this command
    * @param options the command-line options for this command
    * @param args the command-line arguments provided
-   * @return a new CommandLine object
+   * @param stopAtNonOption same as CommandLineParser
+   * @return a new CommandLine object or null
    * @throws ParseException if the arguments cannot be parsed
    */
-  public static CommandLine getCommandLine(String usage, Options options, String[] args)
-      throws ParseException {
+  public static CommandLine maybeGetCommandLine(
+      String usage, Options options, String[] args, boolean stopAtNonOption) throws ParseException {
     CommandLineParser parser = new PosixParser();
-    CommandLine line = parser.parse(options, args);
+    CommandLine line = parser.parse(options, args, stopAtNonOption);
 
     String level;
     if (line.hasOption("very-very-verbose")) {
@@ -551,26 +715,45 @@ public class CommandLineHelper {
 
     if (hasFlagOrCommand(line, "help")) {
       printHelp(usage, options);
-      System.exit(0);
+      return null;
     }
     if (hasFlagOrCommand(line, "version")) {
       printVersion();
-      System.exit(0);
+      return null;
     }
 
     return line;
   }
 
+  /**
+   * Parse the command line, handle help and other common options, (May exit!) and return a
+   * CommandLine.
+   *
+   * @param usage the usage string for this command
+   * @param options the command-line options for this command
+   * @param args the command-line arguments provided
+   * @return a new CommandLine object or exit(0)
+   * @throws ParseException if the arguments cannot be parsed
+   */
+  public static CommandLine getCommandLine(String usage, Options options, String[] args)
+      throws ParseException {
+    CommandLine line = maybeGetCommandLine(usage, options, args, false);
+    if (line == null) {
+      System.exit(0);
+    }
+    return line;
+  }
+
   /**
    * Shared method for dealing with exceptions, printing help, and exiting. Currently prints the
-   * stack trace, then the help, then exits.
+   * error message, stack trace (DEBUG), usage, and then exits.
    *
    * @param usage the usage string for this command
    * @param options the command-line options for this command
    * @param exception the exception to handle
    */
   public static void handleException(String usage, Options options, Exception exception) {
-    exception.printStackTrace();
+    ExceptionHelper.handleException(exception);
     printHelp(usage, options);
     System.exit(1);
   }
diff --git a/robot-command/src/main/java/org/obolibrary/robot/CommandManager.java b/robot-command/src/main/java/org/obolibrary/robot/CommandManager.java
index c2302fd4..f000b120 100644
--- a/robot-command/src/main/java/org/obolibrary/robot/CommandManager.java
+++ b/robot-command/src/main/java/org/obolibrary/robot/CommandManager.java
@@ -22,6 +22,24 @@ public class CommandManager implements Command {
   /** Logger. */
   private static final Logger logger = LoggerFactory.getLogger(CommandManager.class);
 
+  /** Namespace for error messages. */
+  private static final String NS = "errors#";
+
+  /** Error message when no command is provided. */
+  private static final String missingCommandError =
+      NS + "MISSING COMMAND ERROR no command provided";
+
+  /** Error message when the command provided is null. */
+  private static final String nullCommandError = NS + "MISSING COMMAND ERROR command is null: %s";
+
+  /** Error message when no options are provided for a command. */
+  private static final String noOptionsError =
+      NS + "OPTIONS ERROR no options provided for command: %s";
+
+  /** Error message when there is an unknown command provided. */
+  private static final String unknownArgError =
+      NS + "UNKNOWN ARG ERROR unknown command or option: %s";
+
   /** Store the command-line options for the command. */
   private Options globalOptions;
 
@@ -120,7 +138,7 @@ public class CommandManager implements Command {
     try {
       execute(null, args);
     } catch (Exception e) {
-      e.printStackTrace();
+      ExceptionHelper.handleException(e);
       printHelp();
       System.exit(1);
     }
@@ -131,18 +149,13 @@ public class CommandManager implements Command {
    *
    * @param state an state to work with, or null
    * @param args the command-line arguments
-   * @return the result state of the last subcommand
+   * @return the result state of the last subcommand or null on bad input
    * @throws Exception on any problems
    */
   public CommandState execute(CommandState state, String[] args) throws Exception {
-    CommandLineParser parser = new PosixParser();
-    CommandLine line = parser.parse(getOptions(), args, true);
-    if (line.hasOption("help")) {
-      printHelp();
-      return null;
-    }
-    if (line.hasOption("version")) {
-      CommandLineHelper.printVersion();
+    CommandLine line = CommandLineHelper.maybeGetCommandLine(getUsage(), getOptions(), args, true);
+
+    if (line == null) {
       return null;
     }
 
@@ -152,7 +165,7 @@ public class CommandManager implements Command {
 
     List<String> arguments = new ArrayList<String>(Arrays.asList(args));
     if (arguments.size() == 0) {
-      throw new IllegalArgumentException("No command provided");
+      throw new IllegalArgumentException(missingCommandError);
     }
 
     List<String> globalOptionArgs = getOptionArgs(globalOptions, arguments);
@@ -183,7 +196,7 @@ public class CommandManager implements Command {
       commandName = arguments.remove(0);
     }
     if (commandName == null) {
-      throw new IllegalArgumentException("No command provided");
+      throw new IllegalArgumentException(missingCommandError);
     }
 
     commandName = commandName.trim().toLowerCase();
@@ -200,15 +213,15 @@ public class CommandManager implements Command {
       return state;
     }
     if (!commands.containsKey(commandName)) {
-      throw new IllegalArgumentException("Unknown command or option: " + commandName);
+      throw new IllegalArgumentException(String.format(unknownArgError, commandName));
     }
 
     Command command = commands.get(commandName);
     if (command == null) {
-      throw new IllegalArgumentException("Command is null: " + commandName);
+      throw new IllegalArgumentException(String.format(nullCommandError, commandName));
     }
     if (command.getOptions() == null) {
-      throw new IllegalArgumentException("Command has no options: " + commandName);
+      throw new IllegalArgumentException(String.format(noOptionsError, commandName));
     }
 
     List<String> localOptionArgs = getOptionArgs(command.getOptions(), arguments);
@@ -216,7 +229,22 @@ public class CommandManager implements Command {
     optionArgs.addAll(globalOptionArgs);
     optionArgs.addAll(localOptionArgs);
 
-    return command.execute(state, asArgs(optionArgs));
+    // Check to make sure the next provided arg is a valid command after parsing Option Args
+    if (!arguments.isEmpty()) {
+      String nextArg = arguments.get(0);
+      if (!commands.keySet().contains(nextArg)) {
+        throw new IllegalArgumentException(String.format(unknownArgError, nextArg));
+      }
+    }
+
+    try {
+      state = command.execute(state, asArgs(optionArgs));
+    } catch (Exception e) {
+      // Ensure command-specific usage info is returned
+      CommandLineHelper.handleException(command.getUsage(), command.getOptions(), e);
+    }
+
+    return state;
   }
 
   /** Print general help plus a list of available commands. */
diff --git a/robot-command/src/main/java/org/obolibrary/robot/ConvertCommand.java b/robot-command/src/main/java/org/obolibrary/robot/ConvertCommand.java
index ea95bd44..1e3b35a1 100644
--- a/robot-command/src/main/java/org/obolibrary/robot/ConvertCommand.java
+++ b/robot-command/src/main/java/org/obolibrary/robot/ConvertCommand.java
@@ -17,6 +17,19 @@ public class ConvertCommand implements Command {
   /** Logger. */
   private static final Logger logger = LoggerFactory.getLogger(ConvertCommand.class);
 
+  /** Namespace for error messages. */
+  private static final String NS = "convert#";
+
+  /** Error message when no --output is provided. */
+  private static final String missingOutputError = NS + "OUTPUT ERROR an output file is required";
+
+  /** Error message when more than one --output is provided. */
+  private static final String multipleOutputsError =
+      NS + "OUTPUT ERROR only one output file is allowed";
+
+  /** Error message when a --format is not specified and the --output does not have an extension. */
+  private static final String missingFormatError = NS + "FORMAT ERROR an output format is required";
+
   /** Store the command-line options for the command. */
   private Options options;
 
@@ -115,19 +128,24 @@ public class ConvertCommand implements Command {
     OWLOntology ontology = state.getOntology();
 
     String[] outputs = line.getOptionValues("output");
-    if (outputs.length == 0) {
-      throw new Exception("An output file is required.");
+    // Validate the output
+    if (outputs == null || outputs.length == 0) {
+      throw new IllegalArgumentException(missingOutputError);
     } else if (outputs.length > 1) {
-      throw new Exception("Only one output file is allowed.");
+      throw new IllegalArgumentException(multipleOutputsError);
     }
     File outputFile = CommandLineHelper.getOutputFile(line);
 
+    // Check for a format
     String formatName = CommandLineHelper.getOptionalValue(line, "format");
     if (formatName == null) {
       formatName = FilenameUtils.getExtension(outputFile.getName());
+      if (formatName.equals("")) {
+        throw new IllegalArgumentException(missingFormatError);
+      }
     }
 
-    ioHelper.saveOntology(ontology, ioHelper.getFormat(formatName), outputFile);
+    ioHelper.saveOntology(ontology, IOHelper.getFormat(formatName), outputFile);
 
     return state;
   }
diff --git a/robot-command/src/main/java/org/obolibrary/robot/DiffCommand.java b/robot-command/src/main/java/org/obolibrary/robot/DiffCommand.java
index 9f4b0950..f6bc89c6 100644
--- a/robot-command/src/main/java/org/obolibrary/robot/DiffCommand.java
+++ b/robot-command/src/main/java/org/obolibrary/robot/DiffCommand.java
@@ -5,6 +5,7 @@ import java.io.PrintWriter;
 import java.io.Writer;
 import org.apache.commons.cli.CommandLine;
 import org.apache.commons.cli.Options;
+import org.semanticweb.owlapi.model.IRI;
 import org.semanticweb.owlapi.model.OWLOntology;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
@@ -18,6 +19,23 @@ public class DiffCommand implements Command {
   /** Logger. */
   private static final Logger logger = LoggerFactory.getLogger(DiffCommand.class);
 
+  /** Namespace for error messages. */
+  private static final String NS = "diff#";
+
+  private static final String doubleLeftError =
+      NS + "DOUBLE INPUT ERROR only one left ontology allowed";
+
+  private static final String doubleRightError =
+      NS + "DOUBLE INPUT ERROR only one right ontology allowed";
+
+  /** Error message when --left is not provided. */
+  private static final String missingLeftError =
+      NS + "MISSING INPUT ERROR left ontology is required";
+
+  /** Error message when --right is not provided. */
+  private static final String missingRightError =
+      NS + "MISSING INPUT ERROR right ontology is required";
+
   /** Store the command-line options for the command. */
   private Options options;
 
@@ -25,9 +43,9 @@ public class DiffCommand implements Command {
   public DiffCommand() {
     Options o = CommandLineHelper.getCommonOptions();
     o.addOption("l", "left", true, "load left ontology from file");
-    // o.addOption("L", "left-iri",  true, "load left ontology from IRI");
+    o.addOption("L", "left-iri", true, "load left ontology from IRI");
     o.addOption("r", "right", true, "load right ontology from file");
-    // o.addOption("R", "right-iri", true, "load right ontology from IRI");
+    o.addOption("R", "right-iri", true, "load right ontology from IRI");
     o.addOption("o", "output", true, "save results to file");
     options = o;
   }
@@ -107,24 +125,33 @@ public class DiffCommand implements Command {
     }
     if (leftOntology == null) {
       String leftOntologyPath = CommandLineHelper.getOptionalValue(line, "left");
-      if (leftOntologyPath != null) {
+      String leftOntologyIRI = CommandLineHelper.getOptionalValue(line, "left-iri");
+      if (leftOntologyPath != null && leftOntologyIRI != null) {
+        throw new IllegalArgumentException(doubleLeftError);
+      } else if (leftOntologyPath != null) {
         leftOntology = ioHelper.loadOntology(leftOntologyPath);
+      } else if (leftOntologyIRI != null) {
+        leftOntology = ioHelper.loadOntology(IRI.create(leftOntologyIRI));
       }
     }
     if (leftOntology == null) {
-      throw new IllegalArgumentException("left ontology must be specified");
+      throw new IllegalArgumentException(missingLeftError);
     }
 
     OWLOntology rightOntology = null;
     if (rightOntology == null) {
       String rightOntologyPath = CommandLineHelper.getOptionalValue(line, "right");
-      String leftOntologyPath = CommandLineHelper.getOptionalValue(line, "left");
-      if (rightOntologyPath != null) {
+      String rightOntologyIRI = CommandLineHelper.getOptionalValue(line, "right-iri");
+      if (rightOntologyPath != null && rightOntologyIRI != null) {
+        throw new IllegalArgumentException(doubleRightError);
+      } else if (rightOntologyPath != null) {
         rightOntology = ioHelper.loadOntology(rightOntologyPath);
+      } else if (rightOntologyIRI != null) {
+        rightOntology = ioHelper.loadOntology(IRI.create(rightOntologyIRI));
       }
     }
     if (rightOntology == null) {
-      throw new IllegalArgumentException("right ontology must be specified");
+      throw new IllegalArgumentException(missingRightError);
     }
 
     Writer writer;
diff --git a/robot-command/src/main/java/org/obolibrary/robot/ExceptionHelper.java b/robot-command/src/main/java/org/obolibrary/robot/ExceptionHelper.java
new file mode 100644
index 00000000..d52f527c
--- /dev/null
+++ b/robot-command/src/main/java/org/obolibrary/robot/ExceptionHelper.java
@@ -0,0 +1,96 @@
+package org.obolibrary.robot;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Convenience class to handle formatting error messages and returning links to user support.
+ *
+ * @author <a href="mailto:rctauber@gmail.com">Becky Tauber</a>
+ */
+public class ExceptionHelper {
+
+  /** Logger. */
+  private static final Logger logger = LoggerFactory.getLogger(ExceptionHelper.class);
+
+  /**
+   * Prints the exception details as errors, including help URL (if the exception message contains a
+   * valid exception ID). Expects the exception message to be formatted as: "{ID} :: {message}". If
+   * message is not formatted, will just log the message.
+   *
+   * @param exception the exception
+   */
+  public static void handleException(Exception exception) {
+    String msg = trimExceptionClass(exception);
+    if (msg != null) {
+      String exceptionID = getExceptionID(msg);
+      System.out.println(trimExceptionID(msg));
+      System.out.println("For details see: http://robot.obolibrary.org/" + exceptionID + "\n");
+    }
+    // Will only print with --very-very-verbose (DEBUG level)
+    if (logger.isDebugEnabled()) {
+      StackTraceElement[] trace = exception.getStackTrace();
+      for (StackTraceElement t : trace) {
+        logger.debug(t.toString());
+      }
+      System.out.println();
+    }
+  }
+
+  /**
+   * Given an exception message, return the exception ID when formatted as "command#NAME OF ERROR
+   * msg". Otherwise, return an empty string.
+   *
+   * @param msg exception message
+   * @return exception ID or empty string
+   */
+  private static String getExceptionID(String msg) {
+    String exceptionID = "";
+    try {
+      exceptionID =
+          msg.substring(0, msg.indexOf("ERROR") + 5).trim().toLowerCase().replace(" ", "-");
+    } catch (NullPointerException e) {
+      logger.debug("Malformed exception message: {}", msg);
+    }
+    if (!exceptionID.contains("#")) {
+      logger.debug("Missing exception ID: {}", msg);
+      exceptionID = "";
+    }
+    return exceptionID;
+  }
+
+  /**
+   * Given a full exception message, trim the Java exception class from the start. If there is no
+   * class, just return the message. TODO: Support more throwable classes?
+   *
+   * @param exception the Exception
+   * @return trimmed exception message String
+   */
+  private static String trimExceptionClass(Exception exception) {
+    String msg = exception.getMessage();
+    if (msg == null) {
+      logger.debug("{} missing exception message.", exception);
+      return null;
+    }
+    if (msg.startsWith("java.")) {
+      return msg.substring(msg.indexOf(":") + 1).trim();
+    } else {
+      return msg;
+    }
+  }
+
+  /**
+   * Given an exception message, remove the error namespace. If no namespace is provided, just
+   * return the message.
+   *
+   * @param msg exception message
+   * @return message without the command namespace
+   */
+  private static String trimExceptionID(String msg) {
+    if (msg.contains("#")) {
+      return msg.substring(msg.indexOf("#") + 1);
+    } else {
+      return msg;
+    }
+  }
+}
diff --git a/robot-command/src/main/java/org/obolibrary/robot/ExportPrefixesCommand.java b/robot-command/src/main/java/org/obolibrary/robot/ExportPrefixesCommand.java
index c0e8202f..366bde82 100644
--- a/robot-command/src/main/java/org/obolibrary/robot/ExportPrefixesCommand.java
+++ b/robot-command/src/main/java/org/obolibrary/robot/ExportPrefixesCommand.java
@@ -1,6 +1,7 @@
 package org.obolibrary.robot;
 
 import java.io.File;
+import java.io.IOException;
 import org.apache.commons.cli.CommandLine;
 import org.apache.commons.cli.Options;
 import org.slf4j.Logger;
@@ -15,6 +16,12 @@ public class ExportPrefixesCommand implements Command {
   /** Logger. */
   private static final Logger logger = LoggerFactory.getLogger(ExportPrefixesCommand.class);
 
+  /** Namespace for error messages. */
+  private static final String NS = "prefixes#";
+
+  /** Error message when JSON-LD context cannot be generated. */
+  private static final String jsonLDError = NS + "JSON-LD ERROR the JSON-LD could not be generated";
+
   /** Store the command-line options for the command. */
   private Options options;
 
@@ -93,9 +100,17 @@ public class ExportPrefixesCommand implements Command {
 
     File outputFile = CommandLineHelper.getOutputFile(line);
     if (outputFile != null) {
-      ioHelper.saveContext(outputFile);
+      try {
+        ioHelper.saveContext(outputFile);
+      } catch (IOException e) {
+        throw new IOException(jsonLDError, e);
+      }
     } else {
-      System.out.println(ioHelper.getContextString());
+      try {
+        System.out.println(ioHelper.getContextString());
+      } catch (IOException e) {
+        throw new IOException(jsonLDError, e);
+      }
     }
 
     return state;
diff --git a/robot-command/src/main/java/org/obolibrary/robot/ExtractCommand.java b/robot-command/src/main/java/org/obolibrary/robot/ExtractCommand.java
index 46f58d00..786be3d4 100644
--- a/robot-command/src/main/java/org/obolibrary/robot/ExtractCommand.java
+++ b/robot-command/src/main/java/org/obolibrary/robot/ExtractCommand.java
@@ -1,6 +1,7 @@
 package org.obolibrary.robot;
 
 import java.util.ArrayList;
+import java.util.Arrays;
 import java.util.List;
 import java.util.Set;
 import org.apache.commons.cli.CommandLine;
@@ -20,6 +21,31 @@ public class ExtractCommand implements Command {
   /** Logger. */
   private static final Logger logger = LoggerFactory.getLogger(ExtractCommand.class);
 
+  /** Namespace for error messages. */
+  private static final String NS = "extract#";
+
+  /** Error message when lower or branch terms are not specified with MIREOT. */
+  private static final String missingMireotTermsError =
+      NS
+          + "MISSING MIREOT TERMS ERROR "
+          + "either lower term(s) or branch term(s) must be specified for MIREOT";
+
+  /** Error message when only upper terms are specified with MIREOT. */
+  private static final String missingLowerTermError =
+      NS
+          + "MISSING LOWER TERMS ERROR "
+          + "lower term(s) must be specified with upper term(s) for MIREOT";
+
+  /** Error message when user provides invalid extraction method. */
+  private static final String invalidMethodError =
+      NS + "INVALID METHOD ERROR method must be: MIREOT, STAR, TOP, BOT";
+
+  private static final String invalidOptionError =
+      NS
+          + "INVALID OPTION ERROR "
+          + "only --term or --term-file can be used to specify extract term(s) "
+          + "for STAR, TOP, or BOT";
+
   /** Store the command-line options for the command. */
   private Options options;
 
@@ -120,8 +146,12 @@ public class ExtractCommand implements Command {
       outputIRI = inputOntology.getOntologyID().getOntologyIRI().orNull();
     }
 
+    // Get method, make sure it has been specified
     String method =
-        CommandLineHelper.getDefaultValue(line, "method", "mireot").trim().toLowerCase();
+        CommandLineHelper.getRequiredValue(line, "method", "method of extraction must be specified")
+            .trim()
+            .toLowerCase();
+
     ModuleType moduleType = null;
     if (method.equals("star")) {
       moduleType = ModuleType.STAR;
@@ -133,30 +163,74 @@ public class ExtractCommand implements Command {
 
     if (method.equals("mireot")) {
       List<OWLOntology> outputOntologies = new ArrayList<OWLOntology>();
-
-      Set<IRI> upperIRIs = CommandLineHelper.getTerms(ioHelper, line, "upper-term", "upper-terms");
-      if (upperIRIs != null && upperIRIs.size() == 0) {
+      // Get terms from input (ensuring that they are in the input ontology)
+      // It's okay for any of these to return empty (allowEmpty = true)
+      // Checks for empty sets later
+      Set<IRI> upperIRIs =
+          OntologyHelper.filterExistingTerms(
+              inputOntology,
+              CommandLineHelper.getTerms(ioHelper, line, "upper-term", "upper-terms"),
+              true);
+      if (upperIRIs.size() == 0) {
         upperIRIs = null;
       }
-      Set<IRI> lowerIRIs = CommandLineHelper.getTerms(ioHelper, line, "lower-term", "lower-terms");
-      if (lowerIRIs != null && lowerIRIs.size() != 0) {
-        outputOntologies.add(
-            MireotOperation.getAncestors(inputOntology, upperIRIs, lowerIRIs, null));
+      Set<IRI> lowerIRIs =
+          OntologyHelper.filterExistingTerms(
+              inputOntology,
+              CommandLineHelper.getTerms(ioHelper, line, "lower-term", "lower-terms"),
+              true);
+      if (lowerIRIs.size() == 0) {
+        lowerIRIs = null;
       }
-
       Set<IRI> branchIRIs =
-          CommandLineHelper.getTerms(ioHelper, line, "branch-from-term", "branch-from-terms");
-      if (branchIRIs != null && branchIRIs.size() != 0) {
-        outputOntologies.add(MireotOperation.getDescendants(inputOntology, branchIRIs, null));
+          OntologyHelper.filterExistingTerms(
+              inputOntology,
+              CommandLineHelper.getTerms(ioHelper, line, "branch-from-term", "branch-from-terms"),
+              true);
+      if (branchIRIs.size() == 0) {
+        branchIRIs = null;
       }
 
+      // Need branch IRIs or lower IRIs to proceed
+      if (branchIRIs == null && lowerIRIs == null) {
+        throw new IllegalArgumentException(missingMireotTermsError);
+      } else {
+        // First check for lower IRIs, upper IRIs can be null or not
+        if (lowerIRIs != null) {
+          outputOntologies.add(
+              MireotOperation.getAncestors(inputOntology, upperIRIs, lowerIRIs, null));
+          // If there are no lower IRIs, there shouldn't be any upper IRIs
+        } else if (upperIRIs != null) {
+          throw new IllegalArgumentException(missingLowerTermError);
+        }
+        // Check for branch IRIs
+        if (branchIRIs != null) {
+          outputOntologies.add(MireotOperation.getDescendants(inputOntology, branchIRIs, null));
+        }
+      }
       outputOntology = MergeOperation.merge(outputOntologies);
     } else if (moduleType != null) {
-      outputOntology =
-          ExtractOperation.extract(
-              inputOntology, CommandLineHelper.getTerms(ioHelper, line), outputIRI, moduleType);
+      // upper-term, lower-term, and branch-from term should not be used
+      List<String> mireotTerms =
+          Arrays.asList(
+              CommandLineHelper.getOptionalValue(line, "upper-term"),
+              CommandLineHelper.getOptionalValue(line, "upper-terms"),
+              CommandLineHelper.getOptionalValue(line, "lower-term"),
+              CommandLineHelper.getOptionalValue(line, "lower-terms"),
+              CommandLineHelper.getOptionalValue(line, "branch-from-term"),
+              CommandLineHelper.getOptionalValue(line, "branch-from-terms"));
+      for (String mt : mireotTerms) {
+        if (mt != null) {
+          throw new IllegalArgumentException(invalidOptionError);
+        }
+      }
+      // Make sure the terms exist in the input ontology
+      Set<IRI> terms =
+          OntologyHelper.filterExistingTerms(
+              inputOntology, CommandLineHelper.getTerms(ioHelper, line), false);
+      outputOntology = ExtractOperation.extract(inputOntology, terms, outputIRI, moduleType);
     } else {
-      throw new Exception("Method must be: MIREOT, STAR, TOP, BOT");
+      throw new Exception(invalidMethodError);
     }
 
     CommandLineHelper.maybeSaveOutput(line, outputOntology);
diff --git a/robot-command/src/main/java/org/obolibrary/robot/FilterCommand.java b/robot-command/src/main/java/org/obolibrary/robot/FilterCommand.java
index 0b193553..dbf6ef7e 100644
--- a/robot-command/src/main/java/org/obolibrary/robot/FilterCommand.java
+++ b/robot-command/src/main/java/org/obolibrary/robot/FilterCommand.java
@@ -105,7 +105,10 @@ public class FilterCommand implements Command {
     state = CommandLineHelper.updateInputOntology(ioHelper, state, line);
     OWLOntology ontology = state.getOntology();
 
-    Set<IRI> terms = CommandLineHelper.getTerms(ioHelper, line);
+    // Filter out terms that are not in the ontology
+    Set<IRI> terms =
+        OntologyHelper.filterExistingTerms(
+            ontology, CommandLineHelper.getTerms(ioHelper, line), false);
     Set<OWLObjectProperty> properties = new HashSet<OWLObjectProperty>();
     for (IRI term : terms) {
       properties.add(
diff --git a/robot-command/src/main/java/org/obolibrary/robot/MaterializeCommand.java b/robot-command/src/main/java/org/obolibrary/robot/MaterializeCommand.java
index 5edb6da0..e6b64f8e 100644
--- a/robot-command/src/main/java/org/obolibrary/robot/MaterializeCommand.java
+++ b/robot-command/src/main/java/org/obolibrary/robot/MaterializeCommand.java
@@ -121,20 +121,7 @@ public class MaterializeCommand implements Command {
     IOHelper ioHelper = CommandLineHelper.getIOHelper(line);
     state = CommandLineHelper.updateInputOntology(ioHelper, state, line);
     OWLOntology ontology = state.getOntology();
-
-    // ELK is the default reasoner
-    String reasonerName =
-        CommandLineHelper.getDefaultValue(line, "reasoner", "ELK").trim().toLowerCase();
-    OWLReasonerFactory reasonerFactory;
-    if (reasonerName.equals("structural")) {
-      reasonerFactory = new org.semanticweb.owlapi.reasoner.structural.StructuralReasonerFactory();
-
-    } else if (reasonerName.equals("hermit")) {
-      reasonerFactory = new org.semanticweb.HermiT.Reasoner.ReasonerFactory();
-
-    } else {
-      reasonerFactory = new org.semanticweb.elk.owlapi.ElkReasonerFactory();
-    }
+    OWLReasonerFactory reasonerFactory = CommandLineHelper.getReasonerFactory(line);
 
     // Override default reasoner options with command-line options
     Map<String, String> reasonerOptions = ReasonOperation.getDefaultOptions();
diff --git a/robot-command/src/main/java/org/obolibrary/robot/MergeCommand.java b/robot-command/src/main/java/org/obolibrary/robot/MergeCommand.java
index a5d6dddf..0dfa2a39 100644
--- a/robot-command/src/main/java/org/obolibrary/robot/MergeCommand.java
+++ b/robot-command/src/main/java/org/obolibrary/robot/MergeCommand.java
@@ -26,6 +26,7 @@ public class MergeCommand implements Command {
     Options o = CommandLineHelper.getCommonOptions();
     o.addOption("i", "input", true, "merge ontology from a file");
     o.addOption("I", "input-iri", true, "merge ontology from an IRI");
+    o.addOption("p", "inputs", true, "merge ontologies matching wildcard pattern");
     o.addOption("o", "output", true, "save merged ontology to a file");
     o.addOption(
         "c",
@@ -108,14 +109,13 @@ public class MergeCommand implements Command {
     }
 
     List<OWLOntology> inputOntologies = new ArrayList<OWLOntology>();
+    // inputOntologies should not be empty
+    boolean notEmpty = false;
     if (state != null && state.getOntology() != null) {
+      notEmpty = true;
       inputOntologies.add(state.getOntology());
     }
-    inputOntologies.addAll(CommandLineHelper.getInputOntologies(ioHelper, line));
-
-    if (inputOntologies.size() < 1) {
-      throw new IllegalArgumentException("at least one inputOntology must be specified");
-    }
+    inputOntologies.addAll(CommandLineHelper.getInputOntologies(ioHelper, line, notEmpty));
 
     Map<String, String> mergeOptions = MergeOperation.getDefaultOptions();
 
diff --git a/robot-command/src/main/java/org/obolibrary/robot/MirrorCommand.java b/robot-command/src/main/java/org/obolibrary/robot/MirrorCommand.java
index 63d8c579..c1e0c953 100644
--- a/robot-command/src/main/java/org/obolibrary/robot/MirrorCommand.java
+++ b/robot-command/src/main/java/org/obolibrary/robot/MirrorCommand.java
@@ -104,7 +104,7 @@ public class MirrorCommand implements Command {
     File catalogFile = CommandLineHelper.getOutputFile(line);
     if (catalogFile == null) {
       catalogFile = new File(dir + "/catalog-v001.xml");
-      logger.info("Using default catalog with specified directory:" + catalogFile);
+      logger.info("Using default catalog with specified directory: " + catalogFile);
     }
 
     MirrorOperation.mirror(ontology, new File(dir), catalogFile);
diff --git a/robot-command/src/main/java/org/obolibrary/robot/QueryCommand.java b/robot-command/src/main/java/org/obolibrary/robot/QueryCommand.java
index 9a7a0bc9..692a7ac7 100644
--- a/robot-command/src/main/java/org/obolibrary/robot/QueryCommand.java
+++ b/robot-command/src/main/java/org/obolibrary/robot/QueryCommand.java
@@ -23,6 +23,12 @@ public class QueryCommand implements Command {
   /** Logger. */
   private static final Logger logger = LoggerFactory.getLogger(QueryCommand.class);
 
+  /** Namespace for error messages. */
+  private static final String NS = "query#";
+
+  private static final String missingQueryError =
+      NS + "MISSING QUERY ERROR at least one query must be provided";
+
   /** Store the command-line options for the command. */
   private Options options;
 
@@ -148,6 +154,10 @@ public class QueryCommand implements Command {
       queries.add(xs);
     }
 
+    if (queries.isEmpty()) {
+      throw new IllegalArgumentException(missingQueryError);
+    }
+
     // Run queries
     for (List<String> q : queries) {
       String queryPath = q.get(0);
diff --git a/robot-command/src/main/java/org/obolibrary/robot/ReasonCommand.java b/robot-command/src/main/java/org/obolibrary/robot/ReasonCommand.java
index af0e16c2..82190166 100644
--- a/robot-command/src/main/java/org/obolibrary/robot/ReasonCommand.java
+++ b/robot-command/src/main/java/org/obolibrary/robot/ReasonCommand.java
@@ -3,13 +3,10 @@ package org.obolibrary.robot;
 import java.util.Map;
 import org.apache.commons.cli.CommandLine;
 import org.apache.commons.cli.Options;
-import org.geneontology.reasoner.ExpressionMaterializingReasonerFactory;
-import org.semanticweb.elk.owlapi.ElkReasonerFactory;
 import org.semanticweb.owlapi.model.OWLOntology;
 import org.semanticweb.owlapi.reasoner.OWLReasonerFactory;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
-import uk.ac.manchester.cs.jfact.JFactFactory;
 
 /**
  * Handles inputs and outputs for the {@link ReasonOperation}.
@@ -130,27 +127,7 @@ public class ReasonCommand implements Command {
     IOHelper ioHelper = CommandLineHelper.getIOHelper(line);
     state = CommandLineHelper.updateInputOntology(ioHelper, state, line);
     OWLOntology ontology = state.getOntology();
-
-    // ELK is the default reasoner
-    String reasonerName =
-        CommandLineHelper.getDefaultValue(line, "reasoner", "ELK").trim().toLowerCase();
-    OWLReasonerFactory reasonerFactory;
-    if (reasonerName.equals("structural")) {
-      reasonerFactory = new org.semanticweb.owlapi.reasoner.structural.StructuralReasonerFactory();
-
-    } else if (reasonerName.equals("hermit")) {
-      reasonerFactory = new org.semanticweb.HermiT.Reasoner.ReasonerFactory();
-
-    } else if (reasonerName.equals("jfact")) {
-      reasonerFactory = new JFactFactory();
-
-    } else if (reasonerName.equals("emr")) {
-      ElkReasonerFactory innerReasonerFactory = new org.semanticweb.elk.owlapi.ElkReasonerFactory();
-      reasonerFactory = new ExpressionMaterializingReasonerFactory(innerReasonerFactory);
-
-    } else {
-      reasonerFactory = new org.semanticweb.elk.owlapi.ElkReasonerFactory();
-    }
+    OWLReasonerFactory reasonerFactory = CommandLineHelper.getReasonerFactory(line, true);
 
     // Override default reasoner options with command-line options
     Map<String, String> reasonerOptions = ReasonOperation.getDefaultOptions();
@@ -159,8 +136,6 @@ public class ReasonCommand implements Command {
         reasonerOptions.put(option, line.getOptionValue(option));
       }
     }
-    logger.info("Reasoner: " + reasonerName);
-
     ReasonOperation.reason(ontology, reasonerFactory, reasonerOptions);
 
     CommandLineHelper.maybeSaveOutput(line, ontology);
diff --git a/robot-command/src/main/java/org/obolibrary/robot/ReduceCommand.java b/robot-command/src/main/java/org/obolibrary/robot/ReduceCommand.java
index 18802230..7b6d405a 100644
--- a/robot-command/src/main/java/org/obolibrary/robot/ReduceCommand.java
+++ b/robot-command/src/main/java/org/obolibrary/robot/ReduceCommand.java
@@ -102,18 +102,7 @@ public class ReduceCommand implements Command {
     IOHelper ioHelper = CommandLineHelper.getIOHelper(line);
     state = CommandLineHelper.updateInputOntology(ioHelper, state, line);
     OWLOntology ontology = state.getOntology();
-
-    // ELK is the default reasoner
-    String reasonerName =
-        CommandLineHelper.getDefaultValue(line, "reasoner", "ELK").trim().toLowerCase();
-    OWLReasonerFactory reasonerFactory;
-    if (reasonerName.equals("structural")) {
-      reasonerFactory = new org.semanticweb.owlapi.reasoner.structural.StructuralReasonerFactory();
-    } else if (reasonerName.equals("hermit")) {
-      reasonerFactory = new org.semanticweb.HermiT.Reasoner.ReasonerFactory();
-    } else {
-      reasonerFactory = new org.semanticweb.elk.owlapi.ElkReasonerFactory();
-    }
+    OWLReasonerFactory reasonerFactory = CommandLineHelper.getReasonerFactory(line);
 
     // Override default reasoner options with command-line options
     Map<String, String> reasonerOptions = ReduceOperation.getDefaultOptions();
diff --git a/robot-command/src/main/java/org/obolibrary/robot/RelaxCommand.java b/robot-command/src/main/java/org/obolibrary/robot/RelaxCommand.java
index 593170cc..3ce07c27 100644
--- a/robot-command/src/main/java/org/obolibrary/robot/RelaxCommand.java
+++ b/robot-command/src/main/java/org/obolibrary/robot/RelaxCommand.java
@@ -52,7 +52,7 @@ public class RelaxCommand implements Command {
    * @return usage
    */
   public String getUsage() {
-    return "robot relax --input <file> " + "--reasoner <name> " + "[options] " + "--output <file>";
+    return "robot relax --input <file> " + "[options] " + "--output <file>";
   }
 
   /**
diff --git a/robot-command/src/main/java/org/obolibrary/robot/TemplateCommand.java b/robot-command/src/main/java/org/obolibrary/robot/TemplateCommand.java
index 231b908c..3f8ae849 100644
--- a/robot-command/src/main/java/org/obolibrary/robot/TemplateCommand.java
+++ b/robot-command/src/main/java/org/obolibrary/robot/TemplateCommand.java
@@ -21,6 +21,13 @@ public class TemplateCommand implements Command {
   /** Logger. */
   private static final Logger logger = LoggerFactory.getLogger(TemplateCommand.class);
 
+  /** Namespace for error messages. */
+  private static final String NS = "template#";
+
+  /** Error message when a template is not provided. */
+  private static final String missingTemplateError =
+      NS + "MISSING TEMPLATE ERROR at least one template is required";
+
   /** Store the command-line options for the command. */
   private Options options;
 
@@ -114,11 +121,11 @@ public class TemplateCommand implements Command {
     // Read the whole CSV into a nested list of strings.
     List<String> templatePaths = CommandLineHelper.getOptionalValues(line, "template");
     if (templatePaths.size() == 0) {
-      throw new Exception("At least one template is required");
+      throw new IllegalArgumentException(missingTemplateError);
     }
     Map<String, List<List<String>>> tables = new LinkedHashMap<String, List<List<String>>>();
     for (String templatePath : templatePaths) {
-      tables.put(templatePath, ioHelper.readTable(templatePath));
+      tables.put(templatePath, TemplateHelper.readTable(templatePath));
     }
 
     OWLOntology outputOntology = TemplateOperation.template(tables, inputOntology, null, ioHelper);
@@ -131,7 +138,7 @@ public class TemplateCommand implements Command {
     List<OWLOntology> ontologies;
     if (line.hasOption("ancestors") && inputOntology != null) {
       Set<IRI> iris = OntologyHelper.getIRIs(outputOntology);
-      iris.removeAll(TemplateOperation.getIRIs(tables, ioHelper));
+      iris.removeAll(TemplateHelper.getIRIs(tables, ioHelper));
       OWLOntology ancestors =
           MireotOperation.getAncestors(
               inputOntology, null, iris, MireotOperation.getDefaultAnnotationProperties());
diff --git a/robot-command/src/main/java/org/obolibrary/robot/UnmergeCommand.java b/robot-command/src/main/java/org/obolibrary/robot/UnmergeCommand.java
index 0cd43caa..acc37cee 100644
--- a/robot-command/src/main/java/org/obolibrary/robot/UnmergeCommand.java
+++ b/robot-command/src/main/java/org/obolibrary/robot/UnmergeCommand.java
@@ -25,6 +25,7 @@ public class UnmergeCommand implements Command {
     Options o = CommandLineHelper.getCommonOptions();
     o.addOption("i", "input", true, "unmerge ontology from a file");
     o.addOption("I", "input-iri", true, "unmerge ontology from an IRI");
+    o.addOption("p", "inputs", true, "unmerge ontologies matching wildcard pattern");
     o.addOption("o", "output", true, "save unmerged ontology to a file");
     options = o;
   }
@@ -101,14 +102,12 @@ public class UnmergeCommand implements Command {
     }
 
     List<OWLOntology> inputOntologies = new ArrayList<OWLOntology>();
+    boolean notEmpty = false;
     if (state != null && state.getOntology() != null) {
+      notEmpty = true;
       inputOntologies.add(state.getOntology());
     }
-    inputOntologies.addAll(CommandLineHelper.getInputOntologies(ioHelper, line));
-
-    if (inputOntologies.size() < 1) {
-      throw new IllegalArgumentException("at least one inputOntology must be specified");
-    }
+    inputOntologies.addAll(CommandLineHelper.getInputOntologies(ioHelper, line, notEmpty));
 
     OWLOntology outputOntology = UnmergeOperation.unmerge(inputOntologies);
 
diff --git a/robot-command/src/main/java/org/obolibrary/robot/ValidateProfileCommand.java b/robot-command/src/main/java/org/obolibrary/robot/ValidateProfileCommand.java
index c34ce69e..7ad26df4 100644
--- a/robot-command/src/main/java/org/obolibrary/robot/ValidateProfileCommand.java
+++ b/robot-command/src/main/java/org/obolibrary/robot/ValidateProfileCommand.java
@@ -16,6 +16,21 @@ public class ValidateProfileCommand implements Command {
   /** Store the command-line options for the command. */
   private final Options options;
 
+  /** Namespace for error messages. */
+  private static final String NS = "validate#";
+
+  /** Error message when a profile is not provided. */
+  private static final String missingProfileError =
+      NS + "MISSING PROFILE ERROR a profile is required";
+
+  /** Error message when an invalid profile is provided. Expects profile. */
+  private static final String invalidProfileError =
+      NS + "INVALID PROFILE ERROR unknown profile: %s";
+
+  /** Error message when the ontology validates provided profile. Expects ontology IRI, profile. */
+  private static final String profileViolationError =
+      NS + "PROFILE VIOLATION ERROR %1 violates profile %2";
+
   /** Initialize the command. */
   public ValidateProfileCommand() {
     Options o = CommandLineHelper.getCommonOptions();
@@ -64,7 +79,7 @@ public class ValidateProfileCommand implements Command {
     IOHelper ioHelper = CommandLineHelper.getIOHelper(line);
     state = CommandLineHelper.updateInputOntology(ioHelper, state, line);
     OWLOntology ontology = state.getOntology();
-    String profile = CommandLineHelper.getRequiredValue(line, "profile", "A --profile is required");
+    String profile = CommandLineHelper.getRequiredValue(line, "profile", missingProfileError);
     profile = profile.toUpperCase();
     final OWLProfile owlProfile;
     switch (profile) {
@@ -84,7 +99,7 @@ public class ValidateProfileCommand implements Command {
         owlProfile = new OWL2Profile();
         break; // #162
       default:
-        throw new Exception("Invalid profile: " + profile);
+        throw new IllegalArgumentException(String.format(invalidProfileError, profile));
     }
     OWLProfileReport report = owlProfile.checkOntology(ontology);
     File outputFile = CommandLineHelper.getOutputFile(line);
@@ -96,7 +111,9 @@ public class ValidateProfileCommand implements Command {
       System.out.println(report.toString());
     }
     if (!report.isInProfile()) {
-      throw new Exception("Ontology is not in profile " + profile);
+      throw new Exception(
+          String.format(
+              profileViolationError, ontology.getOntologyID().getOntologyIRI().orNull(), profile));
     }
     return state;
   }
diff --git a/robot-command/src/main/java/org/obolibrary/robot/VerifyCommand.java b/robot-command/src/main/java/org/obolibrary/robot/VerifyCommand.java
index 2e6a569f..02fdfc13 100644
--- a/robot-command/src/main/java/org/obolibrary/robot/VerifyCommand.java
+++ b/robot-command/src/main/java/org/obolibrary/robot/VerifyCommand.java
@@ -29,6 +29,13 @@ public class VerifyCommand implements Command {
   /** Logger. */
   private static final Logger logger = LoggerFactory.getLogger(VerifyCommand.class);
 
+  /** Namespace for error messages. */
+  private static final String NS = "verify#";
+
+  /** Error message when no query is provided. */
+  private static final String missingQueryError =
+      NS + "MISSING QUERY ERROR at least one query is required";
+
   /** Store the command-line options for the command. */
   private Options options;
 
@@ -116,7 +123,11 @@ public class VerifyCommand implements Command {
     File outputDir = new File(CommandLineHelper.getDefaultValue(line, "output-dir", "."));
 
     Map<File, Tuple<ResultSetRewindable, OutputStream>> resultMap = new HashMap<>();
-    for (String filePath : line.getOptionValues("queries")) {
+    String[] queryFilePaths = line.getOptionValues("queries");
+    if (queryFilePaths.length == 0) {
+      throw new IllegalArgumentException(missingQueryError);
+    }
+    for (String filePath : queryFilePaths) {
       File query = new File(filePath);
       ResultSet results = QueryOperation.execQuery(graph, fileContents(query));
       ResultSetRewindable resultsCopy = ResultSetFactory.copyResults(results);
@@ -143,6 +154,7 @@ public class VerifyCommand implements Command {
       return Files.toString(file, Charset.defaultCharset());
     } catch (IOException e) {
       String message = "Cannot read from " + file + ": " + e.getMessage();
+      // TODO: Is this necessary?
       throw new CannotReadQuery(message, e);
     }
   }
diff --git a/robot-core/src/main/java/org/obolibrary/robot/FilterOperation.java b/robot-core/src/main/java/org/obolibrary/robot/FilterOperation.java
index b9dd621b..f0b65f8d 100644
--- a/robot-core/src/main/java/org/obolibrary/robot/FilterOperation.java
+++ b/robot-core/src/main/java/org/obolibrary/robot/FilterOperation.java
@@ -24,12 +24,11 @@ public class FilterOperation {
    * @param properties a set of OWLObjectProperties to retain
    */
   public static void filter(OWLOntology ontology, Set<OWLObjectProperty> properties) {
-    System.out.println(properties);
     logger.debug("Filtering ontology for axioms with ObjectProperties " + properties);
 
     OWLOntologyManager manager = ontology.getOWLOntologyManager();
     Set<OWLAxiom> axioms = ontology.getAxioms();
-    logger.debug("Ontology has {} axioms before filtering", axioms);
+    logger.debug("Ontology has {} axioms before filtering", axioms.size());
 
     // For each axiom, get all its object properties,
     // then remove the properties that we're looking for.
diff --git a/robot-core/src/main/java/org/obolibrary/robot/IOHelper.java b/robot-core/src/main/java/org/obolibrary/robot/IOHelper.java
index 8ab6c564..4f57c699 100644
--- a/robot-core/src/main/java/org/obolibrary/robot/IOHelper.java
+++ b/robot-core/src/main/java/org/obolibrary/robot/IOHelper.java
@@ -8,17 +8,12 @@ import com.github.jsonldjava.core.JsonLdProcessor;
 import com.github.jsonldjava.utils.JsonUtils;
 import com.hp.hpl.jena.rdf.model.Model;
 import com.hp.hpl.jena.rdf.model.ModelFactory;
-import com.opencsv.CSVReader;
 import java.io.ByteArrayInputStream;
 import java.io.ByteArrayOutputStream;
 import java.io.File;
-import java.io.FileReader;
 import java.io.FileWriter;
 import java.io.IOException;
 import java.io.InputStream;
-import java.io.InputStreamReader;
-import java.io.Reader;
-import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.HashMap;
 import java.util.HashSet;
@@ -64,9 +59,6 @@ public class IOHelper {
   /** Logger. */
   private static final Logger logger = LoggerFactory.getLogger(IOHelper.class);
 
-  /** RDF literal separator. */
-  private static String seperator = "\"^^";
-
   /** Path to default context as a resource. */
   private static String defaultContextPath = "/obo_context.jsonld";
 
@@ -242,6 +234,10 @@ public class IOHelper {
   public OWLOntology loadOntology(File ontologyFile, File catalogFile) throws IOException {
     logger.debug("Loading ontology {} with catalog file {}", ontologyFile, catalogFile);
 
+    if (!ontologyFile.exists()) {
+      throw new IllegalArgumentException("File " + ontologyFile.getName() + " does not exist");
+    }
+
     Object jsonObject = null;
 
     try {
@@ -277,9 +273,9 @@ public class IOHelper {
       }
       return manager.loadOntologyFromOntologyDocument(ontologyFile);
     } catch (JsonLdError e) {
-      throw new IOException(e);
+      throw new IOException("File " + ontologyFile.getName() + " is not a valid ontology", e);
     } catch (OWLOntologyCreationException e) {
-      throw new IOException(e);
+      throw new IOException("File " + ontologyFile.getName() + " is not a valid ontology", e);
     }
   }
 
@@ -354,7 +350,7 @@ public class IOHelper {
     } else if (formatName.equals("json")) {
       return new OboGraphJsonDocumentFormat();
     } else {
-      throw new IllegalArgumentException("Unknown ontology format: " + formatName);
+      throw new IllegalArgumentException("Invalid ontology format: " + formatName);
     }
   }
 
@@ -391,13 +387,9 @@ public class IOHelper {
    * @throws IOException on any problem
    */
   public OWLOntology saveOntology(final OWLOntology ontology, IRI ontologyIRI) throws IOException {
-    try {
-      String formatName = FilenameUtils.getExtension(ontologyIRI.toString());
-      OWLDocumentFormat format = getFormat(formatName);
-      return saveOntology(ontology, format, ontologyIRI);
-    } catch (IllegalArgumentException e) {
-      throw new IOException(e);
-    }
+    String formatName = FilenameUtils.getExtension(ontologyIRI.toString());
+    OWLDocumentFormat format = getFormat(formatName);
+    return saveOntology(ontology, format, ontologyIRI);
   }
 
   /**
@@ -425,7 +417,8 @@ public class IOHelper {
    */
   public OWLOntology saveOntology(
       final OWLOntology ontology, OWLDocumentFormat format, IRI ontologyIRI) throws IOException {
-    logger.debug("Saving ontology {} as {} with to IRI {}", ontology, format, ontologyIRI);
+    logger.debug(
+        "Saving ontology {} as {} to IRI {}", ontology.getOntologyID(), format, ontologyIRI);
 
     // if (format instanceof PrefixOWLDocumentFormat) {
     //    ((PrefixOWLDocumentFormat) format)
@@ -520,6 +513,9 @@ public class IOHelper {
       IRI iri = createIRI(term);
       if (iri != null) {
         iris.add(iri);
+      } else {
+        // Warn and continue
+        logger.warn("{} is not a valid IRI.", term);
       }
     }
     return iris;
@@ -787,7 +783,7 @@ public class IOHelper {
               JsonUtils.fromString("{}"), context.getPrefixes(false), new JsonLdOptions());
       return JsonUtils.toPrettyString(compact);
     } catch (Exception e) {
-      throw new IOException("JSON-LD could not be generated", e);
+      throw new IOException(e);
     }
   }
 
@@ -812,104 +808,4 @@ public class IOHelper {
     writer.write(getContextString());
     writer.close();
   }
-
-  /**
-   * Read comma-separated values from a path to a list of lists of strings.
-   *
-   * @param path file path to the CSV file
-   * @return a list of lists of strings
-   * @throws IOException on file or reading problems
-   */
-  public static List<List<String>> readCSV(String path) throws IOException {
-    return readCSV(new FileReader(path));
-  }
-
-  /**
-   * Read comma-separated values from a stream to a list of lists of strings.
-   *
-   * @param stream the stream to read from
-   * @return a list of lists of strings
-   * @throws IOException on file or reading problems
-   */
-  public static List<List<String>> readCSV(InputStream stream) throws IOException {
-    return readCSV(new InputStreamReader(stream));
-  }
-
-  /**
-   * Read comma-separated values from a reader to a list of lists of strings.
-   *
-   * @param reader a reader to read data from
-   * @return a list of lists of strings
-   * @throws IOException on file or reading problems
-   */
-  public static List<List<String>> readCSV(Reader reader) throws IOException {
-    CSVReader csv = new CSVReader(reader);
-    List<List<String>> rows = new ArrayList<List<String>>();
-    String[] nextLine;
-    while ((nextLine = csv.readNext()) != null) {
-      rows.add(new ArrayList<String>(Arrays.asList(nextLine)));
-    }
-    csv.close();
-    return rows;
-  }
-
-  /**
-   * Read tab-separated values from a path to a list of lists of strings.
-   *
-   * @param path file path to the CSV file
-   * @return a list of lists of strings
-   * @throws IOException on file or reading problems
-   */
-  public static List<List<String>> readTSV(String path) throws IOException {
-    return readTSV(new FileReader(path));
-  }
-
-  /**
-   * Read tab-separated values from a stream to a list of lists of strings.
-   *
-   * @param stream the stream to read from
-   * @return a list of lists of strings
-   * @throws IOException on file or reading problems
-   */
-  public static List<List<String>> readTSV(InputStream stream) throws IOException {
-    return readTSV(new InputStreamReader(stream));
-  }
-
-  /**
-   * Read tab-separated values from a reader to a list of lists of strings.
-   *
-   * @param reader a reader to read data from
-   * @return a list of lists of strings
-   * @throws IOException on file or reading problems
-   */
-  public static List<List<String>> readTSV(Reader reader) throws IOException {
-    CSVReader csv = new CSVReader(reader, '\t');
-    List<List<String>> rows = new ArrayList<List<String>>();
-    String[] nextLine;
-    while ((nextLine = csv.readNext()) != null) {
-      rows.add(new ArrayList<String>(Arrays.asList(nextLine)));
-    }
-    csv.close();
-    return rows;
-  }
-
-  /**
-   * Read a table from a path to a list of lists of strings.
-   *
-   * @param path file path to the CSV file
-   * @return a list of lists of strings
-   * @throws IOException on file or reading problems
-   */
-  public static List<List<String>> readTable(String path) throws IOException {
-    File file = new File(path);
-    String extension = FilenameUtils.getExtension(file.getName());
-    extension = extension.trim().toLowerCase();
-    if (extension.equals("csv")) {
-      return readCSV(new FileReader(path));
-    } else if (extension.equals("tsv") || extension.equals("tab")) {
-      return readTSV(new FileReader(path));
-    } else {
-      throw new IOException("Unrecognized file type for: " + path);
-    }
-  }
 }
diff --git a/robot-core/src/main/java/org/obolibrary/robot/MirrorOperation.java b/robot-core/src/main/java/org/obolibrary/robot/MirrorOperation.java
index 0054a781..e8486c2f 100644
--- a/robot-core/src/main/java/org/obolibrary/robot/MirrorOperation.java
+++ b/robot-core/src/main/java/org/obolibrary/robot/MirrorOperation.java
@@ -133,7 +133,7 @@ public class MirrorOperation {
       // less imports than actually declared
       // assume something went wrong, throw Exception
       throw new IOException(
-          "The ontology has less actual imports then declared.\nActual: "
+          "the ontology has less actual imports then declared.\nActual: "
               + directImports
               + "\n Declared: "
               + directImportDocuments);
diff --git a/robot-core/src/main/java/org/obolibrary/robot/OntologyHelper.java b/robot-core/src/main/java/org/obolibrary/robot/OntologyHelper.java
index ac206493..cf01c668 100644
--- a/robot-core/src/main/java/org/obolibrary/robot/OntologyHelper.java
+++ b/robot-core/src/main/java/org/obolibrary/robot/OntologyHelper.java
@@ -41,153 +41,131 @@ public class OntologyHelper {
   /** Logger. */
   private static final Logger logger = LoggerFactory.getLogger(OntologyHelper.class);
 
-  /**
-   * Set the ontology IRI and version IRI using strings.
-   *
-   * @param ontology the ontology to change
-   * @param ontologyIRIString the ontology IRI string, or null for no change
-   * @param versionIRIString the version IRI string, or null for no change
-   */
-  public static void setOntologyIRI(
-      OWLOntology ontology, String ontologyIRIString, String versionIRIString) {
-    IRI ontologyIRI = null;
-    if (ontologyIRIString != null) {
-      ontologyIRI = IRI.create(ontologyIRIString);
-    }
+  /** Namespace for general ontology error messages. */
+  private static final String NS = "errors#";
 
-    IRI versionIRI = null;
-    if (versionIRIString != null) {
-      versionIRI = IRI.create(versionIRIString);
-    }
+  /** Error message when an unsupported axiom type is requested. Expects axiom Class. */
+  private static final String axiomTypeError =
+      NS + "AXIOM TYPE ERROR cannot annotate axioms of type: %s";
 
-    setOntologyIRI(ontology, ontologyIRI, versionIRI);
-  }
+  private static final String emptyTermsError =
+      NS + "EMPTY TERMS ERROR ontology does not contain input terms";
 
   /**
-   * Set the ontology IRI and version IRI.
+   * Given an ontology, an axiom, a property IRI, and a value string, add an annotation to this
+   * ontology with that property and value.
    *
-   * @param ontology the ontology to change
-   * @param ontologyIRI the new ontology IRI, or null for no change
-   * @param versionIRI the new version IRI, or null for no change
+   * <p>Note that as axioms are immutable, the axiom is removed and replaced with a new one.
+   *
+   * @param ontology the ontology to modify
+   * @param axiom the axiom to annotate
+   * @param propertyIRI the IRI of the property to add
+   * @param value the IRI or literal value to add
    */
-  public static void setOntologyIRI(OWLOntology ontology, IRI ontologyIRI, IRI versionIRI) {
-    OWLOntologyID currentID = ontology.getOntologyID();
-
-    if (ontologyIRI == null && versionIRI == null) {
-      // don't change anything
-      return;
-    } else if (ontologyIRI == null) {
-      ontologyIRI = currentID.getOntologyIRI().orNull();
-    } else if (versionIRI == null) {
-      versionIRI = currentID.getVersionIRI().orNull();
-    }
-
-    OWLOntologyID newID;
-    if (versionIRI == null) {
-      newID = new OWLOntologyID(ontologyIRI);
-    } else {
-      newID = new OWLOntologyID(ontologyIRI, versionIRI);
-    }
+  public static void addAxiomAnnotation(
+      OWLOntology ontology, OWLAxiom axiom, IRI propertyIRI, OWLAnnotationValue value) {
+    OWLOntologyManager manager = ontology.getOWLOntologyManager();
+    OWLDataFactory df = manager.getOWLDataFactory();
 
-    SetOntologyID setID = new SetOntologyID(ontology, newID);
-    ontology.getOWLOntologyManager().applyChange(setID);
+    OWLAnnotationProperty property = df.getOWLAnnotationProperty(propertyIRI);
+    OWLAnnotation annotation = df.getOWLAnnotation(property, value);
+    addAxiomAnnotation(ontology, axiom, Collections.singleton(annotation));
   }
 
   /**
-   * Given an OWLAnnotationValue, return its value as a string.
+   * Given an ontology, an axiom, and a set of annotations, annotate the axiom with the annotations
+   * in the ontology.
    *
-   * @param value the OWLAnnotationValue to get the string value of
-   * @return the string value
+   * <p>Note that as axioms are immutable, the axiom is removed and replaced with a new one.
+   *
+   * @param ontology the ontology to modify
+   * @param axiom the axiom to annotate
+   * @param annotations the set of annotation to add to the axiom
    */
-  public static String getValue(OWLAnnotationValue value) {
-    String result = null;
-    if (value instanceof OWLLiteral) {
-      result = ((OWLLiteral) value).getLiteral();
+  public static void addAxiomAnnotation(
+      OWLOntology ontology, OWLAxiom axiom, Set<OWLAnnotation> annotations) {
+    OWLOntologyManager manager = ontology.getOWLOntologyManager();
+    OWLDataFactory factory = manager.getOWLDataFactory();
+    OWLAxiom newAxiom;
+    if (axiom instanceof OWLSubClassOfAxiom) {
+      OWLSubClassOfAxiom x = ((OWLSubClassOfAxiom) axiom);
+      newAxiom = factory.getOWLSubClassOfAxiom(x.getSubClass(), x.getSuperClass(), annotations);
+      logger.debug("ANNOTATED: " + newAxiom);
+    } else {
+      // TODO - See https://github.com/ontodev/robot/issues/67
+      throw new UnsupportedOperationException(String.format(axiomTypeError, axiom.getClass()));
     }
-    return result;
+    manager.removeAxiom(ontology, axiom);
+    manager.addAxiom(ontology, newAxiom);
   }
 
   /**
-   * Given an OWLAnnotation, return its value as a string.
+   * Given an ontology, an annotation property IRI, and an annotation value, annotate all axioms in
+   * the ontology with that property and value.
    *
-   * @param annotation the OWLAnnotation to get the string value of
-   * @return the string value
+   * @param ontology the ontology to modify
+   * @param propertyIRI the IRI of the property to add
+   * @param value the IRI or literal value to add
    */
-  public static String getValue(OWLAnnotation annotation) {
-    return getValue(annotation.getValue());
+  public static void addAxiomAnnotations(
+      OWLOntology ontology, IRI propertyIRI, OWLAnnotationValue value) {
+    for (OWLAxiom a : ontology.getAxioms()) {
+      addAxiomAnnotation(ontology, a, propertyIRI, value);
+    }
   }
 
   /**
-   * Given an OWLAnnotationAssertionAxiom, return its value as a string.
+   * Given an ontology, a property IRI, and a value string, add an annotation to this ontology with
+   * that property and value.
    *
-   * @param axiom the OWLAnnotationAssertionAxiom to get the string value of
-   * @return the string value
+   * @param ontology the ontology to modify
+   * @param propertyIRI the IRI of the property to add
+   * @param value the IRI or literal value to add
    */
-  public static String getValue(OWLAnnotationAssertionAxiom axiom) {
-    return getValue(axiom.getValue());
-  }
+  public static void addOntologyAnnotation(
+      OWLOntology ontology, IRI propertyIRI, OWLAnnotationValue value) {
+    OWLOntologyManager manager = ontology.getOWLOntologyManager();
+    OWLDataFactory df = manager.getOWLDataFactory();
 
-  /**
-   * Given a set of OWLAnnotations, return the first string value as determined by natural string
-   * sorting.
-   *
-   * @param annotations a set of OWLAnnotations to get the value of
-   * @return the first string value
-   */
-  public static String getValue(Set<OWLAnnotation> annotations) {
-    Set<String> valueSet = getValues(annotations);
-    List<String> valueList = new ArrayList<String>(valueSet);
-    Collections.sort(valueList);
-    String value = null;
-    if (valueList.size() > 0) {
-      value = valueList.get(0);
-    }
-    return value;
+    OWLAnnotationProperty property = df.getOWLAnnotationProperty(propertyIRI);
+    OWLAnnotation annotation = df.getOWLAnnotation(property, value);
+    addOntologyAnnotation(ontology, annotation);
   }
 
   /**
-   * Given a set of OWLAnnotations, return a set of their value strings.
+   * Annotate the ontology with the annotation.
    *
-   * @param annotations a set of OWLAnnotations to get the value of
-   * @return a set of the value strings
+   * @param ontology the ontology to modify
+   * @param annotation the annotation to add
    */
-  public static Set<String> getValues(Set<OWLAnnotation> annotations) {
-    Set<String> results = new HashSet<String>();
-    for (OWLAnnotation annotation : annotations) {
-      String value = getValue(annotation);
-      if (value != null) {
-        results.add(value);
-      }
-    }
-    return results;
+  public static void addOntologyAnnotation(OWLOntology ontology, OWLAnnotation annotation) {
+    OWLOntologyManager manager = ontology.getOWLOntologyManager();
+    AddOntologyAnnotation addition = new AddOntologyAnnotation(ontology, annotation);
+    manager.applyChange(addition);
   }
 
   /**
-   * Given an annotation value, return its datatype, or null.
+   * Given an ontology and a set of IRIs, filter the set of IRIs to only include those that exist in
+   * the ontology.
    *
-   * @param value the value to check
-   * @return the datatype, or null if the value has none
+   * @param ontology the ontology to check for IRIs
+   * @param IRIs Set of IRIs to filter
+   * @param allowEmpty boolean specifying if an empty set can be returned
+   * @return Set of filtered IRIs
    */
-  public static OWLDatatype getType(OWLAnnotationValue value) {
-    if (value instanceof OWLLiteral) {
-      return ((OWLLiteral) value).getDatatype();
+  public static Set<IRI> filterExistingTerms(
+      OWLOntology ontology, Set<IRI> IRIs, boolean allowEmpty) {
+    for (IRI iri : IRIs) {
+      if (!ontology.containsEntityInSignature(iri)) {
+        logger.warn("Ontology does not contain term {}", iri.toString());
+        IRIs.remove(iri);
+      }
     }
-    return null;
-  }
 
-  /**
-   * Given an annotation value, return the IRI of its datatype, or null.
-   *
-   * @param value the value to check
-   * @return the IRI of the datatype, or null if the value has none
-   */
-  public static IRI getTypeIRI(OWLAnnotationValue value) {
-    OWLDatatype datatype = getType(value);
-    if (datatype == null) {
-      return null;
-    } else {
-      return datatype.getIRI();
+    if (IRIs.isEmpty() && !allowEmpty) {
+      throw new IllegalArgumentException(emptyTermsError);
     }
+    return IRIs;
   }
 
   /**
@@ -260,39 +238,43 @@ public class OntologyHelper {
 
   /**
    * Given an ontology, an optional set of annotation properties, and an optional set of subject,
-   * return a set of annotation values for those subjects and those properties.
+   * return the alphanumerically first annotation value string for those subjects and those
+   * properties.
    *
    * @param ontology the ontology to search (including imports closure)
    * @param property an annotation property
    * @param subject an annotation subject IRIs
-   * @return a filtered set of annotation values
+   * @return the first annotation string
    */
-  public static Set<OWLAnnotationValue> getAnnotationValues(
+  public static String getAnnotationString(
       OWLOntology ontology, OWLAnnotationProperty property, IRI subject) {
     Set<OWLAnnotationProperty> properties = new HashSet<OWLAnnotationProperty>();
     properties.add(property);
     Set<IRI> subjects = new HashSet<IRI>();
     subjects.add(subject);
-    return getAnnotationValues(ontology, properties, subjects);
+    return getAnnotationString(ontology, properties, subjects);
   }
 
   /**
    * Given an ontology, an optional set of annotation properties, and an optional set of subject,
-   * return a set of annotation values for those subjects and those properties.
+   * return the alphanumerically first annotation value string for those subjects and those
+   * properties.
    *
    * @param ontology the ontology to search (including imports closure)
    * @param properties a set of annotation properties, or null if all properties should be included
    * @param subjects a set of annotation subject IRIs, or null if all subjects should be included
-   * @return a filtered set of annotation values
+   * @return the first annotation string
    */
-  public static Set<OWLAnnotationValue> getAnnotationValues(
+  public static String getAnnotationString(
       OWLOntology ontology, Set<OWLAnnotationProperty> properties, Set<IRI> subjects) {
-    Set<OWLAnnotationValue> results = new HashSet<OWLAnnotationValue>();
-    Set<OWLAnnotationAssertionAxiom> axioms = getAnnotationAxioms(ontology, properties, subjects);
-    for (OWLAnnotationAssertionAxiom axiom : axioms) {
-      results.add(axiom.getValue());
+    Set<String> valueSet = getAnnotationStrings(ontology, properties, subjects);
+    List<String> valueList = new ArrayList<String>(valueSet);
+    Collections.sort(valueList);
+    String value = null;
+    if (valueList.size() > 0) {
+      value = valueList.get(0);
     }
-    return results;
+    return value;
   }
 
   /**
@@ -331,100 +313,88 @@ public class OntologyHelper {
     }
     return results;
   }
+
   /**
    * Given an ontology, an optional set of annotation properties, and an optional set of subject,
-   * return the alphanumerically first annotation value string for those subjects and those
-   * properties.
+   * return a set of annotation values for those subjects and those properties.
    *
    * @param ontology the ontology to search (including imports closure)
    * @param property an annotation property
    * @param subject an annotation subject IRIs
-   * @return the first annotation string
+   * @return a filtered set of annotation values
    */
-  public static String getAnnotationString(
+  public static Set<OWLAnnotationValue> getAnnotationValues(
       OWLOntology ontology, OWLAnnotationProperty property, IRI subject) {
     Set<OWLAnnotationProperty> properties = new HashSet<OWLAnnotationProperty>();
     properties.add(property);
     Set<IRI> subjects = new HashSet<IRI>();
     subjects.add(subject);
-    return getAnnotationString(ontology, properties, subjects);
+    return getAnnotationValues(ontology, properties, subjects);
   }
 
   /**
    * Given an ontology, an optional set of annotation properties, and an optional set of subject,
-   * return the alphanumerically first annotation value string for those subjects and those
-   * properties.
+   * return a set of annotation values for those subjects and those properties.
    *
    * @param ontology the ontology to search (including imports closure)
    * @param properties a set of annotation properties, or null if all properties should be included
    * @param subjects a set of annotation subject IRIs, or null if all subjects should be included
-   * @return the first annotation string
+   * @return a filtered set of annotation values
    */
-  public static String getAnnotationString(
+  public static Set<OWLAnnotationValue> getAnnotationValues(
       OWLOntology ontology, Set<OWLAnnotationProperty> properties, Set<IRI> subjects) {
-    Set<String> valueSet = getAnnotationStrings(ontology, properties, subjects);
-    List<String> valueList = new ArrayList<String>(valueSet);
-    Collections.sort(valueList);
-    String value = null;
-    if (valueList.size() > 0) {
-      value = valueList.get(0);
+    Set<OWLAnnotationValue> results = new HashSet<OWLAnnotationValue>();
+    Set<OWLAnnotationAssertionAxiom> axioms = getAnnotationAxioms(ontology, properties, subjects);
+    for (OWLAnnotationAssertionAxiom axiom : axioms) {
+      results.add(axiom.getValue());
     }
-    return value;
+    return results;
   }
 
   /**
-   * Given an ontology, return a map from IRIs to rdfs:labels. Includes labels asserted in for all
-   * imported ontologies. If there are multiple labels, use the alphanumerically first.
+   * Given an ontology and a set of term IRIs, return a set of entities for those IRIs. The input
+   * ontology is not changed.
    *
-   * @param ontology the ontology to use
-   * @return a map from IRIs to label strings
+   * @param ontology the ontology to search
+   * @param iris the IRIs of the entities to find
+   * @return a set of OWLEntities with the given IRIs
    */
-  public static Map<IRI, String> getLabels(OWLOntology ontology) {
-    logger.info("Fetching labels for " + ontology.getOntologyID());
-    Map<IRI, String> results = new HashMap<IRI, String>();
-    OWLOntologyManager manager = ontology.getOWLOntologyManager();
-    OWLAnnotationProperty rdfsLabel = manager.getOWLDataFactory().getRDFSLabel();
-    Set<OWLOntology> ontologies = new HashSet<OWLOntology>();
-    ontologies.add(ontology);
-    ReferencedEntitySetProvider resp = new ReferencedEntitySetProvider(ontologies);
-    logger.info("iterating through entities...");
-    for (OWLEntity entity : resp.getEntities()) {
-      String value = getAnnotationString(ontology, rdfsLabel, entity.getIRI());
-      if (value != null) {
-        results.put(entity.getIRI(), value);
-      }
-    }
-    logger.info("Results: " + results.size());
-    return results;
+  public static Set<OWLEntity> getEntities(OWLOntology ontology, Set<IRI> iris) {
+    Set<OWLEntity> entities = new HashSet<OWLEntity>();
+    if (iris == null) {
+      return entities;
+    }
+    for (IRI iri : iris) {
+      entities.addAll(ontology.getEntitiesInSignature(iri, true));
+    }
+    return entities;
   }
 
   /**
-   * Given an ontology, return a map from rdfs:label to IRIs. Includes labels asserted in for all
-   * imported ontologies. Duplicates overwrite existing with a warning.
+   * Given an ontology, return a set of all the entities in its signature.
    *
-   * @param ontology the ontology to use
-   * @return a map from label strings to IRIs
+   * @param ontology the ontology to search
+   * @return a set of all entities in the ontology
    */
-  public static Map<String, IRI> getLabelIRIs(OWLOntology ontology) {
-    Map<String, IRI> results = new HashMap<String, IRI>();
-    OWLOntologyManager manager = ontology.getOWLOntologyManager();
-    OWLAnnotationProperty rdfsLabel = manager.getOWLDataFactory().getRDFSLabel();
-    Set<OWLAnnotationAssertionAxiom> axioms = getAnnotationAxioms(ontology, rdfsLabel);
-    for (OWLAnnotationAssertionAxiom axiom : axioms) {
-      String value = getValue(axiom);
-      if (value == null) {
-        continue;
-      }
-      OWLAnnotationSubject subject = axiom.getSubject();
-      if (subject == null || !(subject instanceof IRI)) {
-        continue;
-      }
-      if (results.containsKey(value)) {
-        logger.warn("Duplicate rdfs:label \"" + value + "\" for subject " + subject);
-      }
-      results.put(value, (IRI) subject);
+  public static Set<OWLEntity> getEntities(OWLOntology ontology) {
+    Set<OWLOntology> ontologies = new HashSet<OWLOntology>();
+    ontologies.add(ontology);
+    ReferencedEntitySetProvider resp = new ReferencedEntitySetProvider(ontologies);
+    return resp.getEntities();
+  }
+
+  /**
+   * Given an ontology, return a set of IRIs for all the entities in its signature.
+   *
+   * @param ontology the ontology to search
+   * @return a set of IRIs for all entities in the ontology
+   */
+  public static Set<IRI> getIRIs(OWLOntology ontology) {
+    Set<IRI> iris = new HashSet<IRI>();
+    for (OWLEntity entity : getEntities(ontology)) {
+      iris.add(entity.getIRI());
     }
-    return results;
+    return iris;
   }
 
   /**
@@ -459,155 +429,220 @@ public class OntologyHelper {
   }
 
   /**
-   * Given an ontology and a set of term IRIs, return a set of entities for those IRIs. The input
-   * ontology is not changed.
+   * Given an ontology, return a map from rdfs:label to IRIs. Includes labels asserted in for all
+   * imported ontologies. Duplicates overwrite existing with a warning.
    *
-   * @param ontology the ontology to search
-   * @param iris the IRIs of the entities to find
-   * @return a set of OWLEntities with the given IRIs
+   * @param ontology the ontology to use
+   * @return a map from label strings to IRIs
    */
-  public static Set<OWLEntity> getEntities(OWLOntology ontology, Set<IRI> iris) {
-    Set<OWLEntity> entities = new HashSet<OWLEntity>();
-    if (iris == null) {
-      return entities;
-    }
-    for (IRI iri : iris) {
-      entities.addAll(ontology.getEntitiesInSignature(iri, true));
+  public static Map<String, IRI> getLabelIRIs(OWLOntology ontology) {
+    Map<String, IRI> results = new HashMap<String, IRI>();
+    OWLOntologyManager manager = ontology.getOWLOntologyManager();
+    OWLAnnotationProperty rdfsLabel = manager.getOWLDataFactory().getRDFSLabel();
+    Set<OWLAnnotationAssertionAxiom> axioms = getAnnotationAxioms(ontology, rdfsLabel);
+    for (OWLAnnotationAssertionAxiom axiom : axioms) {
+      String value = getValue(axiom);
+      if (value == null) {
+        continue;
+      }
+      OWLAnnotationSubject subject = axiom.getSubject();
+      if (subject == null || !(subject instanceof IRI)) {
+        continue;
+      }
+      if (results.containsKey(value)) {
+        logger.warn("Duplicate rdfs:label \"" + value + "\" for subject " + subject);
+      }
+      results.put(value, (IRI) subject);
     }
-    return entities;
+    return results;
   }
 
   /**
-   * Given an ontology, return a set of all the entities in its signature.
+   * Given an ontology, return a map from IRIs to rdfs:labels. Includes labels asserted in for all
+   * imported ontologies. If there are multiple labels, use the alphanumerically first.
    *
-   * @param ontology the ontology to search
-   * @return a set of all entities in the ontology
+   * @param ontology the ontology to use
+   * @return a map from IRIs to label strings
    */
-  public static Set<OWLEntity> getEntities(OWLOntology ontology) {
+  public static Map<IRI, String> getLabels(OWLOntology ontology) {
+    logger.info("Fetching labels for " + ontology.getOntologyID());
+    Map<IRI, String> results = new HashMap<IRI, String>();
+    OWLOntologyManager manager = ontology.getOWLOntologyManager();
+    OWLAnnotationProperty rdfsLabel = manager.getOWLDataFactory().getRDFSLabel();
     Set<OWLOntology> ontologies = new HashSet<OWLOntology>();
     ontologies.add(ontology);
     ReferencedEntitySetProvider resp = new ReferencedEntitySetProvider(ontologies);
-    return resp.getEntities();
+    logger.info("iterating through entities...");
+    for (OWLEntity entity : resp.getEntities()) {
+      String value = getAnnotationString(ontology, rdfsLabel, entity.getIRI());
+      if (value != null) {
+        results.put(entity.getIRI(), value);
+      }
+    }
+    logger.info("Results: " + results.size());
+    return results;
   }
 
   /**
-   * Given an ontology, return a set of IRIs for all the entities in its signature.
+   * Given an annotation value, return its datatype, or null.
    *
-   * @param ontology the ontology to search
-   * @return a set of IRIs for all entities in the ontology
+   * @param value the value to check
+   * @return the datatype, or null if the value has none
    */
-  public static Set<IRI> getIRIs(OWLOntology ontology) {
-    Set<IRI> iris = new HashSet<IRI>();
-    for (OWLEntity entity : getEntities(ontology)) {
-      iris.add(entity.getIRI());
+  public static OWLDatatype getType(OWLAnnotationValue value) {
+    if (value instanceof OWLLiteral) {
+      return ((OWLLiteral) value).getDatatype();
     }
-    return iris;
+    return null;
   }
 
   /**
-   * Remove all annotations on this ontology. Just annotations on the ontology itself, not
-   * annotations on its classes, etc.
+   * Given an annotation value, return the IRI of its datatype, or null.
    *
-   * @param ontology the ontology to modify
+   * @param value the value to check
+   * @return the IRI of the datatype, or null if the value has none
    */
-  public static void removeOntologyAnnotations(OWLOntology ontology) {
-    OWLOntologyManager manager = ontology.getOWLOntologyManager();
-    for (OWLAnnotation annotation : ontology.getAnnotations()) {
-      RemoveOntologyAnnotation remove = new RemoveOntologyAnnotation(ontology, annotation);
-      manager.applyChange(remove);
+  public static IRI getTypeIRI(OWLAnnotationValue value) {
+    OWLDatatype datatype = getType(value);
+    if (datatype == null) {
+      return null;
+    } else {
+      return datatype.getIRI();
     }
   }
 
   /**
-   * Given an ontology, a property IRI, and a value string, add an annotation to this ontology with
-   * that property and value.
+   * Given an OWLAnnotationValue, return its value as a string.
    *
-   * @param ontology the ontology to modify
-   * @param propertyIRI the IRI of the property to add
-   * @param value the IRI or literal value to add
+   * @param value the OWLAnnotationValue to get the string value of
+   * @return the string value
    */
-  public static void addOntologyAnnotation(
-      OWLOntology ontology, IRI propertyIRI, OWLAnnotationValue value) {
-    OWLOntologyManager manager = ontology.getOWLOntologyManager();
-    OWLDataFactory df = manager.getOWLDataFactory();
-
-    OWLAnnotationProperty property = df.getOWLAnnotationProperty(propertyIRI);
-    OWLAnnotation annotation = df.getOWLAnnotation(property, value);
-    addOntologyAnnotation(ontology, annotation);
+  public static String getValue(OWLAnnotationValue value) {
+    String result = null;
+    if (value instanceof OWLLiteral) {
+      result = ((OWLLiteral) value).getLiteral();
+    }
+    return result;
   }
+
   /**
-   * Annotate the ontology with the annotation.
+   * Given an OWLAnnotation, return its value as a string.
    *
-   * @param ontology the ontology to modify
-   * @param annotation the annotation to add
+   * @param annotation the OWLAnnotation to get the string value of
+   * @return the string value
    */
-  public static void addOntologyAnnotation(OWLOntology ontology, OWLAnnotation annotation) {
-    OWLOntologyManager manager = ontology.getOWLOntologyManager();
-    AddOntologyAnnotation addition = new AddOntologyAnnotation(ontology, annotation);
-    manager.applyChange(addition);
+  public static String getValue(OWLAnnotation annotation) {
+    return getValue(annotation.getValue());
   }
 
   /**
-   * Given an ontology, an axiom, a property IRI, and a value string, add an annotation to this
-   * ontology with that property and value.
-   *
-   * <p>Note that as axioms are immutable, the axiom is removed and replaced with a new one.
+   * Given an OWLAnnotationAssertionAxiom, return its value as a string.
    *
-   * @param ontology the ontology to modify
-   * @param axiom the axiom to annotate
-   * @param propertyIRI the IRI of the property to add
-   * @param value the IRI or literal value to add
+   * @param axiom the OWLAnnotationAssertionAxiom to get the string value of
+   * @return the string value
    */
-  public static void addAxiomAnnotation(
-      OWLOntology ontology, OWLAxiom axiom, IRI propertyIRI, OWLAnnotationValue value) {
-    OWLOntologyManager manager = ontology.getOWLOntologyManager();
-    OWLDataFactory df = manager.getOWLDataFactory();
+  public static String getValue(OWLAnnotationAssertionAxiom axiom) {
+    return getValue(axiom.getValue());
+  }
 
-    OWLAnnotationProperty property = df.getOWLAnnotationProperty(propertyIRI);
-    OWLAnnotation annotation = df.getOWLAnnotation(property, value);
-    addAxiomAnnotation(ontology, axiom, Collections.singleton(annotation));
+  /**
+   * Given a set of OWLAnnotations, return the first string value as determined by natural string
+   * sorting.
+   *
+   * @param annotations a set of OWLAnnotations to get the value of
+   * @return the first string value
+   */
+  public static String getValue(Set<OWLAnnotation> annotations) {
+    Set<String> valueSet = getValues(annotations);
+    List<String> valueList = new ArrayList<String>(valueSet);
+    Collections.sort(valueList);
+    String value = null;
+    if (valueList.size() > 0) {
+      value = valueList.get(0);
+    }
+    return value;
   }
 
   /**
-   * Given an ontology, an axiom, and a set of annotations, annotate the axiom with the annotations
-   * in the ontology.
+   * Given a set of OWLAnnotations, return a set of their value strings.
    *
-   * <p>Note that as axioms are immutable, the axiom is removed and replaced with a new one.
+   * @param annotations a set of OWLAnnotations to get the value of
+   * @return a set of the value strings
+   */
+  public static Set<String> getValues(Set<OWLAnnotation> annotations) {
+    Set<String> results = new HashSet<String>();
+    for (OWLAnnotation annotation : annotations) {
+      String value = getValue(annotation);
+      if (value != null) {
+        results.add(value);
+      }
+    }
+    return results;
+  }
+
+  /**
+   * Remove all annotations on this ontology. Just annotations on the ontology itself, not
+   * annotations on its classes, etc.
    *
    * @param ontology the ontology to modify
-   * @param axiom the axiom to annotate
-   * @param annotations the set of annotation to add to the axiom
    */
-  public static void addAxiomAnnotation(
-      OWLOntology ontology, OWLAxiom axiom, Set<OWLAnnotation> annotations) {
+  public static void removeOntologyAnnotations(OWLOntology ontology) {
     OWLOntologyManager manager = ontology.getOWLOntologyManager();
-    OWLDataFactory factory = manager.getOWLDataFactory();
-    OWLAxiom newAxiom;
-    if (axiom instanceof OWLSubClassOfAxiom) {
-      OWLSubClassOfAxiom x = ((OWLSubClassOfAxiom) axiom);
-      newAxiom = factory.getOWLSubClassOfAxiom(x.getSubClass(), x.getSuperClass(), annotations);
-      logger.debug("ANNOTATED: " + newAxiom);
-    } else {
-      // TODO - See https://github.com/ontodev/robot/issues/67
-      throw new UnsupportedOperationException(
-          "Cannot annotate axioms of type: " + axiom.getClass());
+    for (OWLAnnotation annotation : ontology.getAnnotations()) {
+      RemoveOntologyAnnotation remove = new RemoveOntologyAnnotation(ontology, annotation);
+      manager.applyChange(remove);
     }
-    manager.removeAxiom(ontology, axiom);
-    manager.addAxiom(ontology, newAxiom);
   }
 
   /**
-   * Given an ontology, an annotation property IRI, and an annotation value, annotate all axioms in
-   * the ontology with that property and value.
+   * Set the ontology IRI and version IRI using strings.
    *
-   * @param ontology the ontology to modify
-   * @param propertyIRI the IRI of the property to add
-   * @param value the IRI or literal value to add
+   * @param ontology the ontology to change
+   * @param ontologyIRIString the ontology IRI string, or null for no change
+   * @param versionIRIString the version IRI string, or null for no change
    */
-  public static void addAxiomAnnotations(
-      OWLOntology ontology, IRI propertyIRI, OWLAnnotationValue value) {
-    for (OWLAxiom a : ontology.getAxioms()) {
-      addAxiomAnnotation(ontology, a, propertyIRI, value);
+  public static void setOntologyIRI(
+      OWLOntology ontology, String ontologyIRIString, String versionIRIString) {
+    IRI ontologyIRI = null;
+    if (ontologyIRIString != null) {
+      ontologyIRI = IRI.create(ontologyIRIString);
+    }
+
+    IRI versionIRI = null;
+    if (versionIRIString != null) {
+      versionIRI = IRI.create(versionIRIString);
     }
+
+    setOntologyIRI(ontology, ontologyIRI, versionIRI);
+  }
+
+  /**
+   * Set the ontology IRI and version IRI.
+   *
+   * @param ontology the ontology to change
+   * @param ontologyIRI the new ontology IRI, or null for no change
+   * @param versionIRI the new version IRI, or null for no change
+   */
+  public static void setOntologyIRI(OWLOntology ontology, IRI ontologyIRI, IRI versionIRI) {
+    OWLOntologyID currentID = ontology.getOntologyID();
+
+    if (ontologyIRI == null && versionIRI == null) {
+      // don't change anything
+      return;
+    } else if (ontologyIRI == null) {
+      ontologyIRI = currentID.getOntologyIRI().orNull();
+    } else if (versionIRI == null) {
+      versionIRI = currentID.getVersionIRI().orNull();
+    }
+
+    OWLOntologyID newID;
+    if (versionIRI == null) {
+      newID = new OWLOntologyID(ontologyIRI);
+    } else {
+      newID = new OWLOntologyID(ontologyIRI, versionIRI);
+    }
+
+    SetOntologyID setID = new SetOntologyID(ontology, newID);
+    ontology.getOWLOntologyManager().applyChange(setID);
   }
 }
diff --git a/robot-core/src/main/java/org/obolibrary/robot/QueryOperation.java b/robot-core/src/main/java/org/obolibrary/robot/QueryOperation.java
index d7cbfc30..a2550af8 100644
--- a/robot-core/src/main/java/org/obolibrary/robot/QueryOperation.java
+++ b/robot-core/src/main/java/org/obolibrary/robot/QueryOperation.java
@@ -25,6 +25,12 @@ public class QueryOperation {
   /** Logger. */
   private static final Logger logger = LoggerFactory.getLogger(QueryOperation.class);
 
+  /** Namespace for error messages. */
+  private static final String NS = "query#";
+
+  /** Error message when query type is illegal. Expects: query type. */
+  private static final String queryTypeError = NS + "QUERY TYPE ERROR unknown query type: %s";
+
   /**
    * Load an ontology into a DatasetGraph. The ontology is not changed. NOTE: This is not elegant!
    * It basically pipes Turtle output from OWLAPI to Jena Arq.
@@ -59,6 +65,7 @@ public class QueryOperation {
    * @throws IllegalArgumentException on bad query
    */
   public static String getQueryTypeName(String queryString) throws IllegalArgumentException {
+    // TODO: This method is unused
     QueryExecution qexec = QueryExecutionFactory.create(queryString, createEmptyDataset());
     Query query = qexec.getQuery();
     String queryType = null;
@@ -76,7 +83,7 @@ public class QueryOperation {
         queryType = "SELECT";
         break;
       default:
-        throw new IllegalArgumentException("Unsupported SPARQL query type");
+        throw new IllegalArgumentException(String.format(queryTypeError, query.getQueryType()));
     }
     return queryType;
   }
@@ -115,7 +122,7 @@ public class QueryOperation {
         formatName = "csv";
         break;
       default:
-        throw new IllegalArgumentException("Unsupported SPARQL query type");
+        throw new IllegalArgumentException(String.format(queryTypeError, query.getQueryType()));
     }
     return formatName;
   }
@@ -193,7 +200,7 @@ public class QueryOperation {
         result = maybeWriteResult(qexec.execSelect(), formatName, output);
         break;
       default:
-        throw new IllegalArgumentException("Unsupported SPARQL query type");
+        throw new IllegalArgumentException(String.format(queryTypeError, query.getQueryType()));
     }
 
     return result;
diff --git a/robot-core/src/main/java/org/obolibrary/robot/TemplateHelper.java b/robot-core/src/main/java/org/obolibrary/robot/TemplateHelper.java
new file mode 100644
index 00000000..fa8dda2c
--- /dev/null
+++ b/robot-core/src/main/java/org/obolibrary/robot/TemplateHelper.java
@@ -0,0 +1,551 @@
+package org.obolibrary.robot;
+
+import com.google.common.collect.Sets;
+import com.opencsv.CSVReader;
+import java.io.File;
+import java.io.FileReader;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.io.Reader;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Map.Entry;
+import java.util.Set;
+import org.apache.commons.io.FilenameUtils;
+import org.semanticweb.owlapi.model.IRI;
+import org.semanticweb.owlapi.model.OWLAnnotation;
+import org.semanticweb.owlapi.model.OWLAnnotationAssertionAxiom;
+import org.semanticweb.owlapi.model.OWLAnnotationProperty;
+import org.semanticweb.owlapi.model.OWLAnnotationValue;
+import org.semanticweb.owlapi.model.OWLAxiom;
+import org.semanticweb.owlapi.model.OWLClass;
+import org.semanticweb.owlapi.model.OWLClassExpression;
+import org.semanticweb.owlapi.model.OWLDataFactory;
+import org.semanticweb.owlapi.model.OWLDatatype;
+import org.semanticweb.owlapi.model.OWLEntity;
+import org.semanticweb.owlapi.model.OWLObjectIntersectionOf;
+import uk.ac.manchester.cs.owl.owlapi.OWLDataFactoryImpl;
+
+/** Convenience methods for working with templates. */
+public class TemplateHelper {
+
+  /** Shared DataFactory. */
+  private static OWLDataFactory dataFactory = new OWLDataFactoryImpl();
+
+  /** Namespace for error messages. */
+  private static final String NS = "template#";
+
+  /**
+   * Error message when annotation property cannot be resolved. Expects: annotation property name.
+   */
+  private static final String annotationPropertyError =
+      NS + "ANNOTATION PROPERTY ERROR could not handle annotation property: %s";
+
+  /** Error message when datatype cannot be resolved. Expects: datatype name. */
+  private static final String datatypeError = NS + "DATATYPE ERROR could not find datatype: %s";
+
+  /** Error message when template file type is not CSV, TSV, or TAB. Expects: file name. */
+  private static final String fileTypeError = NS + "FILE TYPE ERROR unrecognized file type for: %s";
+
+  /** Error message when the template does not have an ID column. Expects: table name. */
+  private static final String idError = NS + "ID ERROR an \"ID\" column is required in table %s";
+
+  /** Error message when the IRI in an IRI annotation cannot be resolved. Expects: value. */
+  private static final String iriError = NS + "IRI ERROR could not create IRI annotation: %s";
+
+  /**
+   * Error message when a language annotation string does not include "@{lang}". Expects: template
+   * string
+   */
+  private static final String languageFormatError =
+      NS + "LANGUAGE FORMAT ERROR invalid language annotation template string: %s";
+
+  /** Error message when the template file does not exist. Expects: file name. */
+  private static final String templateFileError =
+      NS + "TEMPLATE FILE ERROR template %s does not exist";
+
+  /**
+   * Error message when a language annotation string does not include "^^{type}". Expects: template
+   * string
+   */
+  private static final String typedFormatError =
+      NS + "TYPED FORMAT ERROR invalid typed annotation string: %s";
+
+  /** OWL Namespace. */
+  private static String owl = "http://www.w3.org/2002/07/owl#";
+
+  /**
+   * Create an OWLAnnotation based on the template string and cell value.
+   *
+   * @param checker used to resolve the annotation property
+   * @param ioHelper IOHelper used to create IRIs from values
+   * @param template the template string
+   * @param value the value for the annotation
+   * @return OWLAnnotation, or null if template string is not supported
+   * @throws Exception if annotation property cannot be found
+   */
+  public static OWLAnnotation getAnnotation(
+      QuotedEntityChecker checker, IOHelper ioHelper, String template, String value)
+      throws Exception {
+    if (template.startsWith("A ")) {
+      return getStringAnnotation(checker, template, value);
+    } else if (template.startsWith("AT ")) {
+      if (template.indexOf("^^") > -1) {
+        return getTypedAnnotation(checker, template, value);
+      } else {
+        throw new Exception(String.format(typedFormatError, template));
+      }
+    } else if (template.startsWith("AL ")) {
+      if (template.indexOf("@") > -1) {
+        return getLanguageAnnotation(checker, template, value);
+      } else {
+        throw new Exception(String.format(languageFormatError, template));
+      }
+    } else if (template.startsWith("AI ")) {
+      IRI iri = ioHelper.createIRI(value);
+      if (iri != null) {
+        return getIRIAnnotation(checker, template, iri);
+      } else {
+        throw new Exception(String.format(iriError, value));
+      }
+    } else {
+      return null;
+    }
+  }
+
+  /**
+   * Get a set of annotation axioms for an OWLEntity. Supports axiom annotations and axiom
+   * annotation annotations.
+   *
+   * @param entity OWLEntity to annotation
+   * @param annotations Set of OWLAnnotations
+   * @param nested Map with top-level OWLAnnotation as key and another map (axiom OWLAnnotation, set
+   *     of axiom annotation OWLAnnotations) as value
+   * @return Set of OWLAnnotationAssertionAxioms
+   */
+  public static Set<OWLAnnotationAssertionAxiom> getAnnotationAxioms(
+      OWLEntity entity,
+      Set<OWLAnnotation> annotations,
+      Map<OWLAnnotation, Map<OWLAnnotation, Set<OWLAnnotation>>> nested) {
+    Set<OWLAnnotationAssertionAxiom> axioms = new HashSet<>();
+    // Create basic annotations
+    for (OWLAnnotation annotation : annotations) {
+      axioms.add(dataFactory.getOWLAnnotationAssertionAxiom(entity.getIRI(), annotation));
+    }
+    // Create annotations with axiom annotations
+    for (Entry<OWLAnnotation, Map<OWLAnnotation, Set<OWLAnnotation>>> annotationPlusAxioms :
+        nested.entrySet()) {
+      OWLAnnotation annotation = annotationPlusAxioms.getKey();
+      Set<OWLAnnotation> axiomAnnotations = new HashSet<>();
+      // For each annotation with its axiom annotations ...
+      for (Entry<OWLAnnotation, Set<OWLAnnotation>> nestedSet :
+          annotationPlusAxioms.getValue().entrySet()) {
+        OWLAnnotation axiomAnnotation = nestedSet.getKey();
+        // Check if there are annotations on the axiom annotations, and add those
+        Set<OWLAnnotation> axiomAnnotationAnnotations = nestedSet.getValue();
+        if (axiomAnnotationAnnotations.isEmpty()) {
+          axiomAnnotations.add(axiomAnnotation);
+        } else {
+          OWLAnnotationProperty property = axiomAnnotation.getProperty();
+          OWLAnnotationValue value = axiomAnnotation.getValue();
+          axiomAnnotations.add(
+              dataFactory.getOWLAnnotation(property, value, axiomAnnotationAnnotations));
+        }
+      }
+      axioms.add(
+          dataFactory.getOWLAnnotationAssertionAxiom(
+              entity.getIRI(), annotation, axiomAnnotations));
+    }
+    return axioms;
+  }
+
+  /**
+   * Get a set of OWLAxioms (subclass or equivalent) for an OWLClass. Supports axiom annotations.
+   *
+   * @param cls OWLClass to add axioms to
+   * @param classType subclass or equivalent
+   * @param classExpressions Set of OWLClassExpressions
+   * @param annotatedExpressions Map of annotated OWLClassExpressions and the Set of OWLAnnotations
+   * @return Set of OWLAxioms, or null if classType is not subclass or equivalent
+   * @throws Exception if classType is not subclass or equivalent
+   */
+  public static Set<OWLAxiom> getLogicalAxioms(
+      OWLClass cls,
+      String classType,
+      Set<OWLClassExpression> classExpressions,
+      Map<OWLClassExpression, Set<OWLAnnotation>> annotatedExpressions)
+      throws Exception {
+    Set<OWLAxiom> axioms = new HashSet<>();
+    if (classType.equals("subclass")) {
+      for (OWLClassExpression expression : classExpressions) {
+        axioms.add(dataFactory.getOWLSubClassOfAxiom(cls, expression));
+      }
+      for (Entry<OWLClassExpression, Set<OWLAnnotation>> annotatedEx :
+          annotatedExpressions.entrySet()) {
+        axioms.add(
+            dataFactory.getOWLSubClassOfAxiom(cls, annotatedEx.getKey(), annotatedEx.getValue()));
+      }
+      return axioms;
+    } else if (classType.equals("equivalent")) {
+      // Since it's an intersection, all annotations will be added to the same axiom
+      Set<OWLAnnotation> annotations = new HashSet<>();
+      for (Entry<OWLClassExpression, Set<OWLAnnotation>> annotatedEx :
+          annotatedExpressions.entrySet()) {
+        classExpressions.add(annotatedEx.getKey());
+        annotations.addAll(annotatedEx.getValue());
+      }
+      OWLObjectIntersectionOf intersection =
+          dataFactory.getOWLObjectIntersectionOf(classExpressions);
+      OWLAxiom axiom;
+      if (!annotations.isEmpty()) {
+        axiom = dataFactory.getOWLEquivalentClassesAxiom(cls, intersection, annotations);
+      } else {
+        axiom = dataFactory.getOWLEquivalentClassesAxiom(cls, intersection);
+      }
+      return Sets.newHashSet(axiom);
+    } else {
+      return null;
+    }
+  }
+
+  /**
+   * Find an annotation property with the given name or create one.
+   *
+   * @param checker used to search by rdfs:label (for example)
+   * @param name the name to search for
+   * @return an annotation property
+   * @throws Exception if the name cannot be resolved
+   */
+  public static OWLAnnotationProperty getAnnotationProperty(
+      QuotedEntityChecker checker, String name) throws Exception {
+    OWLAnnotationProperty property = checker.getOWLAnnotationProperty(name, true);
+    if (property != null) {
+      return property;
+    }
+    throw new Exception(String.format(annotationPropertyError, name));
+  }
+
+  /**
+   * Find a datatype with the given name or create one.
+   *
+   * @param checker used to search by rdfs:label (for example)
+   * @param name the name to search for
+   * @return a datatype
+   * @throws Exception if the name cannot be resolved
+   */
+  public static OWLDatatype getDatatype(QuotedEntityChecker checker, String name) throws Exception {
+    OWLDatatype datatype = checker.getOWLDatatype(name, true);
+    if (datatype != null) {
+      return datatype;
+    }
+    throw new Exception(String.format(datatypeError, name));
+  }
+
+  /**
+   * Return a string annotation for the given template string and value.
+   *
+   * @param checker used to resolve the annotation property
+   * @param template the template string
+   * @param value the value for the annotation
+   * @return a new annotation with property and string literal value
+   * @throws Exception if the annotation property cannot be found
+   */
+  public static OWLAnnotation getStringAnnotation(
+      QuotedEntityChecker checker, String template, String value) throws Exception {
+    String name = template.substring(1).trim();
+    OWLAnnotationProperty property = getAnnotationProperty(checker, name);
+    return dataFactory.getOWLAnnotation(property, dataFactory.getOWLLiteral(value));
+  }
+
+  /**
+   * Return a typed annotation for the given template string and value. The template string format
+   * is "AT [name]^^[datatype]" and the value is any string.
+   *
+   * @param checker used to resolve the annotation property and datatype
+   * @param template the template string
+   * @param value the value for the annotation
+   * @return a new annotation axiom with property and typed literal value
+   * @throws Exception if the annotation property cannot be found
+   */
+  public static OWLAnnotation getTypedAnnotation(
+      QuotedEntityChecker checker, String template, String value) throws Exception {
+    template = template.substring(2).trim();
+    String name = template.substring(0, template.indexOf("^^")).trim();
+    String typeName = template.substring(template.indexOf("^^") + 2, template.length()).trim();
+    OWLAnnotationProperty property = getAnnotationProperty(checker, name);
+    OWLDatatype datatype = getDatatype(checker, typeName);
+    return dataFactory.getOWLAnnotation(property, dataFactory.getOWLLiteral(value, datatype));
+  }
+
+  /**
+   * Return a language tagged annotation for the given template and value. The template string
+   * format is "AL [name]@[lang]" and the value is any string.
+   *
+   * @param checker used to resolve the annotation property
+   * @param template the template string
+   * @param value the value for the annotation
+   * @return a new annotation axiom with property and language tagged literal
+   * @throws Exception if the annotation property cannot be found
+   */
+  public static OWLAnnotation getLanguageAnnotation(
+      QuotedEntityChecker checker, String template, String value) throws Exception {
+    template = template.substring(2).trim();
+    String name = template.substring(0, template.indexOf("@")).trim();
+    String lang = template.substring(template.indexOf("@") + 1, template.length()).trim();
+    OWLAnnotationProperty property = getAnnotationProperty(checker, name);
+    return dataFactory.getOWLAnnotation(property, dataFactory.getOWLLiteral(value, lang));
+  }
+
+  /**
+   * Return an IRI annotation for the given template string and value. The template string format is
+   * "AI [name]" and the value is a string that can be interpreted as an IRI.
+   *
+   * @param checker used to resolve the annotation property
+   * @param template the template string
+   * @param value the IRI value for the annotation
+   * @return a new annotation axiom with property and an IRI value
+   * @throws Exception if the annotation property cannot be found
+   */
+  public static OWLAnnotation getIRIAnnotation(
+      QuotedEntityChecker checker, String template, IRI value) throws Exception {
+    String name = template.substring(2).trim();
+    OWLAnnotationProperty property = getAnnotationProperty(checker, name);
+    return dataFactory.getOWLAnnotation(property, value);
+  }
+
+  /**
+   * Use type, id, and label information to get an entity from the data in a row. Requires either:
+   * an id (default type is owl:Class); an id and type; or a label.
+   *
+   * @param checker for looking up labels
+   * @param type the IRI of the type for this entity, or null
+   * @param id the ID for this entity, or null
+   * @param label the label for this entity, or null
+   * @return the entity
+   * @throws Exception if the entity cannot be created
+   */
+  public static OWLEntity getEntity(QuotedEntityChecker checker, IRI type, String id, String label)
+      throws Exception {
+
+    IOHelper ioHelper = checker.getIOHelper();
+
+    if (id != null && ioHelper != null) {
+      IRI iri = ioHelper.createIRI(id);
+      if (type == null) {
+        type = IRI.create(owl + "Class");
+      }
+      String t = type.toString();
+      if (t.equals(owl + "Class")) {
+        return dataFactory.getOWLClass(iri);
+      } else if (t.equals(owl + "AnnotationProperty")) {
+        return dataFactory.getOWLAnnotationProperty(iri);
+      } else if (t.equals(owl + "ObjectProperty")) {
+        return dataFactory.getOWLObjectProperty(iri);
+      } else if (t.equals(owl + "DatatypeProperty")) {
+        return dataFactory.getOWLDataProperty(iri);
+      } else if (t.equals(owl + "Datatype")) {
+        return dataFactory.getOWLDatatype(iri);
+      } else {
+        return dataFactory.getOWLNamedIndividual(iri);
+      }
+    }
+
+    if (label != null && type != null) {
+      String t = type.toString();
+      if (t.equals(owl + "Class")) {
+        return checker.getOWLClass(label);
+      } else if (t.equals(owl + "AnnotationProperty")) {
+        return checker.getOWLAnnotationProperty(label);
+      } else if (t.equals(owl + "ObjectProperty")) {
+        return checker.getOWLObjectProperty(label);
+      } else if (t.equals(owl + "DatatypeProperty")) {
+        return checker.getOWLDataProperty(label);
+      } else if (t.equals(owl + "Datatype")) {
+        return checker.getOWLDatatype(label);
+      } else {
+        return checker.getOWLIndividual(label);
+      }
+    }
+
+    if (label != null) {
+      return checker.getOWLEntity(label);
+    }
+
+    return null;
+  }
+
+  /**
+   * Get a list of the IRIs defined in a set of template tables.
+   *
+   * @param tables a map from table names to tables
+   * @param ioHelper used to find entities by name
+   * @return a list of IRIs
+   * @throws Exception when names or templates cannot be handled
+   */
+  public static List<IRI> getIRIs(Map<String, List<List<String>>> tables, IOHelper ioHelper)
+      throws Exception {
+    List<IRI> iris = new ArrayList<IRI>();
+    for (Map.Entry<String, List<List<String>>> table : tables.entrySet()) {
+      String tableName = table.getKey();
+      List<List<String>> rows = table.getValue();
+      iris.addAll(getIRIs(tableName, rows, ioHelper));
+    }
+    return iris;
+  }
+
+  /**
+   * Get a list of the IRIs defined in a template table.
+   *
+   * @param tableName the name of the table
+   * @param rows the table of data
+   * @param ioHelper used to find entities by name
+   * @return a list of IRIs
+   * @throws Exception when names or templates cannot be handled
+   */
+  public static List<IRI> getIRIs(String tableName, List<List<String>> rows, IOHelper ioHelper)
+      throws Exception {
+    // Find the ID column.
+    List<String> templates = rows.get(1);
+    int idColumn = -1;
+    for (int column = 0; column < templates.size(); column++) {
+      String template = templates.get(column);
+      if (template == null) {
+        continue;
+      }
+      template = template.trim();
+      if (template.equals("ID")) {
+        idColumn = column;
+      }
+    }
+    if (idColumn == -1) {
+      throw new Exception(String.format(idError, tableName));
+    }
+
+    List<IRI> iris = new ArrayList<IRI>();
+    for (int row = 2; row < rows.size(); row++) {
+      String id = null;
+      try {
+        id = rows.get(row).get(idColumn);
+      } catch (IndexOutOfBoundsException e) {
+        continue;
+      }
+      if (id == null || id.trim().isEmpty()) {
+        continue;
+      }
+      IRI iri = ioHelper.createIRI(id);
+      if (iri == null) {
+        continue;
+      }
+      iris.add(iri);
+    }
+
+    return iris;
+  }
+
+  /**
+   * Read comma-separated values from a path to a list of lists of strings.
+   *
+   * @param path file path to the CSV file
+   * @return a list of lists of strings
+   * @throws IOException on file or reading problems
+   */
+  public static List<List<String>> readCSV(String path) throws IOException {
+    return readCSV(new FileReader(path));
+  }
+
+  /**
+   * Read comma-separated values from a stream to a list of lists of strings.
+   *
+   * @param stream the stream to read from
+   * @return a list of lists of strings
+   * @throws IOException on file or reading problems
+   */
+  public static List<List<String>> readCSV(InputStream stream) throws IOException {
+    return readCSV(new InputStreamReader(stream));
+  }
+
+  /**
+   * Read comma-separated values from a reader to a list of lists of strings.
+   *
+   * @param reader a reader to read data from
+   * @return a list of lists of strings
+   * @throws IOException on file or reading problems
+   */
+  public static List<List<String>> readCSV(Reader reader) throws IOException {
+    CSVReader csv = new CSVReader(reader);
+    List<List<String>> rows = new ArrayList<List<String>>();
+    String[] nextLine;
+    while ((nextLine = csv.readNext()) != null) {
+      rows.add(new ArrayList<String>(Arrays.asList(nextLine)));
+    }
+    csv.close();
+    return rows;
+  }
+
+  /**
+   * Read a table from a path to a list of lists of strings.
+   *
+   * @param path file path to the CSV file
+   * @return a list of lists of strings
+   * @throws IOException on file or reading problems
+   */
+  public static List<List<String>> readTable(String path) throws IOException {
+    File file = new File(path);
+    if (!file.exists()) {
+      throw new IllegalArgumentException(String.format(templateFileError, file.getName()));
+    }
+    String extension = FilenameUtils.getExtension(file.getName());
+    extension = extension.trim().toLowerCase();
+    if (extension.equals("csv")) {
+      return readCSV(new FileReader(path));
+    } else if (extension.equals("tsv") || extension.equals("tab")) {
+      return readTSV(new FileReader(path));
+    } else {
+      throw new IOException(String.format(fileTypeError, path));
+    }
+  }
+
+  /**
+   * Read tab-separated values from a path to a list of lists of strings.
+   *
+   * @param path file path to the CSV file
+   * @return a list of lists of strings
+   * @throws IOException on file or reading problems
+   */
+  public static List<List<String>> readTSV(String path) throws IOException {
+    return readTSV(new FileReader(path));
+  }
+
+  /**
+   * Read tab-separated values from a stream to a list of lists of strings.
+   *
+   * @param stream the stream to read from
+   * @return a list of lists of strings
+   * @throws IOException on file or reading problems
+   */
+  public static List<List<String>> readTSV(InputStream stream) throws IOException {
+    return readTSV(new InputStreamReader(stream));
+  }
+
+  /**
+   * Read tab-separated values from a reader to a list of lists of strings.
+   *
+   * @param reader a reader to read data from
+   * @return a list of lists of strings
+   * @throws IOException on file or reading problems
+   */
+  public static List<List<String>> readTSV(Reader reader) throws IOException {
+    CSVReader csv = new CSVReader(reader, '\t');
+    List<List<String>> rows = new ArrayList<List<String>>();
+    String[] nextLine;
+    while ((nextLine = csv.readNext()) != null) {
+      rows.add(new ArrayList<String>(Arrays.asList(nextLine)));
+    }
+    csv.close();
+    return rows;
+  }
+}
diff --git a/robot-core/src/main/java/org/obolibrary/robot/TemplateOperation.java b/robot-core/src/main/java/org/obolibrary/robot/TemplateOperation.java
index 942b2fa6..f14c11f5 100644
--- a/robot-core/src/main/java/org/obolibrary/robot/TemplateOperation.java
+++ b/robot-core/src/main/java/org/obolibrary/robot/TemplateOperation.java
@@ -7,7 +7,6 @@ import java.util.HashMap;
 import java.util.HashSet;
 import java.util.List;
 import java.util.Map;
-import java.util.Map.Entry;
 import java.util.Set;
 import java.util.regex.Matcher;
 import java.util.regex.Pattern;
@@ -18,14 +17,11 @@ import org.semanticweb.owlapi.model.IRI;
 import org.semanticweb.owlapi.model.OWLAnnotation;
 import org.semanticweb.owlapi.model.OWLAnnotationAssertionAxiom;
 import org.semanticweb.owlapi.model.OWLAnnotationProperty;
-import org.semanticweb.owlapi.model.OWLAnnotationValue;
 import org.semanticweb.owlapi.model.OWLAxiom;
 import org.semanticweb.owlapi.model.OWLClass;
 import org.semanticweb.owlapi.model.OWLClassExpression;
 import org.semanticweb.owlapi.model.OWLDataFactory;
-import org.semanticweb.owlapi.model.OWLDatatype;
 import org.semanticweb.owlapi.model.OWLEntity;
-import org.semanticweb.owlapi.model.OWLObjectIntersectionOf;
 import org.semanticweb.owlapi.model.OWLOntology;
 import org.semanticweb.owlapi.model.OWLOntologyManager;
 import org.semanticweb.owlapi.util.SimpleShortFormProvider;
@@ -44,22 +40,23 @@ public class TemplateOperation {
   /** Logger. */
   private static final Logger logger = LoggerFactory.getLogger(TemplateOperation.class);
 
-  /** OWL Namespace. */
-  private static String owl = "http://www.w3.org/2002/07/owl#";
-
   /** Shared OWLOntologyManager. */
   private static OWLOntologyManager outputManager = OWLManager.createOWLOntologyManager();
 
   /** Shared DataFactory. */
   private static OWLDataFactory dataFactory = new OWLDataFactoryImpl();
 
+  /** Namespace for error messages. */
+  private static final String NS = "template#";
+
   /**
    * Error message when an axiom annotation does not have the appropriate annotation or class
    * expression to the left. Expects: table name, row number, row id, column number, column name,
    * cell value, axiom type.
    */
   private static String axiomAnnotationError =
-      "Axiom annotation \"%6$s\" at row %2$d (\"%3$s\"), "
+      NS
+          + "AXIOM ANNOTATION ERROR \"%6$s\" at row %2$d (\"%3$s\"), "
           + "column %4$d (\"%5$s\") in table \"%1$s\" "
           + "requires %7$s in the previous column.";
 
@@ -68,17 +65,27 @@ public class TemplateOperation {
    * Expects: table name, header count, template count.
    */
   private static String columnMismatchError =
-      "The number of header columns (%2$d) must match "
+      NS
+          + "COLUMN MISMATCH ERROR the number of header columns (%2$d) must match "
           + "the number of template columns (%3$d) "
           + "in table \"%1$s\".";
 
   /**
-   * Error message when a template cannot be understood. Expects: table name, column number, column
-   * name, template.
+   * Error message when a row for a class does not have a type. Should be "subclass" or
+   * "equivalent". Expects: table name, row number, row id.
    */
-  private static String unknownTemplateError =
-      "Could not interpret template string \"%4$s\" "
-          + "for column %2$d (\"%3$s\")."
+  private static String missingTypeError =
+      NS + "MISSING TYPE ERROR no class type found for row %2$d (\"%3$s\") in table \"%1$s\".";
+
+  /**
+   * Error message when we cannot create an IRI for a row ID. Expects: table name, row number, row
+   * id.
+   */
+  private static String nullIDError =
+      NS
+          + "NULL ID ERROR could not create IRI for ID \"%3$s\" "
+          + "with label \"%4$s\" "
+          + "at row %2$d "
           + "in table \"%1$s\".";
 
   /**
@@ -86,140 +93,29 @@ public class TemplateOperation {
    * number, column name, content, message.
    */
   private static String parseError =
-      "Error while parsing \"%6$s\" "
+      NS
+          + "PARSE ERROR error while parsing \"%6$s\" "
           + "at row %2$d (\"%3$s\"), "
           + "column %4$d (\"%5$s\") "
           + "in table \"%1$s\": "
           + "%7$s";
 
   /**
-   * Error message when we cannot create an IRI for a row ID. Expects: table name, row number, row
-   * id.
+   * Error message when a template cannot be understood. Expects: table name, column number, column
+   * name, template.
    */
-  private static String nullIDError =
-      "Could not create IRI for ID \"%3$s\" "
-          + "with label \"%4$s\" "
-          + "at row %2$d "
+  private static String unknownTemplateError =
+      NS
+          + "UNKNOWN TEMPLATE ERROR could not interpret template string \"%4$s\" "
+          + "for column %2$d (\"%3$s\") "
           + "in table \"%1$s\".";
 
-  /**
-   * Error message when a row for a class does not have a type. Should be "subclass" or
-   * "equivalent". Expects: table name, row number, row id.
-   */
-  private static String missingTypeError =
-      "No class type found for row %2$d (\"%3$s\") " + "in table \"%1$s\".";
-
   /**
    * Error message when a class type is not recognized. Should be "subclass" or "equivalent".
    * Expects: table name, row number, row id, value.
    */
   private static String unknownTypeError =
-      "Unknown class type \"%4$s\" " + "for row %2$d (\"%3$s\") " + "in table \"%1$s\".";
-
-  /**
-   * Find an annotation property with the given name or create one.
-   *
-   * @param checker used to search by rdfs:label (for example)
-   * @param name the name to search for
-   * @return an annotation property
-   * @throws Exception if the name cannot be resolved
-   */
-  public static OWLAnnotationProperty getAnnotationProperty(
-      QuotedEntityChecker checker, String name) throws Exception {
-    OWLAnnotationProperty property = checker.getOWLAnnotationProperty(name, true);
-    if (property != null) {
-      return property;
-    }
-    throw new Exception("Could not find annotation property: " + name);
-  }
-
-  /**
-   * Find a datatype with the given name or create one.
-   *
-   * @param checker used to search by rdfs:label (for example)
-   * @param name the name to search for
-   * @return a datatype
-   * @throws Exception if the name cannot be resolved
-   */
-  public static OWLDatatype getDatatype(QuotedEntityChecker checker, String name) throws Exception {
-    OWLDatatype datatype = checker.getOWLDatatype(name, true);
-    if (datatype != null) {
-      return datatype;
-    }
-    throw new Exception("Could not find datatype: " + name);
-  }
-
-  /**
-   * Return a string annotation for the given template string and value.
-   *
-   * @param checker used to resolve the annotation property
-   * @param template the template string
-   * @param value the value for the annotation
-   * @return a new annotation with property and string literal value
-   * @throws Exception if the annotation property cannot be found
-   */
-  public static OWLAnnotation getStringAnnotation(
-      QuotedEntityChecker checker, String template, String value) throws Exception {
-    String name = template.substring(1).trim();
-    OWLAnnotationProperty property = getAnnotationProperty(checker, name);
-    return dataFactory.getOWLAnnotation(property, dataFactory.getOWLLiteral(value));
-  }
-
-  /**
-   * Return a typed annotation for the given template string and value. The template string format
-   * is "AT [name]^^[datatype]" and the value is any string.
-   *
-   * @param checker used to resolve the annotation property and datatype
-   * @param template the template string
-   * @param value the value for the annotation
-   * @return a new annotation axiom with property and typed literal value
-   * @throws Exception if the annotation property cannot be found
-   */
-  public static OWLAnnotation getTypedAnnotation(
-      QuotedEntityChecker checker, String template, String value) throws Exception {
-    template = template.substring(2).trim();
-    String name = template.substring(0, template.indexOf("^^")).trim();
-    String typeName = template.substring(template.indexOf("^^") + 2, template.length()).trim();
-    OWLAnnotationProperty property = getAnnotationProperty(checker, name);
-    OWLDatatype datatype = getDatatype(checker, typeName);
-    return dataFactory.getOWLAnnotation(property, dataFactory.getOWLLiteral(value, datatype));
-  }
-
-  /**
-   * Return a language tagged annotation for the given template and value. The template string
-   * format is "AL [name]@[lang]" and the value is any string.
-   *
-   * @param checker used to resolve the annotation property
-   * @param template the template string
-   * @param value the value for the annotation
-   * @return a new annotation axiom with property and language tagged literal
-   * @throws Exception if the annotation property cannot be found
-   */
-  public static OWLAnnotation getLanguageAnnotation(
-      QuotedEntityChecker checker, String template, String value) throws Exception {
-    template = template.substring(2).trim();
-    String name = template.substring(0, template.indexOf("@")).trim();
-    String lang = template.substring(template.indexOf("@") + 1, template.length()).trim();
-    OWLAnnotationProperty property = getAnnotationProperty(checker, name);
-    return dataFactory.getOWLAnnotation(property, dataFactory.getOWLLiteral(value, lang));
-  }
-
-  /**
-   * Return an IRI annotation for the given template string and value. The template string format is
-   * "AI [name]" and the value is a string that can be interpreted as an IRI.
-   *
-   * @param checker used to resolve the annotation property
-   * @param template the template string
-   * @param value the IRI value for the annotation
-   * @return a new annotation axiom with property and an IRI value
-   * @throws Exception if the annotation property cannot be found
-   */
-  public static OWLAnnotation getIRIAnnotation(
-      QuotedEntityChecker checker, String template, IRI value) throws Exception {
-    String name = template.substring(2).trim();
-    OWLAnnotationProperty property = getAnnotationProperty(checker, name);
-    return dataFactory.getOWLAnnotation(property, value);
-  }
+      NS + "UNKNOWN TYPE ERROR \"%4$s\" for row %2$d (\"%3$s\") in table \"%1$s\".";
 
   /**
    * Return true if the template string is valid, false otherwise.
@@ -417,135 +313,6 @@ public class TemplateOperation {
     return outputOntology;
   }
 
-  /**
-   * Use type, id, and label information to get an entity from the data in a row. Requires either:
-   * an id (default type is owl:Class); an id and type; or a label.
-   *
-   * @param checker for looking up labels
-   * @param type the IRI of the type for this entity, or null
-   * @param id the ID for this entity, or null
-   * @param label the label for this entity, or null
-   * @return the entity
-   * @throws Exception if the entity cannot be created
-   */
-  public static OWLEntity getEntity(QuotedEntityChecker checker, IRI type, String id, String label)
-      throws Exception {
-
-    IOHelper ioHelper = checker.getIOHelper();
-
-    if (id != null && ioHelper != null) {
-      IRI iri = ioHelper.createIRI(id);
-      if (type == null) {
-        type = IRI.create(owl + "Class");
-      }
-      String t = type.toString();
-      if (t.equals(owl + "Class")) {
-        return dataFactory.getOWLClass(iri);
-      } else if (t.equals(owl + "AnnotationProperty")) {
-        return dataFactory.getOWLAnnotationProperty(iri);
-      } else if (t.equals(owl + "ObjectProperty")) {
-        return dataFactory.getOWLObjectProperty(iri);
-      } else if (t.equals(owl + "DatatypeProperty")) {
-        return dataFactory.getOWLDataProperty(iri);
-      } else if (t.equals(owl + "Datatype")) {
-        return dataFactory.getOWLDatatype(iri);
-      } else {
-        return dataFactory.getOWLNamedIndividual(iri);
-      }
-    }
-
-    if (label != null && type != null) {
-      String t = type.toString();
-      if (t.equals(owl + "Class")) {
-        return checker.getOWLClass(label);
-      } else if (t.equals(owl + "AnnotationProperty")) {
-        return checker.getOWLAnnotationProperty(label);
-      } else if (t.equals(owl + "ObjectProperty")) {
-        return checker.getOWLObjectProperty(label);
-      } else if (t.equals(owl + "DatatypeProperty")) {
-        return checker.getOWLDataProperty(label);
-      } else if (t.equals(owl + "Datatype")) {
-        return checker.getOWLDatatype(label);
-      } else {
-        return checker.getOWLIndividual(label);
-      }
-    }
-
-    if (label != null) {
-      return checker.getOWLEntity(label);
-    }
-
-    return null;
-  }
-
-  /**
-   * Get a list of the IRIs defined in a set of template tables.
-   *
-   * @param tables a map from table names to tables
-   * @param ioHelper used to find entities by name
-   * @return a list of IRIs
-   * @throws Exception when names or templates cannot be handled
-   */
-  public static List<IRI> getIRIs(Map<String, List<List<String>>> tables, IOHelper ioHelper)
-      throws Exception {
-    List<IRI> iris = new ArrayList<IRI>();
-    for (Map.Entry<String, List<List<String>>> table : tables.entrySet()) {
-      String tableName = table.getKey();
-      List<List<String>> rows = table.getValue();
-      iris.addAll(getIRIs(tableName, rows, ioHelper));
-    }
-    return iris;
-  }
-
-  /**
-   * Get a list of the IRIs defined in a template table.
-   *
-   * @param tableName the name of the table
-   * @param rows the table of data
-   * @param ioHelper used to find entities by name
-   * @return a list of IRIs
-   * @throws Exception when names or templates cannot be handled
-   */
-  public static List<IRI> getIRIs(String tableName, List<List<String>> rows, IOHelper ioHelper)
-      throws Exception {
-    // Find the ID column.
-    List<String> templates = rows.get(1);
-    int idColumn = -1;
-    for (int column = 0; column < templates.size(); column++) {
-      String template = templates.get(column);
-      if (template == null) {
-        continue;
-      }
-      template = template.trim();
-      if (template.equals("ID")) {
-        idColumn = column;
-      }
-    }
-    if (idColumn == -1) {
-      throw new Exception("Template row must include an \"ID\" column " + "in table " + tableName);
-    }
-
-    List<IRI> iris = new ArrayList<IRI>();
-    for (int row = 2; row < rows.size(); row++) {
-      String id = null;
-      try {
-        id = rows.get(row).get(idColumn);
-      } catch (IndexOutOfBoundsException e) {
-        continue;
-      }
-      if (id == null || id.trim().isEmpty()) {
-        continue;
-      }
-      IRI iri = ioHelper.createIRI(id);
-      if (iri == null) {
-        continue;
-      }
-      iris.add(iri);
-    }
-
-    return iris;
-  }
-
   /**
    * Use templates to add entities and their annotations to an ontology.
    *
@@ -643,7 +410,7 @@ public class TemplateOperation {
         if (template.equals("LABEL")) {
           label = value;
           lastAxiomAnnotation = null;
-          lastAnnotation = getStringAnnotation(checker, "A rdfs:label", value);
+          lastAnnotation = TemplateHelper.getStringAnnotation(checker, "A rdfs:label", value);
           annotations.add(lastAnnotation);
         } else if (template.equals("TYPE")) {
           OWLEntity entity = checker.getOWLEntity(value);
@@ -652,11 +419,11 @@ public class TemplateOperation {
           } else {
             type = ioHelper.createIRI(value);
           }
-          OWLAnnotationProperty rdfType = getAnnotationProperty(checker, "rdf:type");
+          OWLAnnotationProperty rdfType = TemplateHelper.getAnnotationProperty(checker, "rdf:type");
           annotations.add(dataFactory.getOWLAnnotation(rdfType, type));
         } else if (template.startsWith("A")) {
           lastAxiomAnnotation = null;
-          lastAnnotation = getAnnotation(checker, ioHelper, template, value);
+          lastAnnotation = TemplateHelper.getAnnotation(checker, ioHelper, template, value);
           annotations.add(lastAnnotation);
         } else if (template.startsWith(">A")) {
           if (lastAnnotation == null) {
@@ -673,7 +440,8 @@ public class TemplateOperation {
                     "an annotation"));
           }
           // Get annotation based on annotation type
-          lastAxiomAnnotation = getAnnotation(checker, ioHelper, template.substring(1), value);
+          lastAxiomAnnotation =
+              TemplateHelper.getAnnotation(checker, ioHelper, template.substring(1), value);
           // If the last annotation is already in the map, get it's existing annotations
           if (nested.containsKey(lastAnnotation)) {
             axiomAnnotations = nested.get(lastAnnotation);
@@ -709,19 +477,20 @@ public class TemplateOperation {
           axiomAnnotationAnnotations = axiomAnnotations.get(lastAxiomAnnotation);
           // Add this iteration of annotation and put into the nested map
           axiomAnnotationAnnotations.add(
-              getAnnotation(checker, ioHelper, template.substring(2), value));
+              TemplateHelper.getAnnotation(checker, ioHelper, template.substring(2), value));
           axiomAnnotations.put(lastAxiomAnnotation, axiomAnnotationAnnotations);
           nested.put(lastAnnotation, axiomAnnotations);
         }
       }
     }
 
-    OWLEntity entity = getEntity(checker, type, id, label);
+    OWLEntity entity = TemplateHelper.getEntity(checker, type, id, label);
     if (entity == null) {
       throw new Exception(String.format(nullIDError, tableName, row + 1, id, label));
     }
     // Create axioms from OWLAnnotation sets
-    Set<OWLAnnotationAssertionAxiom> axioms = getAnnotationAxioms(entity, annotations, nested);
+    Set<OWLAnnotationAssertionAxiom> axioms =
+        TemplateHelper.getAnnotationAxioms(entity, annotations, nested);
     // Add annotations to an entity
     OWLOntology ontology = outputManager.createOntology();
     outputManager.addAxiom(ontology, dataFactory.getOWLDeclarationAxiom(entity));
@@ -850,7 +619,8 @@ public class TemplateOperation {
         } else {
           annotations = new HashSet<>();
         }
-        annotations.add(getStringAnnotation(checker, template.substring(1).trim(), cell));
+        annotations.add(
+            TemplateHelper.getStringAnnotation(checker, template.substring(1).trim(), cell));
         annotatedExpressions.put(lastExpression, annotations);
         // Remove to prevent duplication
         if (classExpressions.contains(lastExpression)) {
@@ -879,131 +649,12 @@ public class TemplateOperation {
     if (cls == null) {
       throw new Exception(String.format(nullIDError, tableName, row + 1, id, label));
     }
-    Set<OWLAxiom> axioms = getLogicalAxioms(cls, classType, classExpressions, annotatedExpressions);
+    Set<OWLAxiom> axioms =
+        TemplateHelper.getLogicalAxioms(cls, classType, classExpressions, annotatedExpressions);
     if (axioms == null) {
       throw new Exception(String.format(unknownTypeError, tableName, row + 1, id));
     } else {
       ontology.getOWLOntologyManager().addAxioms(ontology, axioms);
     }
   }
-
-  /**
-   * Create an OWLAnnotation based on the template string and cell value.
-   *
-   * @param checker used to resolve the annotation property
-   * @param ioHelper IOHelper used to create IRIs from values
-   * @param template the template string
-   * @param value the value for the annotation
-   * @return OWLAnnotation, or null if template string is not supported
-   * @throws Exception if annotation property cannot be found
-   */
-  private static OWLAnnotation getAnnotation(
-      QuotedEntityChecker checker, IOHelper ioHelper, String template, String value)
-      throws Exception {
-    if (template.startsWith("A ")) {
-      return getStringAnnotation(checker, template, value);
-    } else if (template.startsWith("AT ") && template.indexOf("^^") > -1) {
-      return getTypedAnnotation(checker, template, value);
-    } else if (template.startsWith("AL ") && template.indexOf("@") > -1) {
-      return getLanguageAnnotation(checker, template, value);
-    } else if (template.startsWith("AI ")) {
-      IRI iri = ioHelper.createIRI(value);
-      return getIRIAnnotation(checker, template, iri);
-    } else {
-      return null;
-    }
-  }
-
-  /**
-   * Get a set of annotation axioms for an OWLEntity. Supports axiom annotations and axiom
-   * annotation annotations.
-   *
-   * @param entity OWLEntity to annotation
-   * @param annotations Set of OWLAnnotations
-   * @param nested Map with top-level OWLAnnotation as key and another map (axiom OWLAnnotation, set
-   *     of axiom annotation OWLAnnotations) as value
-   * @return Set of OWLAnnotationAssertionAxioms
-   */
-  private static Set<OWLAnnotationAssertionAxiom> getAnnotationAxioms(
-      OWLEntity entity,
-      Set<OWLAnnotation> annotations,
-      Map<OWLAnnotation, Map<OWLAnnotation, Set<OWLAnnotation>>> nested) {
-    Set<OWLAnnotationAssertionAxiom> axioms = new HashSet<>();
-    // Create basic annotations
-    for (OWLAnnotation annotation : annotations) {
-      axioms.add(dataFactory.getOWLAnnotationAssertionAxiom(entity.getIRI(), annotation));
-    }
-    // Create annotations with axiom annotations
-    for (Entry<OWLAnnotation, Map<OWLAnnotation, Set<OWLAnnotation>>> annotationPlusAxioms :
-        nested.entrySet()) {
-      OWLAnnotation annotation = annotationPlusAxioms.getKey();
-      Set<OWLAnnotation> axiomAnnotations = new HashSet<>();
-      // For each annotation with its axiom annotations ...
-      for (Entry<OWLAnnotation, Set<OWLAnnotation>> nestedSet :
-          annotationPlusAxioms.getValue().entrySet()) {
-        OWLAnnotation axiomAnnotation = nestedSet.getKey();
-        // Check if there are annotations on the axiom annotations, and add those
-        Set<OWLAnnotation> axiomAnnotationAnnotations = nestedSet.getValue();
-        if (axiomAnnotationAnnotations.isEmpty()) {
-          axiomAnnotations.add(axiomAnnotation);
-        } else {
-          OWLAnnotationProperty property = axiomAnnotation.getProperty();
-          OWLAnnotationValue value = axiomAnnotation.getValue();
-          axiomAnnotations.add(
-              dataFactory.getOWLAnnotation(property, value, axiomAnnotationAnnotations));
-        }
-      }
-      axioms.add(
-          dataFactory.getOWLAnnotationAssertionAxiom(
-              entity.getIRI(), annotation, axiomAnnotations));
-    }
-    return axioms;
-  }
-
-  /**
-   * Get a set of OWLAxioms (subclass or equivalent) for an OWLClass. Supports axiom annotations.
-   *
-   * @param cls OWLClass to add axioms to
-   * @param classType subclass or equivalent
-   * @param classExpressions Set of OWLClassExpressions
-   * @param annotatedExpressions Map of annotated OWLClassExpressions and the Set of OWLAnnotations
-   * @return Set of OWLAxioms, or null if classType is not subclass or equivalent
-   */
-  private static Set<OWLAxiom> getLogicalAxioms(
-      OWLClass cls,
-      String classType,
-      Set<OWLClassExpression> classExpressions,
-      Map<OWLClassExpression, Set<OWLAnnotation>> annotatedExpressions) {
-    Set<OWLAxiom> axioms = new HashSet<>();
-    if (classType.equals("subclass")) {
-      for (OWLClassExpression expression : classExpressions) {
-        axioms.add(dataFactory.getOWLSubClassOfAxiom(cls, expression));
-      }
-      for (Entry<OWLClassExpression, Set<OWLAnnotation>> annotatedEx :
-          annotatedExpressions.entrySet()) {
-        axioms.add(
-            dataFactory.getOWLSubClassOfAxiom(cls, annotatedEx.getKey(), annotatedEx.getValue()));
-      }
-      return axioms;
-    } else if (classType.equals("equivalent")) {
-      // Since it's an intersection, all annotations will be added to the same axiom
-      Set<OWLAnnotation> annotations = new HashSet<>();
-      for (Entry<OWLClassExpression, Set<OWLAnnotation>> annotatedEx :
-          annotatedExpressions.entrySet()) {
-        classExpressions.add(annotatedEx.getKey());
-        annotations.addAll(annotatedEx.getValue());
-      }
-      OWLObjectIntersectionOf intersection =
-          dataFactory.getOWLObjectIntersectionOf(classExpressions);
-      OWLAxiom axiom;
-      if (!annotations.isEmpty()) {
-        axiom = dataFactory.getOWLEquivalentClassesAxiom(cls, intersection, annotations);
-      } else {
-        axiom = dataFactory.getOWLEquivalentClassesAxiom(cls, intersection);
-      }
-      return Sets.newHashSet(axiom);
-    } else {
-      return null;
-    }
-  }
 }


Test patch:
diff --git a/robot-core/src/test/java/org/obolibrary/robot/TemplateOperationTest.java b/robot-core/src/test/java/org/obolibrary/robot/TemplateOperationTest.java
index 7dedaeb5..a2be5ec6 100644
--- a/robot-core/src/test/java/org/obolibrary/robot/TemplateOperationTest.java
+++ b/robot-core/src/test/java/org/obolibrary/robot/TemplateOperationTest.java
@@ -33,17 +33,16 @@ public class TemplateOperationTest extends CoreTest {
     QuotedEntityChecker checker = new QuotedEntityChecker();
     checker.setIOHelper(new IOHelper());
 
-    ann = TemplateOperation.getStringAnnotation(checker, "A rdfs:label", "bar");
+    ann = TemplateHelper.getStringAnnotation(checker, "A rdfs:label", "bar");
     assertEquals("Annotation(rdfs:label \"bar\"^^xsd:string)", ann.toString());
 
-    ann = TemplateOperation.getTypedAnnotation(checker, "AT rdfs:label^^xsd:integer", "1");
+    ann = TemplateHelper.getTypedAnnotation(checker, "AT rdfs:label^^xsd:integer", "1");
     assertEquals("Annotation(rdfs:label \"1\"^^xsd:integer)", ann.toString());
 
-    ann = TemplateOperation.getLanguageAnnotation(checker, "AL rdfs:label@en", "bar");
+    ann = TemplateHelper.getLanguageAnnotation(checker, "AL rdfs:label@en", "bar");
     assertEquals("Annotation(rdfs:label \"bar\"@en)", ann.toString());
 
-    ann =
-        TemplateOperation.getIRIAnnotation(checker, "AI rdfs:label", IRI.create("http://bar.com"));
+    ann = TemplateHelper.getIRIAnnotation(checker, "AI rdfs:label", IRI.create("http://bar.com"));
     assertEquals("Annotation(rdfs:label <http://bar.com>)", ann.toString());
   }
 
@@ -95,7 +94,7 @@ public class TemplateOperationTest extends CoreTest {
   public void testTemplateCSV() throws Exception {
     Map<String, List<List<String>>> tables = new LinkedHashMap<String, List<List<String>>>();
     String path = "/template.csv";
-    tables.put(path, IOHelper.readCSV(this.getClass().getResourceAsStream(path)));
+    tables.put(path, TemplateHelper.readCSV(this.getClass().getResourceAsStream(path)));
     OWLOntology simpleParts = loadOntology("/simple_parts.owl");
     OWLOntology template = TemplateOperation.template(tables, simpleParts);
     OntologyHelper.setOntologyIRI(template, IRI.create("http://test.com/template.owl"), null);
@@ -111,9 +110,9 @@ public class TemplateOperationTest extends CoreTest {
   public void testTemplates() throws Exception {
     Map<String, List<List<String>>> tables = new LinkedHashMap<String, List<List<String>>>();
     String path = "/template-ids.csv";
-    tables.put(path, IOHelper.readCSV(this.getClass().getResourceAsStream(path)));
+    tables.put(path, TemplateHelper.readCSV(this.getClass().getResourceAsStream(path)));
     path = "/template-labels.csv";
-    tables.put(path, IOHelper.readCSV(this.getClass().getResourceAsStream(path)));
+    tables.put(path, TemplateHelper.readCSV(this.getClass().getResourceAsStream(path)));
     OWLOntology simpleParts = loadOntology("/simple_parts.owl");
     OWLOntology template = TemplateOperation.template(tables, simpleParts);
     assertEquals("Count classes", 4, template.getClassesInSignature().size());