Browse Source

`mmgen-tool`: improve usage screens for individual commands

Example:

    $ mmgen-tool help listaddresses

Testing/demo:

    $ test/test.py -e tool_cmd_usage
The MMGen Project 2 years ago
parent
commit
d07d665f35
9 changed files with 220 additions and 75 deletions
  1. 1 1
      mmgen/data/version
  2. 18 5
      mmgen/main_tool.py
  3. 4 2
      mmgen/tool/file.py
  4. 22 1
      mmgen/tool/fileutil.py
  5. 49 18
      mmgen/tool/help.py
  6. 9 3
      mmgen/tool/mnemonic.py
  7. 32 29
      mmgen/tool/rpc.py
  8. 71 16
      mmgen/tool/util.py
  9. 14 0
      test/test_py_d/ts_misc.py

+ 1 - 1
mmgen/data/version

@@ -1 +1 @@
-13.2.dev12
+13.2.dev13

+ 18 - 5
mmgen/main_tool.py

@@ -36,6 +36,7 @@ opts_data = {
 -k, --use-internal-keccak-module Force use of the internal keccak module
 -K, --keygen-backend=n Use backend 'n' for public key generation.  Options
                        for {coin_id}: {kgs}
+-l, --list             List available commands
 -p, --hash-preset= p   Use the scrypt hash parameters defined by preset 'p'
                        for password hashing (default: '{g.dfl_hash_preset}')
 -P, --passwd-file= f   Get passphrase from file 'f'.
@@ -194,11 +195,10 @@ def create_call_sig(cmd,cls,as_string=False):
 		get_type_from_ann = lambda x: 'str or STDIN' if ann[x] == 'sstr' else ann[x].__name__
 		return ' '.join(
 			[f'{a} [{get_type_from_ann(a)}]' for a in args[:nargs]] +
-			['{a} [{b}={c!r}{d}]'.format(
+			['{a} [{b}={c!r}]'.format(
 				a = a,
 				b = dfl_types[n].__name__,
-				c = dfls[n],
-				d = (' ' + ann[a] if a in ann and isinstance(ann[a],str) else ''))
+				c = dfls[n] )
 					for n,a in enumerate(args[nargs:])] )
 	else:
 		get_type_from_ann = lambda x: 'str' if ann[x] == 'sstr' else ann[x].__name__
@@ -206,10 +206,11 @@ def create_call_sig(cmd,cls,as_string=False):
 			[(a,get_type_from_ann(a)) for a in args[:nargs]],            # c_args
 			dict([(a,dfls[n]) for n,a in enumerate(args[nargs:])]),      # c_kwargs
 			dict([(a,dfl_types[n]) for n,a in enumerate(args[nargs:])]), # c_kwargs_types
-			('STDIN_OK' if nargs and ann[args[0]] == 'sstr' else flag) ) # flag
+			('STDIN_OK' if nargs and ann[args[0]] == 'sstr' else flag),  # flag
+			ann )                                                        # ann
 
 def process_args(cmd,cmd_args,cls):
-	c_args,c_kwargs,c_kwargs_types,flag = create_call_sig(cmd,cls)
+	c_args,c_kwargs,c_kwargs_types,flag,ann = create_call_sig(cmd,cls)
 	have_stdin_input = False
 
 	def usage_die(s):
@@ -339,6 +340,18 @@ if g.prog_name == 'mmgen-tool' and not opt._lock:
 
 	po = opts.init( opts_data, parse_only=True )
 
+	if po.user_opts.get('list'):
+		def gen():
+			for mod,cmdlist in mods.items():
+				if mod == 'help':
+					continue
+				yield capfirst( get_mod_cls(mod).__doc__.lstrip().split('\n')[0] ) + ':'
+				for cmd in cmdlist:
+					yield '  ' + cmd
+				yield ''
+		Msg('\n'.join(gen()).rstrip())
+		sys.exit(0)
+
 	if len(po.cmd_args) < 1:
 		opts.usage()
 

+ 4 - 2
mmgen/tool/file.py

@@ -78,8 +78,10 @@ class tool_cmd(tool_cmd_base):
 				'dfls': ( False, False, 'addr', 'mtime' ),
 				'annots': {
 					'mmgen_tx_file(s)': str,
-					'sort':     options_annot_str(['addr','raw']),
-					'filesort': options_annot_str(['mtime','ctime','atime']),
+					'pager': 'send output to pager',
+					'terse': 'produce compact tabular output',
+					'sort':  'sort order for transaction inputs and outputs ' + options_annot_str(['addr','raw']),
+					'filesort': 'file sort order ' + options_annot_str(['mtime','ctime','atime']),
 				}
 			},
 			*infiles,

+ 22 - 1
mmgen/tool/fileutil.py

@@ -29,7 +29,10 @@ from ..crypto import get_random,aesctr_iv_len
 class tool_cmd(tool_cmd_base):
 	"file utilities"
 
-	def find_incog_data(self,filename:str,incog_id:str,keep_searching=False):
+	def find_incog_data(self,
+			filename: str,
+			incog_id: str,
+			keep_searching: 'continue search after finding data (ID collisions can yield false positives)' = False):
 		"Use an Incog ID to find hidden incognito wallet data"
 
 		from hashlib import sha256
@@ -67,6 +70,24 @@ class tool_cmd(tool_cmd_base):
 	def rand2file(self,outfile:str,nbytes:str,threads=4,silent=False):
 		"""
 		write ‘nbytes’ bytes of random data to specified file (dd-style byte specifiers supported)
+
+		Valid specifiers:
+
+		  c  = 1
+		  w  = 2
+		  b  = 512
+		  kB = 1000
+		  K  = 1024
+		  MB = 1000000
+		  M  = 1048576
+		  GB = 1000000000
+		  G  = 1073741824
+		  TB = 1000000000000
+		  T  = 1099511627776
+		  PB = 1000000000000000
+		  P  = 1125899906842624
+		  EB = 1000000000000000000
+		  E  = 1152921504606846976
 		"""
 		from threading import Thread
 		from queue import Queue

+ 49 - 18
mmgen/tool/help.py

@@ -25,23 +25,20 @@ import mmgen.main_tool as main_tool
 
 def main_help():
 
-	from ..util import pretty_format
+	from ..util import pretty_format,capfirst
 
 	def do():
 		for clsname,cmdlist in main_tool.mods.items():
 			cls = main_tool.get_mod_cls(clsname)
-			cls_doc = cls.__doc__.strip().split('\n')
-			for l in cls_doc:
-				if l is cls_doc[0]:
-					l += ':'
-				l = l.replace('\t','',1)
-				if l:
-					l = l.replace('\t','  ')
-					yield l[0].upper() + l[1:]
-				else:
-					yield ''
+			cls_docstr = cls.__doc__.strip()
+			yield capfirst(cls_docstr.split('\n')[0].strip()) + ':'
 			yield ''
 
+			if '\n' in cls_docstr:
+				for line in cls_docstr.split('\n')[2:]:
+					yield '  ' + line.lstrip('\t')
+				yield ''
+
 			max_w = max(map(len,cmdlist))
 
 			for cmdname in cmdlist:
@@ -64,7 +61,7 @@ def gen_tool_usage():
 	from ..util import capfirst
 
 	m1 = """
-		USAGE INFORMATION FOR MMGEN-TOOL COMMANDS:
+		GENERAL USAGE INFORMATION FOR MMGEN-TOOL COMMANDS
 
 		  Arguments with only type specified in square brackets are required
 
@@ -140,15 +137,49 @@ def gen_tool_cmd_usage(mod,cmdname):
 
 	cls = main_tool.get_mod_cls(mod)
 	docstr = getattr(cls,cmdname).__doc__.strip()
-	args,kwargs,kwargs_types,flag = main_tool.create_call_sig(cmdname,cls)
+	args,kwargs,kwargs_types,flag,ann = main_tool.create_call_sig(cmdname,cls)
+	ARGS = 'ARG' if len(args) == 1 else 'ARGS' if args else ''
+	KWARGS = 'KEYWORD ARG' if len(kwargs) == 1 else 'KEYWORD ARGS' if kwargs else ''
 
-	yield '{a}\n\nUSAGE: {b} {c} {d}{e}'.format(
-		a = capfirst( docstr.split('\n')[0].strip() ),
+	yield capfirst( docstr.split('\n')[0].strip() )
+	yield ''
+	yield 'USAGE: {b} [OPTS] {c}{d}{e}'.format(
 		b = g.prog_name,
 		c = cmdname,
-		d = main_tool.create_call_sig(cmdname,cls,as_string=True),
-		e = '\n\n' + fmt('\n'.join(docstr.split('\n')[1:]),strip_char='\t').rstrip()
-			if '\n' in docstr else '' )
+		d = f' {ARGS}' if ARGS else '',
+		e = f' [{KWARGS}]' if KWARGS else '' )
+
+	if args:
+		max_w = max(len(k[0]) for k in args)
+		yield ''
+		yield f'Required {ARGS} (type shown in square brackets):'
+		yield ''
+		for argname,argtype in args:
+			have_sstr = ann.get(argname) == 'sstr'
+			yield '  {a:{w}} [{b}]{c}{d}'.format(
+				a = argname,
+				b = argtype,
+				c = " (use '-' to read from STDIN)" if have_sstr else '',
+				d = ' ' + ann[argname] if isinstance(ann.get(argname),str) and not have_sstr else '',
+				w = max_w )
+
+	if kwargs:
+		max_w = max(len(k) for k in kwargs)
+		max_w2 = max(len(kwargs_types[k].__name__) + len(repr(kwargs[k])) for k in kwargs) + 3
+		yield ''
+		yield f'Optional {KWARGS} (type and default value shown in square brackets):'
+		yield ''
+		for argname in kwargs:
+			yield '  {a:{w}} {b:{w2}} {c}'.format(
+				a = argname,
+				b = '[{}={}]'.format( kwargs_types[argname].__name__, repr(kwargs[argname]) ),
+				c = capfirst(ann[argname]) if isinstance(ann.get(argname),str) else '',
+				w = max_w,
+				w2 = max_w2 ).rstrip()
+
+	if '\n' in docstr:
+		for line in docstr.split('\n')[1:]:
+			yield line.lstrip('\t')
 
 def usage(cmdname=None,exit_val=1):
 

+ 9 - 3
mmgen/tool/mnemonic.py

@@ -35,7 +35,7 @@ mnemonic_fmts = {
 	'bip39':   mft( 'bip39',   None,  bip39 ),
 	'xmrseed': mft( 'xmrseed', None,  xmrseed ),
 }
-mn_opts_disp = options_annot_str(mnemonic_fmts)
+mn_opts_disp = 'seed phrase format ' + options_annot_str(mnemonic_fmts)
 
 class tool_cmd(tool_cmd_base):
 	"""
@@ -106,7 +106,10 @@ class tool_cmd(tool_cmd_base):
 		f = mnemonic_fmts[fmt]
 		return f.conv_cls(fmt).tohex( seed_mnemonic.split(), f.pad )
 
-	def mn2hex_interactive( self, fmt:mn_opts_disp = dfl_mnemonic_fmt, mn_len=24, print_mn=False ):
+	def mn2hex_interactive( self,
+			fmt: mn_opts_disp = dfl_mnemonic_fmt,
+			mn_len: 'length of seed phrase in words' = 24,
+			print_mn: 'print the seed phrase after entry' = False ):
 		"convert an interactively supplied mnemonic seed phrase to a hexadecimal string"
 		from ..mn_entry import mn_entry
 		mn = mn_entry(fmt).get_mnemonic_from_user(25 if fmt == 'xmrseed' else mn_len,validate=False)
@@ -119,7 +122,10 @@ class tool_cmd(tool_cmd_base):
 		"show stats for a mnemonic wordlist"
 		return mnemonic_fmts[fmt].conv_cls(fmt).check_wordlist()
 
-	def mn_printlist( self, fmt:mn_opts_disp = dfl_mnemonic_fmt, enum=False, pager=False ):
+	def mn_printlist(self,
+			fmt: mn_opts_disp = dfl_mnemonic_fmt,
+			enum: 'enumerate the list' = False,
+			pager: 'send output to pager' = False ):
 		"print a mnemonic wordlist"
 		ret = mnemonic_fmts[fmt].conv_cls(fmt).get_wordlist()
 		if enum:

+ 32 - 29
mmgen/tool/rpc.py

@@ -22,6 +22,7 @@ tool/rpc.py: JSON/RPC routines for the 'mmgen-tool' utility
 
 from .common import tool_cmd_base,options_annot_str
 from ..tw.common import TwCommon
+from ..tw.txhistory import TwTxHistory
 
 class tool_cmd(tool_cmd_base):
 	"tracking-wallet commands using the JSON-RPC interface"
@@ -35,16 +36,19 @@ class tool_cmd(tool_cmd_base):
 		r = await rpc_init( self.proto, ignore_daemon_version=True )
 		return f'{r.daemon.coind_name} version {r.daemon_version} ({r.daemon_version_str})'
 
-	async def getbalance(self,minconf=1,quiet=False,pager=False):
+	async def getbalance(self,
+			minconf: 'minimum number of confirmations' = 1,
+			quiet:   'produce quieter output' = False,
+			pager:   'send output to pager' = False ):
 		"list confirmed/unconfirmed, spendable/unspendable balances in tracking wallet"
 		from ..tw.bal import TwGetBalance
 		return (await TwGetBalance(self.proto,minconf,quiet)).format()
 
 	async def listaddress(self,
 			mmgen_addr:str,
-			minconf     = 1,
-			showbtcaddr = True,
-			age_fmt: options_annot_str(TwCommon.age_fmts) = 'confs' ):
+			minconf:     'minimum number of confirmations' = 1,
+			showbtcaddr: 'display coin address in addition to MMGen ID' = True,
+			age_fmt:     'format for the Age/Date column ' + options_annot_str(TwCommon.age_fmts) = 'confs' ):
 		"list the specified MMGen address in the tracking wallet and its balance"
 
 		return await self.listaddresses(
@@ -54,15 +58,15 @@ class tool_cmd(tool_cmd_base):
 			age_fmt      = age_fmt )
 
 	async def listaddresses(self,
-			mmgen_addrs:'(range or list)' = '',
-			minconf      = 1,
-			showempty    = False,
-			pager        = False,
-			showbtcaddrs = True,
-			all_labels   = False,
-			sort: options_annot_str(['reverse','age']) = '',
-			age_fmt: options_annot_str(TwCommon.age_fmts) = 'confs' ):
-		"list MMGen addresses and their balances"
+			mmgen_addrs: 'hyphenated range or comma-separated list of addresses' = '',
+			minconf:     'minimum number of confirmations' = 1,
+			pager:       'send output to pager' = False,
+			showbtcaddr: 'display coin addresses in addition to MMGen IDs' = True,
+			showempty:   'show addresses with no balances' = True,
+			all_labels:  'show all addresses with labels' = False,
+			age_fmt:     'format for the Age/Date column ' + options_annot_str(TwCommon.age_fmts) = 'confs',
+			sort:        'address sort order ' + options_annot_str(['reverse','age']) = '' ):
+		"list MMGen addresses in the tracking wallet and their balances"
 
 		show_age = bool(age_fmt)
 
@@ -111,14 +115,14 @@ class tool_cmd(tool_cmd_base):
 			return await obj.format_squeezed()
 
 	async def twview(self,
-			pager           = False,
-			reverse         = False,
-			wide            = False,
-			minconf         = 1,
-			sort            = 'age',
-			age_fmt: options_annot_str(TwCommon.age_fmts) = 'confs',
-			interactive     = False,
-			show_mmid       = True ):
+			pager:       'send output to pager' = False,
+			reverse:     'reverse order of unspent outputs' = False,
+			wide:        'display data in wide tabular format' = False,
+			minconf:     'minimum number of confirmations' = 1,
+			sort:        'unspent output sort order ' + options_annot_str(TwCommon.sort_funcs) = 'age',
+			age_fmt:     'format for the Age/Date column ' + options_annot_str(TwCommon.age_fmts) = 'confs',
+			interactive: 'enable interactive operation' = False,
+			show_mmid:   'show MMGen IDs along with coin addresses' = True ):
 		"view tracking wallet unspent outputs"
 
 		from ..tw.unspent import TwUnspentOutputs
@@ -129,16 +133,15 @@ class tool_cmd(tool_cmd_base):
 		return ret
 
 	async def txhist(self,
-			pager           = False,
-			reverse         = False,
-			detail          = False,
-			sinceblock      = 0,
-			sort            = 'age',
-			age_fmt: options_annot_str(TwCommon.age_fmts) = 'confs',
-			interactive     = False ):
+			pager:       'send output to pager' = False,
+			reverse:     'reverse order of transactions' = False,
+			detail:      'produce detailed, non-tabular output' = False,
+			sinceblock:  'display transactions starting from this block' = 0,
+			sort:        'transaction sort order ' + options_annot_str(TwTxHistory.sort_funcs) = 'age',
+			age_fmt:     'format for the Age/Date column ' + options_annot_str(TwCommon.age_fmts) = 'confs',
+			interactive: 'enable interactive operation' = False ):
 		"view transaction history of tracking wallet"
 
-		from ..tw.txhistory import TwTxHistory
 		obj = await TwTxHistory(self.proto,sinceblock=sinceblock)
 		return await self.twops(
 			obj,pager,reverse,detail,sort,age_fmt,interactive,show_mmid=None)

+ 71 - 16
mmgen/tool/util.py

@@ -25,21 +25,64 @@ from .common import tool_cmd_base
 class tool_cmd(tool_cmd_base):
 	"general string conversion and hashing utilities"
 
+	# mmgen.util.bytespec_map
 	def bytespec(self,dd_style_byte_specifier:str):
-		"convert a byte specifier such as '1GB' into an integer"
+		"""
+		convert a byte specifier such as ‘4GB’ into an integer
+
+		Valid specifiers:
+
+		  c  = 1
+		  w  = 2
+		  b  = 512
+		  kB = 1000
+		  K  = 1024
+		  MB = 1000000
+		  M  = 1048576
+		  GB = 1000000000
+		  G  = 1073741824
+		  TB = 1000000000000
+		  T  = 1099511627776
+		  PB = 1000000000000000
+		  P  = 1125899906842624
+		  EB = 1000000000000000000
+		  E  = 1152921504606846976
+		"""
 		from ..util import parse_bytespec
 		return parse_bytespec(dd_style_byte_specifier)
 
+	# mmgen.util.bytespec_map
 	def to_bytespec(self,
 			n: int,
 			dd_style_byte_specifier: str,
-			fmt = '0.2',
-			print_sym = True ):
-		"convert an integer to a byte specifier such as '1GB'"
+			fmt:       'width and precision of output' = '0.2',
+			print_sym: 'print the specifier after the numerical value' = True ):
+		"""
+		convert an integer to a byte specifier such as ‘4GB’
+
+		Supported specifiers:
+
+		  c  = 1
+		  w  = 2
+		  b  = 512
+		  kB = 1000
+		  K  = 1024
+		  MB = 1000000
+		  M  = 1048576
+		  GB = 1000000000
+		  G  = 1073741824
+		  TB = 1000000000000
+		  T  = 1099511627776
+		  PB = 1000000000000000
+		  P  = 1125899906842624
+		  EB = 1000000000000000000
+		  E  = 1152921504606846976
+		"""
 		from ..util import int2bytespec
 		return int2bytespec( n, dd_style_byte_specifier, fmt, print_sym )
 
-	def randhex(self,nbytes=32):
+	def randhex(self,
+			nbytes: 'number of bytes to output' = 32 ):
 		"print 'n' bytes (default 32) of random data in hex format"
 		from ..crypto import get_random
 		return get_random( nbytes ).hex()
@@ -58,7 +101,10 @@ class tool_cmd(tool_cmd_base):
 		"convert a hexadecimal string to bytes (warning: outputs binary data)"
 		return bytes.fromhex(hexstr)
 
-	def hexdump(self,infile:str,cols=8,line_nums='hex'):
+	def hexdump(self,
+			infile: str,
+			cols:      'number of columns in output' = 8,
+			line_nums: "format for line numbers (valid choices: 'hex','dec')" = 'hex'):
 		"create hexdump of data from file (use '-' for stdin)"
 		from ..fileutil import get_data_from_file
 		from ..util import pretty_hexdump
@@ -81,7 +127,11 @@ class tool_cmd(tool_cmd_base):
 		from ..proto.common import hash160
 		return hash160( bytes.fromhex(hexstr) ).hex()
 
-	def hash256(self,data:str,file_input=False,hex_input=False): # TODO: handle stdin
+	# TODO: handle stdin
+	def hash256(self,
+			data: str,
+			file_input: 'first arg is the name of a file containing the data' = False,
+			hex_input:  'first arg is a hexadecimal string' = False ):
 		"compute sha256(sha256(data)) (double sha256)"
 		from hashlib import sha256
 		if file_input:
@@ -113,30 +163,32 @@ class tool_cmd(tool_cmd_base):
 		return make_chksum_8(
 			get_data_from_file( infile, dash=True, quiet=True, binary=True ))
 
-	def randb58(self,nbytes=32,pad=0):
+	def randb58(self,
+			nbytes: 'number of bytes to output' = 32,
+			pad:    'pad output to this width' = 0 ):
 		"generate random data (default: 32 bytes) and convert it to base 58"
 		from ..crypto import get_random
 		from ..baseconv import baseconv
 		return baseconv('b58').frombytes( get_random(nbytes), pad=pad, tostr=True )
 
-	def bytestob58(self,infile:str,pad=0):
+	def bytestob58(self,infile:str,pad: 'pad output to this width' = 0):
 		"convert bytes to base 58 (supply data via STDIN)"
 		from ..fileutil import get_data_from_file
 		from ..baseconv import baseconv
 		data = get_data_from_file( infile, dash=True, quiet=True, binary=True )
 		return baseconv('b58').frombytes( data, pad=pad, tostr=True )
 
-	def b58tobytes(self,b58_str:'sstr',pad=0):
+	def b58tobytes(self,b58_str:'sstr',pad: 'pad output to this width' = 0):
 		"convert a base 58 string to bytes (warning: outputs binary data)"
 		from ..baseconv import baseconv
 		return baseconv('b58').tobytes( b58_str, pad=pad )
 
-	def hextob58(self,hexstr:'sstr',pad=0):
+	def hextob58(self,hexstr:'sstr',pad: 'pad output to this width' = 0):
 		"convert a hexadecimal string to base 58"
 		from ..baseconv import baseconv
 		return baseconv('b58').fromhex( hexstr, pad=pad, tostr=True )
 
-	def b58tohex(self,b58_str:'sstr',pad=0):
+	def b58tohex(self,b58_str:'sstr',pad: 'pad output to this width' = 0):
 		"convert a base 58 string to hexadecimal"
 		from ..baseconv import baseconv
 		return baseconv('b58').tohex( b58_str, pad=pad )
@@ -151,24 +203,27 @@ class tool_cmd(tool_cmd_base):
 		from ..proto.common import b58chk_decode
 		return b58chk_decode(b58chk_str).hex()
 
-	def hextob32(self,hexstr:'sstr',pad=0):
+	def hextob32(self,hexstr:'sstr',pad: 'pad output to this width' = 0):
 		"convert a hexadecimal string to an MMGen-flavor base 32 string"
 		from ..baseconv import baseconv
 		return baseconv('b32').fromhex( hexstr, pad, tostr=True )
 
-	def b32tohex(self,b32_str:'sstr',pad=0):
+	def b32tohex(self,b32_str:'sstr',pad: 'pad output to this width' = 0):
 		"convert an MMGen-flavor base 32 string to hexadecimal"
 		from ..baseconv import baseconv
 		return baseconv('b32').tohex( b32_str.upper(), pad )
 
-	def hextob6d(self,hexstr:'sstr',pad=0,add_spaces=True):
+	def hextob6d(self,
+			hexstr:'sstr',
+			pad: 'pad output to this width' = 0,
+			add_spaces: 'add a space after every 5th character' = True):
 		"convert a hexadecimal string to die roll base6 (base6d)"
 		from ..baseconv import baseconv
 		from ..util import block_format
 		ret = baseconv('b6d').fromhex(hexstr,pad,tostr=True)
 		return block_format( ret, gw=5, cols=None ).strip() if add_spaces else ret
 
-	def b6dtohex(self,b6d_str:'sstr',pad=0):
+	def b6dtohex(self,b6d_str:'sstr',pad: 'pad output to this width' = 0):
 		"convert a die roll base6 (base6d) string to hexadecimal"
 		from ..baseconv import baseconv
 		from ..util import remove_whitespace

+ 14 - 0
test/test_py_d/ts_misc.py

@@ -54,6 +54,7 @@ class TestSuiteHelp(TestSuiteBase):
 		('longhelpscreens',       (1,'help screens (--longhelp)',[])),
 		('show_hash_presets',     (1,'info screen (--show-hash-presets)',[])),
 		('tool_help',             (1,"'mmgen-tool' usage screen",[])),
+		('tool_cmd_usage',        (1,"'mmgen-tool' usage screen",[])),
 		('test_help',             (1,"'test.py' help screens",[])),
 	)
 
@@ -114,6 +115,19 @@ class TestSuiteHelp(TestSuiteBase):
 			t = self.spawn_chk('mmgen-tool',args,extra_desc=f"('mmgen-tool {fmt_list(args,fmt='bare')}')")
 		return t
 
+	def tool_cmd_usage(self):
+
+		if os.getenv('PYTHONOPTIMIZE') == '2':
+			ymsg('Skipping tool cmd usage with PYTHONOPTIMIZE=2 (no docstrings)')
+			return 'skip'
+
+		from mmgen.main_tool import mods
+
+		for cmdlist in mods.values():
+			for cmd in cmdlist:
+				t = self.spawn_chk( 'mmgen-tool', ['help',cmd], extra_desc=f'({cmd})' )
+		return t
+
 	def test_help(self):
 		for args in (
 			['--help'],