diff --git a/pokedex/cli/__init__.py b/pokedex/cli/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/pokedex/cli/search.py b/pokedex/cli/search.py
new file mode 100644
index 0000000..cef0458
--- /dev/null
+++ b/pokedex/cli/search.py
@@ -0,0 +1,22 @@
+from pokedex.search import search
+
+
+def configure_parser(parser):
+    parser.set_defaults(func=command_search)
+
+    parser.add_argument('--name', default=None)
+
+    parser.add_argument('--attack', '--atk', dest='attack', default=None)
+    parser.add_argument('--defense', '--def', dest='defense', default=None)
+    parser.add_argument('--special-attack', '--spatk', dest='special-attack', default=None)
+    parser.add_argument('--special-defense', '--spdef', dest='special-defense', default=None)
+    parser.add_argument('--speed', dest='speed', default=None)
+    parser.add_argument('--hp', dest='hp', default=None)
+
+
+def command_search(parser, args):
+    from pokedex.main import get_session
+    session = get_session(args)
+    results = search(session, **vars(args))
+    for result in results:
+        print(result.name)
diff --git a/pokedex/data/csv/move_meta_ailment_names.csv b/pokedex/data/csv/move_meta_ailment_names.csv
index 5b4e8e6..d73c143 100644
--- a/pokedex/data/csv/move_meta_ailment_names.csv
+++ b/pokedex/data/csv/move_meta_ailment_names.csv
@@ -29,7 +29,7 @@ move_meta_ailment_id,local_language_id,name
 14,9,Yawn
 15,5,Anti-Soin
 15,9,Heal Block
-17,5,
+17,5,Aucune immunité aux types
 17,9,No type immunity
 18,5,Vampigraine
 18,9,Leech Seed
diff --git a/pokedex/data/csv/pokemon.csv b/pokedex/data/csv/pokemon.csv
index 6baab9b..c1a2e72 100644
--- a/pokedex/data/csv/pokemon.csv
+++ b/pokedex/data/csv/pokemon.csv
@@ -785,7 +785,7 @@ id,identifier,species_id,height,weight,base_experience,order,is_default
 10063,latios-mega,381,23,700,315,461,0
 10064,swampert-mega,260,19,1020,286,315,0
 10065,sceptile-mega,254,19,552,284,307,0
-10066,sableye-mega,302,50,1610,168,361,0
+10066,sableye-mega,302,05,1610,168,361,0
 10067,altaria-mega,334,15,206,207,402,0
 10068,gallade-mega,475,16,564,278,340,0
 10069,audino-mega,531,15,320,425,602,0
diff --git a/pokedex/data/csv/pokemon_forms.csv b/pokedex/data/csv/pokemon_forms.csv
index 79fd990..20b4f0c 100644
--- a/pokedex/data/csv/pokemon_forms.csv
+++ b/pokedex/data/csv/pokemon_forms.csv
@@ -898,8 +898,8 @@ id,identifier,form_identifier,pokemon_id,introduced_in_version_group_id,is_defau
 10176,glalie-mega,mega,10074,16,1,1,1,2,464
 10177,diancie-mega,mega,10075,16,1,1,1,2,890
 10178,metagross-mega,mega,10076,16,1,1,1,2,481
-10179,kyogre-primal,primal,10077,16,1,0,0,2,490
-10180,groudon-primal,primal,10078,16,1,0,0,2,492
+10179,kyogre-primal,primal,10077,16,1,1,0,2,490
+10180,groudon-primal,primal,10078,16,1,1,0,2,492
 10181,rayquaza-mega,mega,10079,16,1,1,1,2,494
 10182,pikachu-rock-star,rock-star,10080,16,1,0,0,3,35
 10183,pikachu-belle,belle,10081,16,1,0,0,4,36
diff --git a/pokedex/data/csv/pokemon_species.csv b/pokedex/data/csv/pokemon_species.csv
index 54f7c11..435af90 100644
--- a/pokedex/data/csv/pokemon_species.csv
+++ b/pokedex/data/csv/pokemon_species.csv
@@ -13,10 +13,10 @@ id,identifier,generation_id,evolves_from_species_id,evolution_chain_id,color_id,
 12,butterfree,1,11,4,9,13,2,4,45,70,0,15,1,2,0,12,
 13,weedle,1,,5,3,2,2,4,255,70,0,15,0,2,0,13,
 14,kakuna,1,13,5,10,2,2,4,120,70,0,15,0,2,0,14,
-15,beedrill,1,14,5,10,13,2,4,45,70,0,15,0,2,0,15,177
+15,beedrill,1,14,5,10,13,2,4,45,70,0,15,0,2,1,15,177
 16,pidgey,1,,6,3,9,2,4,255,70,0,15,0,4,0,16,
 17,pidgeotto,1,16,6,3,9,2,4,120,70,0,15,0,4,0,17,
-18,pidgeot,1,17,6,3,9,2,4,45,70,0,15,0,4,0,18,
+18,pidgeot,1,17,6,3,9,2,4,45,70,0,15,0,4,1,18,
 19,rattata,1,,7,7,8,3,4,255,70,0,15,1,2,0,19,
 20,raticate,1,19,7,3,8,3,4,127,70,0,15,1,2,0,20,
 21,spearow,1,,8,3,9,6,4,255,70,0,15,0,2,0,21,
@@ -78,7 +78,7 @@ id,identifier,generation_id,evolves_from_species_id,evolution_chain_id,color_id,
 77,ponyta,1,,32,10,8,3,4,190,70,0,20,0,2,0,83,
 78,rapidash,1,77,32,10,8,3,4,60,70,0,20,0,2,0,84,
 79,slowpoke,1,,33,6,8,9,4,190,70,0,20,0,2,0,85,
-80,slowbro,1,79,33,6,6,9,4,75,70,0,20,0,2,0,86,
+80,slowbro,1,79,33,6,6,9,4,75,70,0,20,0,2,1,86,
 81,magnemite,1,,34,4,4,6,-1,190,70,0,20,0,2,0,88,
 82,magneton,1,81,34,4,11,6,-1,60,70,0,20,0,2,0,89,
 83,farfetchd,1,,35,3,9,3,4,45,70,0,20,0,2,0,91,
@@ -206,7 +206,7 @@ id,identifier,generation_id,evolves_from_species_id,evolution_chain_id,color_id,
 205,forretress,2,204,102,7,1,2,4,75,70,0,20,0,2,0,236,57
 206,dunsparce,2,,103,10,2,1,4,190,70,0,20,0,2,0,237,
 207,gligar,2,,104,7,9,4,4,60,70,0,20,1,4,0,238,
-208,steelix,2,95,41,4,2,1,4,25,70,0,25,1,2,0,104,176
+208,steelix,2,95,41,4,2,1,4,25,70,0,25,1,2,1,104,176
 209,snubbull,2,,105,6,12,8,6,190,70,0,20,0,3,0,240,
 210,granbull,2,209,105,7,6,8,6,75,70,0,20,0,3,0,241,
 211,qwilfish,2,,106,4,3,7,4,45,70,0,20,0,2,0,242,
@@ -252,13 +252,13 @@ id,identifier,generation_id,evolves_from_species_id,evolution_chain_id,color_id,
 251,celebi,2,,129,5,12,2,-1,45,100,0,120,0,4,0,276,
 252,treecko,3,,130,5,6,2,1,45,70,0,20,0,4,0,277,130
 253,grovyle,3,252,130,5,6,2,1,45,70,0,20,0,4,0,278,131
-254,sceptile,3,253,130,5,6,2,1,45,70,0,20,0,4,0,279,132
+254,sceptile,3,253,130,5,6,2,1,45,70,0,20,0,4,1,279,132
 255,torchic,3,,131,8,7,3,1,45,70,0,20,1,4,0,280,
 256,combusken,3,255,131,8,6,3,1,45,70,0,20,1,4,0,281,
 257,blaziken,3,256,131,8,6,3,1,45,70,0,20,1,4,1,282,
 258,mudkip,3,,132,2,8,9,1,45,70,0,20,0,4,0,283,
 259,marshtomp,3,258,132,2,6,9,1,45,70,0,20,0,4,0,284,
-260,swampert,3,259,132,2,6,9,1,45,70,0,20,0,4,0,285,
+260,swampert,3,259,132,2,6,9,1,45,70,0,20,0,4,1,285,
 261,poochyena,3,,133,4,8,3,4,255,70,0,15,0,2,0,286,
 262,mightyena,3,261,133,4,8,3,4,127,70,0,15,0,2,0,287,
 263,zigzagoon,3,,134,3,8,3,4,255,70,0,15,0,2,0,288,
@@ -300,7 +300,7 @@ id,identifier,generation_id,evolves_from_species_id,evolution_chain_id,color_id,
 299,nosepass,3,,147,4,12,1,4,255,70,0,20,0,2,0,324,
 300,skitty,3,,148,6,8,2,6,255,70,0,15,0,3,0,326,
 301,delcatty,3,300,148,7,8,2,6,60,70,0,15,0,3,0,327,
-302,sableye,3,,149,7,12,1,4,45,35,0,25,0,4,0,328,
+302,sableye,3,,149,7,12,1,4,45,35,0,25,0,4,1,328,
 303,mawile,3,,150,1,12,1,4,45,70,0,20,0,3,1,329,
 304,aron,3,,151,4,8,4,4,180,35,0,35,0,1,0,330,149
 305,lairon,3,304,151,4,8,4,4,90,35,0,35,0,1,0,331,150
@@ -317,11 +317,11 @@ id,identifier,generation_id,evolves_from_species_id,evolution_chain_id,color_id,
 316,gulpin,3,,159,5,4,3,4,225,70,0,20,1,6,0,344,
 317,swalot,3,316,159,7,4,3,4,75,70,0,20,1,6,0,345,
 318,carvanha,3,,160,8,3,7,4,225,35,0,20,0,1,0,346,
-319,sharpedo,3,318,160,2,3,7,4,60,35,0,20,0,1,0,347,
+319,sharpedo,3,318,160,2,3,7,4,60,35,0,20,0,1,1,347,
 320,wailmer,3,,161,2,3,7,4,125,70,0,40,0,6,0,348,
 321,wailord,3,320,161,2,3,7,4,60,70,0,40,0,6,0,349,
 322,numel,3,,162,10,8,4,4,255,70,0,20,1,2,0,350,
-323,camerupt,3,322,162,8,8,4,4,150,70,0,20,1,2,0,351,
+323,camerupt,3,322,162,8,8,4,4,150,70,0,20,1,2,1,351,
 324,torkoal,3,,163,3,8,4,4,90,70,0,20,0,2,0,352,
 325,spoink,3,,164,1,4,4,4,255,70,0,20,0,3,0,353,
 326,grumpig,3,325,164,7,6,4,4,60,70,0,20,0,3,0,354,
@@ -332,7 +332,7 @@ id,identifier,generation_id,evolves_from_species_id,evolution_chain_id,color_id,
 331,cacnea,3,,167,5,12,6,4,190,35,0,20,0,4,0,359,
 332,cacturne,3,331,167,5,12,6,4,60,35,0,20,1,4,0,360,
 333,swablu,3,,168,2,9,2,4,255,70,0,20,0,5,0,361,
-334,altaria,3,333,168,2,9,2,4,45,70,0,20,0,5,0,362,
+334,altaria,3,333,168,2,9,2,4,45,70,0,20,0,5,1,362,
 335,zangoose,3,,169,9,6,3,4,90,70,0,20,0,5,0,363,
 336,seviper,3,,170,1,2,3,4,90,70,0,20,0,6,0,364,
 337,lunatone,3,,171,10,1,1,-1,45,70,0,25,0,3,0,365,
@@ -360,7 +360,7 @@ id,identifier,generation_id,evolves_from_species_id,evolution_chain_id,color_id,
 359,absol,3,,185,9,8,4,4,30,35,0,25,0,4,1,389,
 360,wynaut,3,,100,2,6,1,4,125,70,1,20,0,2,0,232,
 361,snorunt,3,,186,4,12,1,4,190,70,0,20,0,2,0,390,93
-362,glalie,3,361,186,4,1,1,4,75,70,0,20,0,2,0,391,94
+362,glalie,3,361,186,4,1,1,4,75,70,0,20,0,2,1,391,94
 363,spheal,3,,187,2,3,7,4,255,70,0,20,0,4,0,393,60
 364,sealeo,3,363,187,2,3,7,4,120,70,0,20,0,4,0,394,61
 365,walrein,3,364,187,2,8,7,4,45,70,0,20,0,4,0,395,62
@@ -371,18 +371,18 @@ id,identifier,generation_id,evolves_from_species_id,evolution_chain_id,color_id,
 370,luvdisc,3,,190,6,3,7,6,225,70,0,20,0,3,0,400,
 371,bagon,3,,191,2,12,6,4,45,35,0,40,0,1,0,401,
 372,shelgon,3,371,191,9,8,6,4,45,35,0,40,0,1,0,402,
-373,salamence,3,372,191,2,8,6,4,45,35,0,40,0,1,0,403,
+373,salamence,3,372,191,2,8,6,4,45,35,0,40,0,1,1,403,
 374,beldum,3,,192,2,5,6,-1,3,35,0,40,0,1,0,404,82
 375,metang,3,374,192,2,4,6,-1,3,35,0,40,0,1,0,405,83
-376,metagross,3,375,192,2,11,6,-1,3,35,0,40,0,1,0,406,84
+376,metagross,3,375,192,2,11,6,-1,3,35,0,40,0,1,1,406,84
 377,regirock,3,,193,3,12,1,-1,3,35,0,80,0,1,0,407,
 378,regice,3,,194,2,12,1,-1,3,35,0,80,0,1,0,408,
 379,registeel,3,,195,4,12,1,-1,3,35,0,80,0,1,0,409,193
-380,latias,3,,196,8,9,9,8,3,90,0,120,0,1,0,410,
-381,latios,3,,197,2,9,9,0,3,90,0,120,0,1,0,411,
+380,latias,3,,196,8,9,9,8,3,90,0,120,0,1,1,410,
+381,latios,3,,197,2,9,9,0,3,90,0,120,0,1,1,411,
 382,kyogre,3,,198,2,3,7,-1,5,0,0,120,0,1,0,412,
 383,groudon,3,,199,8,6,6,-1,5,0,0,120,0,1,0,413,194
-384,rayquaza,3,,200,5,2,5,-1,3,0,0,120,0,1,0,414,200
+384,rayquaza,3,,200,5,2,5,-1,3,0,0,120,0,1,1,414,200
 385,jirachi,3,,201,10,12,4,-1,3,100,0,120,0,1,0,415,
 386,deoxys,3,,202,8,12,5,-1,3,0,0,120,0,1,1,416,
 387,turtwig,4,,203,5,8,,1,45,70,0,20,0,4,0,417,
@@ -426,7 +426,7 @@ id,identifier,generation_id,evolves_from_species_id,evolution_chain_id,color_id,
 425,drifloon,4,,219,7,4,,4,125,70,0,30,0,6,0,452,167
 426,drifblim,4,425,219,7,4,,4,60,70,0,30,0,6,0,453,168
 427,buneary,4,,220,3,6,,4,190,0,0,20,0,2,0,454,
-428,lopunny,4,427,220,3,6,,4,60,140,0,20,0,2,0,455,
+428,lopunny,4,427,220,3,6,,4,60,140,0,20,0,2,1,455,
 429,mismagius,4,200,98,7,1,,4,45,35,0,25,0,3,0,230,184
 430,honchkrow,4,198,97,1,9,,4,30,35,0,20,0,4,0,228,
 431,glameow,4,,221,4,8,,6,190,70,0,20,0,3,0,456,
@@ -473,7 +473,7 @@ id,identifier,generation_id,evolves_from_species_id,evolution_chain_id,color_id,
 472,gliscor,4,207,104,7,9,,4,30,70,0,20,0,4,0,239,
 473,mamoswine,4,221,112,3,8,,4,50,70,0,20,1,1,0,253,
 474,porygon-z,4,233,68,8,4,,-1,30,70,0,20,0,2,0,168,
-475,gallade,4,281,140,9,12,,0,45,35,0,20,0,1,0,308,12
+475,gallade,4,281,140,9,12,,0,45,35,0,20,0,1,1,308,12
 476,probopass,4,299,147,4,11,,4,60,70,0,20,0,2,0,325,
 477,dusknoir,4,356,182,1,4,,4,45,35,0,25,0,3,0,385,71
 478,froslass,4,361,186,9,4,,8,75,70,0,20,0,2,0,392,95
@@ -529,7 +529,7 @@ id,identifier,generation_id,evolves_from_species_id,evolution_chain_id,color_id,
 528,swoobat,5,527,269,2,9,,4,45,70,0,15,0,2,0,529,
 529,drilbur,5,,270,4,6,,4,120,70,0,20,0,2,0,530,152
 530,excadrill,5,529,270,4,12,,4,60,70,0,20,0,2,0,531,153
-531,audino,5,,271,6,6,,4,255,70,0,20,0,3,0,532,185
+531,audino,5,,271,6,6,,4,255,70,0,20,0,3,1,532,185
 532,timburr,5,,272,4,12,,2,180,70,0,20,0,4,0,533,101
 533,gurdurr,5,532,272,4,12,,2,90,70,0,20,0,4,0,534,102
 534,conkeldurr,5,533,272,3,12,,2,45,70,0,20,0,4,0,535,103
@@ -717,6 +717,6 @@ id,identifier,generation_id,evolves_from_species_id,evolution_chain_id,color_id,
 716,xerneas,6,,368,2,8,,-1,45,0,0,120,0,1,1,716,
 717,yveltal,6,,369,8,9,,-1,45,0,0,120,0,1,0,717,
 718,zygarde,6,,370,5,2,,-1,3,0,0,120,0,1,0,718,
-719,diancie,6,,371,6,4,,-1,3,70,0,25,0,1,0,719,
+719,diancie,6,,371,6,4,,-1,3,70,0,25,0,1,1,719,
 720,hoopa,6,,372,7,1,,-1,3,100,0,120,0,1,0,720,
 721,volcanion,6,,373,3,1,,-1,3,100,0,120,0,1,0,721,
diff --git a/pokedex/main.py b/pokedex/main.py
index a8171a0..ac83695 100644
--- a/pokedex/main.py
+++ b/pokedex/main.py
@@ -1,57 +1,147 @@
 # encoding: utf8
 from __future__ import print_function
 
-from optparse import OptionParser
+import argparse
 import os
 import sys
 
+import pokedex.cli.search
 import pokedex.db
 import pokedex.db.load
 import pokedex.db.tables
 import pokedex.lookup
 from pokedex import defaults
 
-def main(*argv):
-    if len(argv) <= 1:
+
+def main(junk, *argv):
+    if len(argv) <= 0:
         command_help()
+        return
 
-    command = argv[1]
-    args = argv[2:]
+    parser = create_parser()
+    args = parser.parse_args(argv)
+    args.func(parser, args)
 
-    # XXX there must be a better way to get Unicode argv
-    # XXX this doesn't work on Windows durp
-    enc = sys.stdin.encoding or 'utf8'
-    args = [_.decode(enc) if isinstance(_, bytes) else _ for _ in args]
-
-    # Find the command as a function in this file
-    func = globals().get("command_%s" % command, None)
-    if func:
-        func(*args)
-    else:
-        command_help()
 
 def setuptools_entry():
     main(*sys.argv)
 
 
-def get_parser(verbose=True):
-    """Returns an OptionParser prepopulated with the global options.
-
-    `verbose` is whether or not the options should be verbose by default.
+def create_parser():
+    """Build and return an ArgumentParser.
     """
-    parser = OptionParser()
-    parser.add_option('-e', '--engine', dest='engine_uri', default=None)
-    parser.add_option('-i', '--index', dest='index_dir', default=None)
-    parser.add_option('-q', '--quiet', dest='verbose', default=verbose, action='store_false')
-    parser.add_option('-v', '--verbose', dest='verbose', default=verbose, action='store_true')
+    # Slightly clumsy workaround to make both `setup -v` and `-v setup` work
+    common_parser = argparse.ArgumentParser(add_help=False)
+    common_parser.add_argument(
+        '-e', '--engine', dest='engine_uri', default=None,
+        help=u'By default, all commands try to use a SQLite database '
+            u'in the pokedex install directory.  Use this option (or '
+            u'a POKEDEX_DB_ENGINE environment variable) to specify an '
+            u'alternate database.',
+        )
+    common_parser.add_argument(
+        '-i', '--index', dest='index_dir', default=None,
+        help=u'By default, all commands try to put the lookup index in '
+            u'the pokedex install directory.  Use this option (or a '
+            u'POKEDEX_INDEX_DIR environment variable) to specify an '
+            u'alternate loction.',
+    )
+    common_parser.add_argument(
+        '-q', '--quiet', dest='verbose', action='store_false',
+        help=u'Don\'t print system output.  This is the default for '
+            'non-system commands and setup.',
+    )
+    common_parser.add_argument(
+        '-v', '--verbose', dest='verbose', default=False, action='store_true',
+        help=u'Print system output.  This is the default for system '
+            u'commands, except setup.',
+    )
+
+    parser = argparse.ArgumentParser(
+        prog='pokedex', description=u'A command-line Pokédex interface',
+        parents=[common_parser],
+    )
+
+    cmds = parser.add_subparsers(title='Commands')
+    cmd_help = cmds.add_parser(
+        'help', help=u'Display this message',
+        parents=[common_parser])
+    cmd_help.set_defaults(func=command_help)
+
+    cmd_lookup = cmds.add_parser(
+        'lookup', help=u'Look up something in the Pokédex',
+        parents=[common_parser])
+    cmd_lookup.set_defaults(func=command_lookup)
+    cmd_lookup.add_argument('criteria', nargs='+')
+
+    cmd_search = cmds.add_parser(
+        'search', help=u'Find things by various criteria',
+        parents=[common_parser])
+    pokedex.cli.search.configure_parser(cmd_search)
+
+    cmd_load = cmds.add_parser(
+        'load', help=u'Load Pokédex data into a database from CSV files',
+        parents=[common_parser])
+    cmd_load.set_defaults(func=command_load, verbose=True)
+    # TODO get the actual default here
+    cmd_load.add_argument(
+        '-d', '--directory', dest='directory', default=None,
+        help="directory containing the CSV files to load")
+    cmd_load.add_argument(
+        '-D', '--drop-tables', dest='drop_tables', default=False, action='store_true',
+        help="drop all tables before loading data")
+    cmd_load.add_argument(
+        '-r', '--recursive', dest='recursive', default=False, action='store_true',
+        help="load and drop all dependent tables (default is to use exactly the given list)")
+    cmd_load.add_argument(
+        '-S', '--safe', dest='safe', default=False, action='store_true',
+        help="disable database-specific optimizations, such as Postgres's COPY FROM")
+    # TODO need a custom handler for splittin' all of these
+    cmd_load.add_argument(
+        '-l', '--langs', dest='langs', default=None,
+        help="comma-separated list of language codes to load, or 'none' (default: all)")
+    cmd_load.add_argument(
+        'tables', nargs='*',
+        help="list of database tables to load (default: all)")
+
+    cmd_dump = cmds.add_parser(
+        'dump', help=u'Dump Pokédex data from a database into CSV files',
+        parents=[common_parser])
+    cmd_dump.set_defaults(func=command_dump, verbose=True)
+    cmd_dump.add_argument(
+        '-d', '--directory', dest='directory', default=None,
+        help="directory to place the dumped CSV files")
+    cmd_dump.add_argument(
+        '-l', '--langs', dest='langs', default=None,
+        help="comma-separated list of language codes to load, 'none', or 'all' (default: en)")
+    cmd_dump.add_argument(
+        'tables', nargs='*',
+        help="list of database tables to load (default: all)")
+
+    cmd_reindex = cmds.add_parser(
+        'reindex', help=u'Rebuild the lookup index from the database',
+        parents=[common_parser])
+    cmd_reindex.set_defaults(func=command_reindex, verbose=True)
+
+    cmd_setup = cmds.add_parser(
+        'setup', help=u'Combine load and reindex',
+        parents=[common_parser])
+    cmd_setup.set_defaults(func=command_setup, verbose=False)
+
+    cmd_status = cmds.add_parser(
+        'status', help=u'Print which engine, index, and csv directory would be used for other commands',
+        parents=[common_parser])
+    cmd_status.set_defaults(func=command_status, verbose=True)
+
     return parser
 
-def get_session(options):
+
+def get_session(args):
     """Given a parsed options object, connects to the database and returns a
     session.
     """
 
-    engine_uri = options.engine_uri
+    engine_uri = args.engine_uri
     got_from = 'command line'
 
     if engine_uri is None:
@@ -59,13 +149,14 @@ def get_session(options):
 
     session = pokedex.db.connect(engine_uri)
 
-    if options.verbose:
+    if args.verbose:
         print("Connected to database %(engine)s (from %(got_from)s)"
             % dict(engine=session.bind.url, got_from=got_from))
 
     return session
 
-def get_lookup(options, session=None, recreate=False):
+
+def get_lookup(args, session=None, recreate=False):
     """Given a parsed options object, opens the whoosh index and returns a
     PokedexLookup object.
     """
@@ -73,13 +164,13 @@ def get_lookup(options, session=None, recreate=False):
     if recreate and not session:
         raise ValueError("get_lookup() needs an explicit session to regen the index")
 
-    index_dir = options.index_dir
+    index_dir = args.index_dir
     got_from = 'command line'
 
     if index_dir is None:
         index_dir, got_from = defaults.get_default_index_dir_with_origin()
 
-    if options.verbose:
+    if args.verbose:
         print("Opened lookup index %(index_dir)s (from %(got_from)s)"
             % dict(index_dir=index_dir, got_from=got_from))
 
@@ -90,13 +181,14 @@ def get_lookup(options, session=None, recreate=False):
 
     return lookup
 
-def get_csv_directory(options):
+
+def get_csv_directory(args):
     """Prints and returns the csv directory we're about to use."""
 
-    if not options.verbose:
+    if not args.verbose:
         return
 
-    csvdir = options.directory
+    csvdir = args.directory
     got_from = 'command line'
 
     if csvdir is None:
@@ -110,99 +202,78 @@ def get_csv_directory(options):
 
 ### Plumbing commands
 
-def command_dump(*args):
-    parser = get_parser(verbose=True)
-    parser.add_option('-d', '--directory', dest='directory', default=None)
-    parser.add_option('-l', '--langs', dest='langs', default=None,
-        help="Comma-separated list of languages to dump all strings for. "
-            "Default is English ('en')")
-    options, tables = parser.parse_args(list(args))
+def command_dump(parser, args):
+    session = get_session(args)
+    get_csv_directory(args)
 
-    session = get_session(options)
-    get_csv_directory(options)
-
-    if options.langs is not None:
-        langs = [l.strip() for l in options.langs.split(',')]
+    if args.langs is not None:
+        langs = [l.strip() for l in args.langs.split(',')]
     else:
         langs = None
 
-    pokedex.db.load.dump(session, directory=options.directory,
-                                  tables=tables,
-                                  verbose=options.verbose,
-                                  langs=langs)
+    pokedex.db.load.dump(
+        session,
+        directory=args.directory,
+        tables=args.tables,
+        verbose=args.verbose,
+        langs=langs,
+    )
 
-def command_load(*args):
-    parser = get_parser(verbose=True)
-    parser.add_option('-d', '--directory', dest='directory', default=None)
-    parser.add_option('-D', '--drop-tables', dest='drop_tables', default=False, action='store_true')
-    parser.add_option('-r', '--recursive', dest='recursive', default=False, action='store_true')
-    parser.add_option('-S', '--safe', dest='safe', default=False, action='store_true',
-        help="Do not use backend-specific optimalizations.")
-    parser.add_option('-l', '--langs', dest='langs', default=None,
-        help="Comma-separated list of extra languages to load, or 'none' for none. "
-            "Default is to load 'em all. Example: 'fr,de'")
-    options, tables = parser.parse_args(list(args))
 
-    if not options.engine_uri:
+def command_load(parser, args):
+    if not args.engine_uri:
         print("WARNING: You're reloading the default database, but not the lookup index.  They")
         print("         might get out of sync, and pokedex commands may not work correctly!")
         print("To fix this, run `pokedex reindex` when this command finishes.  Or, just use")
         print("`pokedex setup` to do both at once.")
         print()
 
-    if options.langs == 'none':
+    if args.langs == 'none':
         langs = []
-    elif options.langs is None:
+    elif args.langs is None:
         langs = None
     else:
-        langs = [l.strip() for l in options.langs.split(',')]
+        langs = [l.strip() for l in args.langs.split(',')]
 
-    session = get_session(options)
-    get_csv_directory(options)
+    session = get_session(args)
+    get_csv_directory(args)
 
-    pokedex.db.load.load(session, directory=options.directory,
-                                  drop_tables=options.drop_tables,
-                                  tables=tables,
-                                  verbose=options.verbose,
-                                  safe=options.safe,
-                                  recursive=options.recursive,
-                                  langs=langs)
+    pokedex.db.load.load(
+        session,
+        directory=args.directory,
+        drop_tables=args.drop_tables,
+        tables=args.tables,
+        verbose=args.verbose,
+        safe=args.safe,
+        recursive=args.recursive,
+        langs=langs,
+    )
 
-def command_reindex(*args):
-    parser = get_parser(verbose=True)
-    options, _ = parser.parse_args(list(args))
-
-    session = get_session(options)
-    lookup = get_lookup(options, session=session, recreate=True)
 
+def command_reindex(parser, args):
+    session = get_session(args)
+    get_lookup(args, session=session, recreate=True)
     print("Recreated lookup index.")
 
 
-def command_setup(*args):
-    parser = get_parser(verbose=False)
-    options, _ = parser.parse_args(list(args))
+def command_setup(parser, args):
+    args.directory = None
 
-    options.directory = None
-
-    session = get_session(options)
-    get_csv_directory(options)
-    pokedex.db.load.load(session, directory=None, drop_tables=True,
-                                  verbose=options.verbose,
-                                  safe=False)
-
-    lookup = get_lookup(options, session=session, recreate=True)
+    session = get_session(args)
+    get_csv_directory(args)
+    pokedex.db.load.load(
+        session, directory=None, drop_tables=True,
+        verbose=args.verbose, safe=False)
 
+    get_lookup(args, session=session, recreate=True)
     print("Recreated lookup index.")
 
 
-def command_status(*args):
-    parser = get_parser(verbose=True)
-    options, _ = parser.parse_args(list(args))
-    options.verbose = True
-    options.directory = None
+def command_status(parser, args):
+    args.directory = None
 
     # Database, and a lame check for whether it's been inited at least once
-    session = get_session(options)
+    session = get_session(args)
     print("  - OK!  Connected successfully.")
 
     if pokedex.db.tables.Pokemon.__table__.exists(session.bind):
@@ -211,7 +282,7 @@ def command_status(*args):
         print("  - WARNING: Database appears to be empty.")
 
     # CSV; simple checks that the dir exists
-    csvdir = get_csv_directory(options)
+    csvdir = get_csv_directory(args)
     if not os.path.exists(csvdir):
         print("  - ERROR: No such directory!")
     elif not os.path.isdir(csvdir):
@@ -232,20 +303,17 @@ def command_status(*args):
 
     # Index; the PokedexLookup constructor covers most tests and will
     # cheerfully bomb if they fail
-    lookup = get_lookup(options, recreate=False)
+    get_lookup(args, recreate=False)
     print("  - OK!  Opened successfully.")
 
 
 ### User-facing commands
 
-def command_lookup(*args):
-    parser = get_parser(verbose=False)
-    options, words = parser.parse_args(list(args))
+def command_lookup(parser, args):
+    name = u' '.join(args.criteria)
 
-    name = u' '.join(words)
-
-    session = get_session(options)
-    lookup = get_lookup(options, session=session, recreate=False)
+    session = get_session(args)
+    lookup = get_lookup(args, session=session, recreate=False)
 
     results = lookup.lookup(name)
     if not results:
@@ -268,62 +336,8 @@ def command_lookup(*args):
             print()
 
 
-def command_help():
-    print(u"""pokedex -- a command-line Pokédex interface
-usage: pokedex {command} [options...]
-Run `pokedex setup` first, or nothing will work!
-See https://github.com/veekun/pokedex/wiki/CLI for more documentation.
-
-Commands:
-    help                Displays this message.
-    lookup [thing]      Look up something in the Pokédex.
-
-System commands:
-    load                Load Pokédex data into a database from CSV files.
-    dump                Dump Pokédex data from a database into CSV files.
-    reindex             Rebuilds the lookup index from the database.
-    setup               Combines load and reindex.
-    status              No effect, but prints which engine, index, and csv
-                        directory would be used for other commands.
-
-Global options:
-    -e|--engine=URI     By default, all commands try to use a SQLite database
-                        in the pokedex install directory.  Use this option (or
-                        a POKEDEX_DB_ENGINE environment variable) to specify an
-                        alternate database.
-    -i|--index=DIR      By default, all commands try to put the lookup index in
-                        the pokedex install directory.  Use this option (or a
-                        POKEDEX_INDEX_DIR environment variable) to specify an
-                        alternate loction.
-    -q|--quiet          Don't print system output.  This is the default for
-                        non-system commands and setup.
-    -v|--verbose        Print system output.  This is the default for system
-                        commands, except setup.
-
-System options:
-    -d|--directory=DIR  By default, load and dump will use the CSV files in the
-                        pokedex install directory.  Use this option to specify
-                        a different directory.
-
-Load options:
-    -D|--drop-tables    Drop all tables before loading data.
-    -S|--safe           Disable engine-specific optimizations.
-    -r|--recursive      Load (and drop) all dependent tables.
-    -l|--langs          Load translations for the given languages.
-                        By default, all available translations are loaded.
-                        Separate multiple languages by a comma (-l en,de,fr)
-
-Dump options:
-    -l|--langs          Dump unofficial texts for given languages.
-                        By default, English (en) is dumped.
-                        Separate multiple languages by a comma (-l en,de,fr)
-                        Use 'none' to not dump any unofficial texts.
-
-    Additionally, load and dump accept a list of table names (possibly with
-    wildcards) and/or csv fileames as an argument list.
-""".encode(sys.getdefaultencoding(), 'replace'))
-
-    sys.exit(0)
+def command_help(parser, args):
+    parser.print_help()
 
 
 if __name__ == '__main__':
diff --git a/pokedex/search.py b/pokedex/search.py
new file mode 100644
index 0000000..fa5a04a
--- /dev/null
+++ b/pokedex/search.py
@@ -0,0 +1,65 @@
+import re
+
+from sqlalchemy import func
+from sqlalchemy.orm import joinedload
+
+import pokedex.db.tables as t
+
+
+def _parse_range(value):
+    v = int(value)
+    return lambda x: x == v
+
+
+CRITERION_RX = re.compile(r"""
+    \s*
+    (?: (?P<field>[-_a-zA-Z0-9]+): )?
+    (?P<pattern>
+        (?:
+            [^\s"]+?
+        )+
+    )
+""", re.VERBOSE)
+def parse_search_string(string):
+    """Parses a search string!"""
+    criteria = {}
+    for match in CRITERION_RX.finditer(string):
+        # TODO what if there are several of the same match!
+        # TODO the cli needs to do append too
+        field = match.group('field') or '*'
+        criteria[field] = match.group('pattern')
+    return criteria
+
+
+def search(session, **criteria):
+    query = (
+        session.query(t.Pokemon)
+        .options(
+            joinedload(t.Pokemon.species)
+        )
+    )
+
+    stat_query = (
+        session.query(t.PokemonStat.pokemon_id)
+        .join(t.PokemonStat.stat)
+    )
+    do_stat = False
+
+    if criteria.get('name') is not None:
+        query = query.filter(t.Pokemon.species.has(func.lower(t.PokemonSpecies.name) == criteria['name'].lower()))
+
+    for stat_ident in (u'attack', u'defense', u'special-attack', u'special-defense', u'speed', u'hp'):
+        criterion = criteria.get(stat_ident)
+        if criterion is None:
+            continue
+
+        do_stat = True
+        stat_query = stat_query.filter(
+            (t.Stat.identifier == stat_ident)
+            & _parse_range(criterion)(t.PokemonStat.base_stat)
+        )
+
+    if do_stat:
+        query = query.filter(t.Pokemon.id.in_(stat_query.subquery()))
+
+    return query.all()