diff --git a/docs/cli.md b/docs/cli.md index 29c67ecf82..f576bd73d9 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -524,6 +524,29 @@ Use this to learn about command-specific options, refresh your memory about synt $ nextflow help run ``` +(cli-machine-readable-help)= + +### Machine-readable help + +:::{versionadded} 26.06.0-edge +::: + +The `-help-json` option prints the help and options for a command as JSON instead of the human-formatted help text. This provides a stable, structured description of the CLI for tools and AI agents to consume, without scraping the rendered `-help` output. + +The option is contextual, so you can discover the CLI one command at a time. At the top level, it lists the global options and an index of every available command: + +```console +$ nextflow -help-json +``` + +Add it to any command to get that command's full options and arguments: + +```console +$ nextflow run -help-json +``` + +The output describes each option with its name, flags, type, help text, and default value (where applicable). The schema is derived directly from the CLI definitions, so it stays in sync with the available commands and options. + ### Version information The `-v` and `-version` options print Nextflow version information. diff --git a/docs/reference/cli.md b/docs/reference/cli.md index bb9895f0d8..363028bb91 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -33,6 +33,12 @@ Available options: `-h` : Print available commands and options. +`-help-json` +: :::{versionadded} 26.06.0-edge + ::: +: Print the help and options as JSON, for consumption by tools and LLMs (see {ref}`cli-machine-readable-help`). +: Works as a top-level option (`nextflow -help-json`) and on any command (e.g. `nextflow run -help-json`). + `-log` : Set Nextflow log file path (default: `.nextflow.log`). Must be a local path. diff --git a/modules/nextflow/src/main/groovy/nextflow/cli/CliOptions.groovy b/modules/nextflow/src/main/groovy/nextflow/cli/CliOptions.groovy index 66988dc367..abaf10af89 100644 --- a/modules/nextflow/src/main/groovy/nextflow/cli/CliOptions.groovy +++ b/modules/nextflow/src/main/groovy/nextflow/cli/CliOptions.groovy @@ -75,6 +75,9 @@ class CliOptions { @Parameter(names = ['-h'], description = 'Print this help', help = true) boolean help + @Parameter(names = ['-help-json'], description = 'Print the command help and options as JSON (for tools and LLMs)', help = true) + boolean helpJson + @Parameter(names = ['-q','-quiet'], description = 'Do not print information messages' ) boolean quiet diff --git a/modules/nextflow/src/main/groovy/nextflow/cli/CliSchema.groovy b/modules/nextflow/src/main/groovy/nextflow/cli/CliSchema.groovy new file mode 100644 index 0000000000..dbfdb5e270 --- /dev/null +++ b/modules/nextflow/src/main/groovy/nextflow/cli/CliSchema.groovy @@ -0,0 +1,237 @@ +/* + * Copyright 2013-2026, Seqera Labs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package nextflow.cli + +import java.lang.reflect.Field + +import com.beust.jcommander.DynamicParameter +import com.beust.jcommander.Parameter +import com.beust.jcommander.Parameters +import groovy.json.JsonOutput +import groovy.transform.CompileDynamic +import groovy.transform.CompileStatic + +/** + * Build a machine-readable (JSON) description of the Nextflow command line + * interface by introspecting the JCommander {@link Parameter} / {@link Parameters} + * annotations. + * + * This powers the global {@code -help-json} flag, which lets tools and LLMs + * discover the CLI one command at a time without scraping the rendered + * {@code -help} text: + * + *
+ *   nextflow -help-json            # global options + index of all commands
+ *   nextflow run -help-json        # full option/argument detail for `run`
+ * 
+ * + * @author Phil Ewels + */ +@CompileStatic +class CliSchema { + + /** + * Build the schema for the top-level program: the global options plus a + * name + description index of every available command. + */ + static String root(CliOptions options, List commands) { + final schema = [ + name : 'nextflow', + path : 'nextflow', + usage : 'nextflow [options] COMMAND [arg...]', + params : paramsOf(CliOptions), + subcommands : commandIndex(commands), + ] + return render(schema) + } + + /** + * Build the schema for a single command: its usage, options and positional + * arguments. + */ + static String command(CmdBase cmd) { + final clazz = cmd.getClass() + final params = paramsOf(clazz) + final schema = [ + name : cmd.name, + path : "nextflow ${cmd.name}".toString(), + usage : usageOf(cmd, params), + params : params, + ] as Map + + final description = descriptionOf(clazz) + if( description ) + schema.help = description + final aliases = aliasesOf(clazz) + if( aliases ) + schema.aliases = aliases + + return render(schema) + } + + /** + * Map each command name to its description and aliases. Nextflow commands + * are flat (no nested groups), so a single index lists them all; drill into + * any one with {@code nextflow -help-json}. + */ + @CompileDynamic + private static Map commandIndex(List commands) { + final index = new TreeMap() + for( CmdBase cmd : commands ) { + final clazz = cmd.getClass() + final description = descriptionOf(clazz) + // only advertise commands that opt in with a description, matching `-help` + if( !description ) + continue + final entry = [help: description] as Map + final aliases = aliasesOf(clazz) + if( aliases ) + entry.aliases = aliases + index[cmd.name] = entry + } + return index + } + + /** + * Introspect the {@link Parameter} / {@link DynamicParameter} annotations of + * a command (or options) class into a list of JSON-friendly maps. Inherited + * fields are included; hidden and meta options ({@code -h}, {@code -help}, + * {@code -help-json}) are skipped. + */ + @CompileDynamic + private static List> paramsOf(Class clazz) { + final instance = newInstance(clazz) + final result = [] + for( Field field : declaredFields(clazz) ) { + final p = field.getAnnotation(Parameter) + final dp = field.getAnnotation(DynamicParameter) + if( p ) + addParam(result, field, p.names() as List, p.description(), p.hidden(), p.required(), p.arity(), instance) + else if( dp ) + addParam(result, field, dp.names() as List, dp.description(), dp.hidden(), false, -1, instance) + } + // sort for a stable, deterministic ordering: reflection field order is not + // guaranteed by the JVM, but this is a machine-readable contract + result.sort { it.opts ? it.opts[0] : it.name } + return result + } + + @CompileDynamic + private static void addParam(List result, Field field, List names, String description, boolean hidden, boolean required, int arity, Object instance) { + if( hidden ) + return + if( names.any { it in META_OPTIONS } ) + return + + final isArgument = !names + final isFlag = field.getType() == boolean || field.getType() == Boolean || arity == 0 + final entry = [ + name : field.getName(), + kind : isArgument ? 'argument' : 'option', + type : typeName(field), + ] as Map + if( names ) + entry.opts = names + if( description ) + entry.help = description + if( required ) + entry.required = true + if( isFlag ) + entry.is_flag = true + + final defValue = defaultOf(field, instance) + if( defValue != null && !isFlag ) + entry.default = defValue + + result << entry + } + + /** Build a usage string, e.g. {@code nextflow run [options] }. */ + private static String usageOf(CmdBase cmd, List> params) { + final pieces = ["nextflow ${cmd.name}".toString()] + if( params.any { it.kind == 'option' } ) + pieces << '[options]' + if( params.any { it.kind == 'argument' } ) + pieces << '[args...]' + return pieces.join(' ') + } + + @CompileDynamic + private static String descriptionOf(Class clazz) { + return clazz.getAnnotation(Parameters)?.commandDescription() ?: null + } + + @CompileDynamic + private static List aliasesOf(Class clazz) { + final names = clazz.getAnnotation(Parameters)?.commandNames() + return names ? (names as List) : null + } + + private static String typeName(Field field) { + return field.getType().getSimpleName() + } + + /** Read a field's default value from a fresh instance, ignoring empty values. */ + @CompileDynamic + private static Object defaultOf(Field field, Object instance) { + if( instance == null ) + return null + try { + field.setAccessible(true) + final value = field.get(instance) + if( value == null || value == false ) + return null + if( value instanceof Collection && value.isEmpty() ) + return null + if( value instanceof Map && value.isEmpty() ) + return null + return value instanceof CharSequence || value instanceof Number || value instanceof Boolean + ? value + : value.toString() + } + catch( Exception e ) { + return null + } + } + + private static Object newInstance(Class clazz) { + try { + return clazz.getDeclaredConstructor().newInstance() + } + catch( Exception e ) { + return null + } + } + + /** Collect declared fields from the class and its superclasses. */ + private static List declaredFields(Class clazz) { + final fields = new ArrayList() + Class current = clazz + while( current != null && current != Object ) { + fields.addAll(current.getDeclaredFields()) + current = current.getSuperclass() + } + return fields + } + + private static String render(Map schema) { + return JsonOutput.prettyPrint(JsonOutput.toJson(schema)) + } + + private static final List META_OPTIONS = ['-h', '-help', '-help-json'] + +} diff --git a/modules/nextflow/src/main/groovy/nextflow/cli/CmdBase.groovy b/modules/nextflow/src/main/groovy/nextflow/cli/CmdBase.groovy index b402971fff..6ef1e3a5f6 100644 --- a/modules/nextflow/src/main/groovy/nextflow/cli/CmdBase.groovy +++ b/modules/nextflow/src/main/groovy/nextflow/cli/CmdBase.groovy @@ -42,4 +42,7 @@ abstract class CmdBase implements Runnable { @Parameter(names=['-h','-help'], description = 'Print the command usage', arity = 0, help = true) boolean help + + @Parameter(names=['-help-json'], description = 'Print the command help and options as JSON (for tools and LLMs)', arity = 0, help = true) + boolean helpJson } diff --git a/modules/nextflow/src/main/groovy/nextflow/cli/Launcher.groovy b/modules/nextflow/src/main/groovy/nextflow/cli/Launcher.groovy index 387b58b67b..b6678adb5b 100644 --- a/modules/nextflow/src/main/groovy/nextflow/cli/Launcher.groovy +++ b/modules/nextflow/src/main/groovy/nextflow/cli/Launcher.groovy @@ -404,6 +404,7 @@ class Launcher { println "Usage: nextflow [options] COMMAND [arg...]\n" printOptions(CliOptions) printCommands(allCommands) + println "Tip: add -help-json to any command for machine-readable help as JSON.\n" } @CompileDynamic @@ -478,6 +479,10 @@ class Launcher { return this } + private boolean isHelpJson() { + options.helpJson || command?.helpJson + } + protected void checkForHelp() { if( options.help || !command || command.help ) { if( command instanceof UsageAware ) { @@ -519,6 +524,13 @@ class Launcher { return 0 } + // -- print out the machine-readable JSON help, then exit + if( isHelpJson() ) { + final json = command ? CliSchema.command(command) : CliSchema.root(options, allCommands) + println json + return 0 + } + // -- print out the program help, then exit checkForHelp() @@ -705,9 +717,6 @@ class Launcher { } - /* - * The application 'logo' - */ /* * The application 'logo' */ diff --git a/modules/nextflow/src/test/groovy/nextflow/cli/CliSchemaTest.groovy b/modules/nextflow/src/test/groovy/nextflow/cli/CliSchemaTest.groovy new file mode 100644 index 0000000000..a69ae3b1bd --- /dev/null +++ b/modules/nextflow/src/test/groovy/nextflow/cli/CliSchemaTest.groovy @@ -0,0 +1,95 @@ +/* + * Copyright 2013-2026, Seqera Labs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package nextflow.cli + +import groovy.json.JsonSlurper +import spock.lang.Specification + +/** + * + * @author Phil Ewels + */ +class CliSchemaTest extends Specification { + + private static Map parse(String json) { + return new JsonSlurper().parseText(json) as Map + } + + def 'should build the root schema with global options and a command index' () { + given: + def commands = [ new CmdInfo(), new CmdRun(), new CmdLineage() ] as List + + when: + def schema = parse(CliSchema.root(new CliOptions(), commands)) + + then: + schema.name == 'nextflow' + schema.path == 'nextflow' + schema.usage.startsWith('nextflow') + + and: 'global options are reported, but meta options are hidden' + def opts = schema.params.collectMany { it.opts ?: [] } + '-config' in opts + !('-h' in opts) + !('-help-json' in opts) + + and: 'commands are indexed by name with their description' + schema.subcommands.size() == 3 + schema.subcommands.containsKey('run') + schema.subcommands.containsKey('info') + schema.subcommands.run.help + + and: 'aliases are surfaced when present' + schema.subcommands.lineage.aliases == ['li'] + } + + def 'should build a command schema with options and arguments' () { + when: + def schema = parse(CliSchema.command(new CmdRun())) + + then: + schema.name == 'run' + schema.path == 'nextflow run' + schema.help + schema.usage.startsWith('nextflow run') + + and: 'a known option is reported with its metadata' + def resume = schema.params.find { it.name == 'resume' } + resume.kind == 'option' + '-resume' in resume.opts + + and: 'a boolean option is flagged' + def cache = schema.params.find { it.opts == ['-cache'] } + cache.is_flag == true + + and: 'positional arguments are reported as arguments' + schema.params.any { it.kind == 'argument' } + + and: 'meta options are excluded' + !schema.params.collectMany { it.opts ?: [] }.contains('-help-json') + } + + def 'should expose command aliases' () { + when: + def schema = parse(CliSchema.command(new CmdLineage())) + + then: + schema.name == 'lineage' + schema.aliases == ['li'] + } + +}