diff --git a/.claude/README.md b/.claude/README.md new file mode 100644 index 00000000..fc03e078 --- /dev/null +++ b/.claude/README.md @@ -0,0 +1,63 @@ +# Claude Code Configuration for Seqera Platform CLI + +This directory contains Claude Code configuration and skill documentation for contributors working on the Seqera Platform CLI codebase. + +## Skills Available + +### enrich-cli-help + +Workflow documentation for generating structured CLI metadata from the `tower-cli` source tree. + +**Use this skill when:** +- Updating the metadata extractor +- Validating metadata output +- Reviewing the release-artifact workflow for CLI docs metadata + +**Quick start:** +```bash +./gradlew extractCliMetadata +``` + +This generates `build/cli-metadata/cli-metadata.json`. + +**Documentation:** +- `skills/enrich-cli-help/SKILL.md` - Complete workflow guide +- `skills/enrich-cli-help/README.md` - Quick reference + +## Project Context + +### Repository Structure +```text +tower-cli/ +├── src/main/java/io/seqera/tower/cli/ +│ └── utils/metadata/ +│ └── CliMetadataExtractor.java +├── docs/ +│ └── README.md +├── .claude/ +└── build.gradle +``` + +### Key Files + +- `src/main/java/io/seqera/tower/cli/utils/metadata/CliMetadataExtractor.java` +- `build.gradle` +- `docs/README.md` +- `.github/workflows/build.yml` + +## Workflow Overview + +```text +1. Extract metadata + └─> ./gradlew extractCliMetadata + └─> Outputs build/cli-metadata/cli-metadata.json + +2. Verify + └─> Run tests when the extractor changes + └─> Confirm metadata shape stays stable + +3. Release + └─> Generate cli-metadata.json in CI + └─> Upload it to GitHub release assets + └─> Dispatch docs automation with the release asset URL +``` diff --git a/.claude/skills/enrich-cli-help/README.md b/.claude/skills/enrich-cli-help/README.md new file mode 100644 index 00000000..40e6a80c --- /dev/null +++ b/.claude/skills/enrich-cli-help/README.md @@ -0,0 +1,36 @@ +# CLI Metadata Generation Skill + +This skill documents how to generate and validate structured CLI metadata from the `tower-cli` source tree. + +## Quick Start + +Run: + +```bash +./gradlew extractCliMetadata +``` + +The output is written to `build/cli-metadata/cli-metadata.json`. + +## What This Skill Does + +1. Generates CLI metadata from picocli command definitions +2. Verifies the extractor output shape +3. Documents the release-artifact workflow +4. Keeps metadata generation independent from external OpenAPI specs + +## Release strategy + +- Metadata is generated from source code without modifying it +- Metadata is a build and release artifact, not a tracked repository file +- Downstream docs tooling should consume the release asset + +## Files + +- `SKILL.md` - Complete skill documentation + +## Requirements + +- Repository root: `tower-cli` +- Gradle task: `extractCliMetadata` +- Extractor: `src/main/java/io/seqera/tower/cli/utils/metadata/CliMetadataExtractor.java` diff --git a/.claude/skills/enrich-cli-help/SKILL.md b/.claude/skills/enrich-cli-help/SKILL.md new file mode 100644 index 00000000..9adf1384 --- /dev/null +++ b/.claude/skills/enrich-cli-help/SKILL.md @@ -0,0 +1,73 @@ +--- +name: enrich-cli-help +description: Generate structured CLI metadata from tower-cli source code using the built-in Java extractor. Use when validating metadata generation, updating release automation, or checking the downstream artifact consumed by docs tooling. +--- + +# CLI Metadata Generation for Seqera Platform CLI + +This skill is for generating and validating CLI metadata from the `tower-cli` source tree. + +## When to Use This Skill + +Use this skill when: +- Updating the metadata extractor implementation +- Verifying that the Gradle task still produces valid metadata +- Reviewing release automation for the metadata artifact +- Investigating command coverage in the generated metadata +- Confirming downstream docs consumers can rely on the release artifact + +## Scope + +The generator is deterministic and works directly from the compiled CLI command tree. It does not edit CLI source code and it does not depend on external OpenAPI specifications. + +## Workflow + +### Step 1: Generate metadata locally + +Run the Gradle task from the repository root: + +```bash +./gradlew extractCliMetadata +``` + +The generated file is written to: + +```text +build/cli-metadata/cli-metadata.json +``` + +### Step 2: Validate the output + +Check that the JSON contains the expected top-level keys: + +```text +metadata +hierarchy +commands +``` + +If you are changing the extractor, add or update unit tests under `src/test/`. + +### Step 3: Review only the metadata generation path + +Focus on: +- `src/main/java/io/seqera/tower/cli/utils/metadata/CliMetadataExtractor.java` +- `build.gradle` +- release automation in `.github/workflows/` +- docs describing metadata generation + +Do not mix this workflow with CLI help-text enrichment or external API specs. + +### Step 4: Release behavior + +At release time the metadata should be: +1. generated from the current `tower-cli` source tree +2. uploaded as a GitHub release artifact +3. passed to downstream docs tooling via the release asset URL + +## Notes + +- Remove hard-coded local paths from all documentation. +- Keep generated metadata out of Git. +- Prefer battle-tested libraries already on the classpath over hand-written serializers. +- Keep the workflow focused on metadata generation only. diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 51645ede..5001ee87 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -355,6 +355,12 @@ jobs: echo "VERSION=$VERSION" echo "VERSION=$VERSION" >> $GITHUB_OUTPUT + - name: Generate CLI metadata artifact + run: ./gradlew extractCliMetadata + env: + GITHUB_USERNAME: ${{ github.actor }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Run JReleaser uses: jreleaser/release-action@v2 env: diff --git a/.gitignore b/.gitignore index b739c0ca..1dce9e88 100644 --- a/.gitignore +++ b/.gitignore @@ -5,9 +5,15 @@ # Ignore Gradle build output directory build **/build-info.properties +docs/cli-metadata.json # Location for unshared files .user/ .DS_Store + *.iml + +# macOS system files +.DS_Store +**/.DS_Store diff --git a/build.gradle b/build.gradle index de3367e1..d49f62b8 100644 --- a/build.gradle +++ b/build.gradle @@ -151,6 +151,19 @@ tasks.register('runReflectionConfigGenerator', JavaExec) { jvmArgs = ["-agentlib:native-image-agent=access-filter-file=conf/access-filter-file.json,config-merge-dir=conf/"] } +task extractCliMetadata(type: JavaExec) { + group = 'documentation' + description = 'Extract CLI metadata from the source tree using picocli metadata inspection' + classpath = sourceSets.main.runtimeClasspath + mainClass = 'io.seqera.tower.cli.utils.metadata.CliMetadataExtractor' + args = [file('build/cli-metadata/cli-metadata.json').absolutePath] + outputs.file(file('build/cli-metadata/cli-metadata.json')) + dependsOn classes + doFirst { + file('build/cli-metadata').mkdirs() + } +} + tasks.named('shadowJar') { archiveBaseName.set('tw') archiveClassifier.set('') diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 00000000..2be9cd21 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,107 @@ +# CLI Metadata Generator + +This directory documents the CLI metadata generator used by `tower-cli`. + +## Overview + +The metadata generator inspects the compiled CLI command tree through picocli and emits a structured JSON document for downstream documentation tooling. + +The generator: +- lives in the `tower-cli` repository +- is implemented in Java +- runs as the Gradle task `extractCliMetadata` +- does not modify CLI source files +- produces build output that is not tracked in Git + +## Manual generation + +Generate metadata locally with: + +```bash +./gradlew extractCliMetadata +``` + +The task writes the artifact to: + +```text +build/cli-metadata/cli-metadata.json +``` + +## Output contents + +The generated JSON includes: +- top-level extraction metadata +- the full command hierarchy +- a flat command lookup map +- command options, positional parameters, types, arity, and descriptions +- resolved picocli mixins + +## Implementation + +The extractor is implemented in: + +```text +src/main/java/io/seqera/tower/cli/utils/metadata/CliMetadataExtractor.java +``` + +It builds the `tw` command tree, walks the picocli `CommandSpec` model, filters standard help/version options, and serializes the resulting structure with Jackson. + +## Release flow + +The metadata file is a release artifact, similar to the CLI binaries. + +At release time the build pipeline: +1. runs `./gradlew extractCliMetadata` +2. produces `build/cli-metadata/cli-metadata.json` +3. lets JReleaser publish that file to the GitHub release assets + +Downstream consumers should fetch metadata from the release assets rather than from a tracked repository file. + +**Documentation:** See `.claude/skills/enrich-cli-help/` for complete workflow guide + +## Troubleshooting + +### Missing GitHub package credentials + +If the build fails with `Username must not be null!`, configure GitHub credentials for the private `tower-java-sdk` dependency: + +```bash +# In ~/.gradle/gradle.properties +github.packages.user=your-github-username +github.packages.token=your-github-token +``` + +You can also inline credentials for a one-off run: + +```bash +GITHUB_USERNAME= GITHUB_TOKEN= ./gradlew extractCliMetadata +``` + +or + +```bash +GITHUB_USERNAME= GITHUB_TOKEN=$(cat gh_token.txt) ./gradlew extractCliMetadata +``` + +### Commands missing from output + +Check for: +- Proper `@Command` annotation on class +- Parent command has `subcommands = {ChildCmd.class}` reference +- Import statement if subcommand in different package + +### Options missing from output + +Check for: +- Ensure mixin classes are properly annotated with `@Mixin` +- Verify option annotations: `@Option` or `@CommandLine.Option` (both supported) +- Check that picocli can access the option at runtime + +## Related Documentation + +- **Java extractor:** `src/main/java/io/seqera/tower/cli/utils/metadata/CliMetadataExtractor.java` +- **Gradle task:** `extractCliMetadata` in `build.gradle` +- **Release workflow:** `.github/workflows/build.yml` +- **JReleaser config:** `jreleaser.yml` +- **Claude Code skill:** `.claude/skills/enrich-cli-help/SKILL.md` +- **CLI documentation:** https://docs.seqera.io/platform/latest/cli/overview diff --git a/jreleaser.yml b/jreleaser.yml index cbd45244..96c06646 100644 --- a/jreleaser.yml +++ b/jreleaser.yml @@ -75,6 +75,10 @@ release: - search: ' \[skip ci\]' - search: ' \[release\]' +files: + artifacts: + - path: "build/cli-metadata/cli-metadata.json" + distributions: tw: type: FLAT_BINARY @@ -116,4 +120,4 @@ packagers: active: RELEASE tagName: '{{distributionName}}-{{tagName}}' branch: HEAD - commitMessage: '{{distributionName}} {{tagName}}' \ No newline at end of file + commitMessage: '{{distributionName}} {{tagName}}' diff --git a/src/main/java/io/seqera/tower/cli/utils/metadata/CliMetadataExtractor.java b/src/main/java/io/seqera/tower/cli/utils/metadata/CliMetadataExtractor.java new file mode 100644 index 00000000..ad5b46ac --- /dev/null +++ b/src/main/java/io/seqera/tower/cli/utils/metadata/CliMetadataExtractor.java @@ -0,0 +1,272 @@ +/* + * Copyright 2021-2023, Seqera. + * + * 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 io.seqera.tower.cli.utils.metadata; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.ObjectWriter; +import com.fasterxml.jackson.databind.SerializationFeature; +import io.seqera.tower.cli.Tower; +import picocli.CommandLine; +import picocli.CommandLine.Model.CommandSpec; +import picocli.CommandLine.Model.OptionSpec; +import picocli.CommandLine.Model.PositionalParamSpec; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Instant; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * Extracts CLI metadata from picocli annotations using reflection. + * + * This approach is deterministic and captures all command metadata including: + * - Complete command hierarchy with resolved mixins + * - All options with descriptions, defaults, arity, required status + * - Positional parameters + * - Hidden commands and options + * + * Usage: java -cp io.seqera.tower.cli.utils.metadata.CliMetadataExtractor [output-file] + * If no output file is specified, the metadata is written to stdout. + */ +public class CliMetadataExtractor { + private static final ObjectWriter JSON_WRITER = new ObjectMapper() + .enable(SerializationFeature.INDENT_OUTPUT) + .writer(); + + private int totalCommands = 0; + private int totalOptions = 0; + private int totalParameters = 0; + + public static void main(String[] args) throws IOException { + CliMetadataExtractor extractor = new CliMetadataExtractor(); + String json = extractor.extractMetadataAsJson(buildRootSpec()); + + if (args.length == 0) { + System.out.print(json); + return; + } + + Path outputPath = resolveOutputPath(args[0]); + Path parent = outputPath.getParent(); + if (parent != null) { + Files.createDirectories(parent); + } + + Files.writeString(outputPath, json); + System.err.println("CLI metadata written to: " + outputPath); + System.err.println("Total commands: " + extractor.totalCommands); + System.err.println("Total options: " + extractor.totalOptions); + System.err.println("Total parameters: " + extractor.totalParameters); + } + + /** + * Build the picocli command tree used for metadata extraction. + */ + static CommandSpec buildRootSpec() { + CommandLine commandLine = new CommandLine(new Tower()); + commandLine.setUsageHelpLongOptionsMaxWidth(40); + commandLine.setCaseInsensitiveEnumValuesAllowed(true); + return commandLine.getCommandSpec(); + } + + private static Path resolveOutputPath(String outputArg) { + Path outputPath = Path.of(outputArg); + if (outputArg.endsWith(".json")) { + return outputPath; + } + + return outputPath.resolve("cli-metadata.json"); + } + + /** + * Extract all CLI metadata and return it as a nested map structure. + */ + public Map extractMetadata(CommandSpec rootSpec) { + Map output = new LinkedHashMap<>(); + + Map metadata = new LinkedHashMap<>(); + metadata.put("extractor_version", "2.0.0"); + metadata.put("extractor_type", "java-reflection"); + metadata.put("extracted_at", Instant.now().toString()); + output.put("metadata", metadata); + + Map hierarchy = extractCommand(rootSpec, null, ""); + output.put("hierarchy", hierarchy); + + Map commands = new LinkedHashMap<>(); + flattenCommands(hierarchy, commands); + output.put("commands", commands); + + metadata.put("total_commands", totalCommands); + metadata.put("total_options", totalOptions); + metadata.put("total_parameters", totalParameters); + + return output; + } + + /** + * Extract all CLI metadata and return it as formatted JSON. + */ + public String extractMetadataAsJson(CommandSpec rootSpec) throws JsonProcessingException { + return JSON_WRITER.writeValueAsString(extractMetadata(rootSpec)); + } + + /** + * Extract metadata from a CommandSpec recursively. + */ + private Map extractCommand(CommandSpec spec, String parentName, String fullCommandPath) { + Map cmd = new LinkedHashMap<>(); + + String name = spec.name(); + String fullCommand = fullCommandPath.isEmpty() ? name : fullCommandPath + " " + name; + + totalCommands++; + + cmd.put("name", name); + cmd.put("full_command", fullCommand); + cmd.put("parent", parentName); + + String[] descArray = spec.usageMessage().description(); + String description = descArray.length > 0 ? String.join(" ", descArray) : null; + cmd.put("description", description); + + cmd.put("hidden", spec.usageMessage().hidden()); + + Object userObject = spec.userObject(); + if (userObject != null) { + cmd.put("source_class", userObject.getClass().getName()); + } + + List> options = new ArrayList<>(); + for (OptionSpec opt : spec.options()) { + if (isBuiltInOption(opt)) { + continue; + } + options.add(extractOption(opt)); + totalOptions++; + } + cmd.put("options", options); + + List> parameters = new ArrayList<>(); + for (PositionalParamSpec param : spec.positionalParameters()) { + parameters.add(extractParameter(param)); + totalParameters++; + } + cmd.put("parameters", parameters); + + List> children = new ArrayList<>(); + List subcommandNames = new ArrayList<>(); + for (Map.Entry entry : spec.subcommands().entrySet()) { + String subName = entry.getKey(); + CommandSpec subSpec = entry.getValue().getCommandSpec(); + + if (!subName.equals(subSpec.name())) { + continue; + } + + subcommandNames.add(subName); + children.add(extractCommand(subSpec, name, fullCommand)); + } + cmd.put("subcommands", subcommandNames); + cmd.put("children", children); + + return cmd; + } + + /** + * Extract metadata from an OptionSpec. + */ + private Map extractOption(OptionSpec opt) { + Map option = new LinkedHashMap<>(); + + List names = new ArrayList<>(); + for (String name : opt.names()) { + names.add(name); + } + option.put("names", names); + + String[] descArray = opt.description(); + String description = descArray.length > 0 ? String.join(" ", descArray) : null; + option.put("description", description); + + option.put("required", opt.required()); + option.put("default_value", opt.defaultValue()); + option.put("arity", opt.arity().toString()); + option.put("hidden", opt.hidden()); + option.put("type", opt.type().getSimpleName()); + option.put("param_label", opt.paramLabel()); + + String splitRegex = opt.splitRegex(); + if (splitRegex != null && !splitRegex.isEmpty()) { + option.put("split", splitRegex); + } + + option.put("negatable", opt.negatable()); + return option; + } + + /** + * Extract metadata from a PositionalParamSpec. + */ + private Map extractParameter(PositionalParamSpec param) { + Map parameter = new LinkedHashMap<>(); + parameter.put("index", param.index().toString()); + parameter.put("param_label", param.paramLabel()); + + String[] descArray = param.description(); + String description = descArray.length > 0 ? String.join(" ", descArray) : null; + parameter.put("description", description); + + parameter.put("arity", param.arity().toString()); + parameter.put("required", param.arity().min() > 0); + parameter.put("type", param.type().getSimpleName()); + parameter.put("hidden", param.hidden()); + return parameter; + } + + /** + * Check if an option is a built-in picocli option (help, version). + */ + private boolean isBuiltInOption(OptionSpec opt) { + return opt.usageHelp() || opt.versionHelp(); + } + + /** + * Flatten the hierarchy into a map keyed by full command path. + */ + @SuppressWarnings("unchecked") + private void flattenCommands(Map node, Map result) { + String fullCommand = (String) node.get("full_command"); + + Map flatNode = new LinkedHashMap<>(node); + flatNode.remove("children"); + result.put(fullCommand, flatNode); + + List> children = (List>) node.get("children"); + if (children != null) { + for (Map child : children) { + flattenCommands(child, result); + } + } + } +} diff --git a/src/test/java/io/seqera/tower/cli/utils/metadata/CliMetadataExtractorTest.java b/src/test/java/io/seqera/tower/cli/utils/metadata/CliMetadataExtractorTest.java new file mode 100644 index 00000000..744d6c3f --- /dev/null +++ b/src/test/java/io/seqera/tower/cli/utils/metadata/CliMetadataExtractorTest.java @@ -0,0 +1,70 @@ +/* + * Copyright 2021-2023, Seqera. + * + * 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 io.seqera.tower.cli.utils.metadata; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +public class CliMetadataExtractorTest { + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Test + void shouldGenerateValidJsonWithExpectedTopLevelStructure() throws Exception { + CliMetadataExtractor extractor = new CliMetadataExtractor(); + + JsonNode root = objectMapper.readTree(extractor.extractMetadataAsJson(CliMetadataExtractor.buildRootSpec())); + + assertTrue(root.has("metadata")); + assertTrue(root.has("hierarchy")); + assertTrue(root.has("commands")); + assertEquals("java-reflection", root.path("metadata").path("extractor_type").asText()); + assertEquals("tw", root.path("hierarchy").path("name").asText()); + } + + @Test + void shouldIncludeLaunchCommandAndSkipBuiltInHelpOptions() throws Exception { + CliMetadataExtractor extractor = new CliMetadataExtractor(); + + JsonNode root = objectMapper.readTree(extractor.extractMetadataAsJson(CliMetadataExtractor.buildRootSpec())); + JsonNode commands = root.path("commands"); + JsonNode launch = commands.path("tw launch"); + + assertTrue(launch.isObject()); + assertEquals("launch", launch.path("name").asText()); + assertTrue(hasOptionNamed(launch.path("options"), "--params-file")); + assertFalse(hasOptionNamed(commands.path("tw").path("options"), "--help")); + assertFalse(hasOptionNamed(commands.path("tw").path("options"), "--version")); + } + + private boolean hasOptionNamed(JsonNode options, String name) { + for (JsonNode option : options) { + for (JsonNode optionName : option.path("names")) { + if (name.equals(optionName.asText())) { + return true; + } + } + } + + return false; + } +}