Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .lycheeignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
# broken plugin and dependency references
https://github.com/jline/jline3/.*
https://bytebuddy.net/byte-buddy
https://checkstyle.org/checks/indentation/indentation.html
https://chronicle.software/java-parent-pom/compiler
Expand Down
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,18 @@ docker pull ghcr.io/metaschema-framework/metaschema-cli:latest
docker run -it ghcr.io/metaschema-framework/metaschema-cli:latest --version
```

### CLI Usage Notes

#### Disabling Color Output

The CLI uses ANSI escape codes for colored output, which is supported by most modern terminals including Windows 10+, Linux, and macOS. If you are using a legacy console that does not support ANSI escape codes (e.g., older Windows cmd.exe, certain CI/CD environments, or when redirecting output to a file), you may see raw escape sequences in the output.

To disable colored output, use the `--no-color` flag:

```sh
metaschema-cli --no-color <command>
```

## Relationship to prior work

The contents of this repository is based on work from the [Metaschema Java repository](https://github.com/usnistgov/metaschema-java/) maintained by the National Institute of Standards and Technology (NIST), the [contents of which have been dedicated in the worldwide public domain](https://github.com/usnistgov/metaschema-java/blob/1a496e4bcf905add6b00a77a762ed3cc31bf77e6/LICENSE.md) using the [CC0 1.0 Universal](https://creativecommons.org/publicdomain/zero/1.0/) public domain dedication. This repository builds on this prior work, maintaining the [CCO license](https://github.com/metaschema-framework/metaschema-java/blob/main/LICENSE.md) on any new works in this repository.
Expand Down
7 changes: 2 additions & 5 deletions cli-processor/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -42,12 +42,9 @@
<artifactId>commons-cli</artifactId>
</dependency>
<dependency>
<groupId>org.fusesource.jansi</groupId>
<artifactId>jansi</artifactId>
<groupId>org.jline</groupId>
<artifactId>jansi-core</artifactId>
</dependency>
<!-- <dependency> <groupId>org.jline</groupId>
<artifactId>jline-terminal-jansi</artifactId>
</dependency> -->
<dependency>
<groupId>nl.talsmasoftware</groupId>
<artifactId>lazy4j</artifactId>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

package gov.nist.secauto.metaschema.cli.processor;

import static org.fusesource.jansi.Ansi.ansi;
import static org.jline.jansi.Ansi.ansi;

import gov.nist.secauto.metaschema.cli.processor.command.CommandService;
import gov.nist.secauto.metaschema.cli.processor.command.ICommand;
Expand All @@ -21,7 +21,7 @@
import org.apache.logging.log4j.core.config.Configuration;
import org.apache.logging.log4j.core.config.LoggerConfig;
import org.eclipse.jdt.annotation.NotOwning;
import org.fusesource.jansi.AnsiConsole;
import org.jline.jansi.Ansi;

import java.io.PrintStream;
import java.util.Arrays;
Expand Down Expand Up @@ -172,12 +172,9 @@ public CLIProcessor(@NonNull String exec, @NonNull Map<String, IVersionInfo> ver
@Nullable @NotOwning PrintStream outputStream) {
this.exec = exec;
this.versionInfos = versionInfos;
if (outputStream == null) {
AnsiConsole.systemInstall();
this.outputStream = ObjectUtils.notNull(AnsiConsole.out());
} else {
this.outputStream = outputStream;
}
// Use System.out directly - modern terminals (Windows 10+, Linux, macOS)
// support ANSI natively without requiring native terminal detection
this.outputStream = outputStream != null ? outputStream : ObjectUtils.notNull(System.out);
}

/**
Expand Down Expand Up @@ -272,9 +269,16 @@ protected final Map<String, ICommand> getTopLevelCommandsByName() {
.collect(Collectors.toUnmodifiableMap(ICommand::getName, Function.identity())));
}

/**
* Disable ANSI escape sequences in output.
* <p>
* When called, this method disables ANSI color codes, causing output to use
* plain text without formatting. This is useful for legacy consoles that do not
* support ANSI escape codes, CI/CD environments, or when redirecting output to
* a file.
*/
static void handleNoColor() {
System.setProperty(AnsiConsole.JANSI_MODE, AnsiConsole.JANSI_MODE_STRIP);
AnsiConsole.systemUninstall();
Ansi.setEnabled(false);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

package gov.nist.secauto.metaschema.cli.processor;

import static org.fusesource.jansi.Ansi.ansi;
import static org.jline.jansi.Ansi.ansi;

import gov.nist.secauto.metaschema.cli.processor.command.CommandExecutionException;
import gov.nist.secauto.metaschema.cli.processor.command.ExtraArgument;
Expand All @@ -23,8 +23,6 @@
import org.apache.commons.cli.help.HelpFormatter;
import org.apache.commons.cli.help.OptionFormatter;
import org.apache.commons.cli.help.TextHelpAppendable;
import org.fusesource.jansi.AnsiPrintStream;

import java.io.IOException;
import java.io.PrintStream;
import java.io.PrintWriter;
Expand Down Expand Up @@ -399,10 +397,12 @@ private static String buildHelpHeader() {
/**
* Callback for providing a help footer.
*
* @return the footer or {@code null}
* @param terminalWidth
* the terminal width for text wrapping
* @return the footer or an empty string if no subcommands
*/
@NonNull
private String buildHelpFooter() {
private String buildHelpFooter(int terminalWidth) {
ICommand targetCommand = getTargetCommand();
Collection<ICommand> subCommands;
if (targetCommand == null) {
Expand All @@ -421,16 +421,23 @@ private String buildHelpFooter() {
.append("The following are available commands:")
.append(System.lineSeparator());

int length = subCommands.stream()
int commandColWidth = subCommands.stream()
.mapToInt(command -> command.getName().length())
.max().orElse(0);

// Calculate description column width: terminal - 3 (leading spaces) -
// commandCol - 1 (space)
int prefixWidth = 3 + commandColWidth + 1;
int descWidth = Math.max(terminalWidth - prefixWidth, 20);
String continuationIndent = " ".repeat(prefixWidth);
Comment thread
coderabbitai[bot] marked this conversation as resolved.

for (ICommand command : subCommands) {
String wrappedDesc = wrapText(command.getDescription(), descWidth, continuationIndent);
builder.append(
ansi()
.render(String.format(" @|bold %-" + length + "s|@ %s%n",
.render(String.format(" @|bold %-" + commandColWidth + "s|@ %s%n",
command.getName(),
command.getDescription())));
wrappedDesc)));
}
builder
.append(System.lineSeparator())
Expand Down Expand Up @@ -540,6 +547,95 @@ private static CharSequence getExtraArguments(@NonNull ICommand targetCommand) {
return builder;
}

private static final int DEFAULT_TERMINAL_WIDTH = 80;

/**
* Get the terminal width from environment or use a default.
* <p>
* This method avoids native terminal detection which triggers Java 21+
* restricted method warnings. Instead, it uses the COLUMNS environment variable
* which is set by most shells.
*
* @return the terminal width in characters
*/
private static int getTerminalWidth() {
String columns = System.getenv("COLUMNS");
if (columns != null) {
try {
int width = Integer.parseInt(columns);
if (width > 0) {
return width;
}
} catch (NumberFormatException e) {
// Ignore and use default
}
}
return DEFAULT_TERMINAL_WIDTH;
}

/**
* Wrap text to fit within the specified width, with proper indentation for
* continuation lines.
*
* @param text
* the text to wrap
* @param maxWidth
* the maximum line width
* @param indent
* the indentation string for continuation lines
* @return the wrapped text
* @throws IllegalArgumentException
* if maxWidth is less than or equal to zero, or if the indent length
* is greater than or equal to maxWidth
*/
@NonNull
static String wrapText(@NonNull String text, int maxWidth, @NonNull String indent) {
if (maxWidth <= 0) {
throw new IllegalArgumentException("maxWidth must be positive, got: " + maxWidth);
}
if (indent.length() >= maxWidth) {
throw new IllegalArgumentException(
"indent length (" + indent.length() + ") must be less than maxWidth (" + maxWidth + ")");
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
if (text.length() <= maxWidth) {
return text;
}

StringBuilder result = new StringBuilder(text.length() + 32);
int lineStart = 0;
boolean firstLine = true;
int effectiveWidth = maxWidth;

while (lineStart < text.length()) {
if (!firstLine) {
result.append(System.lineSeparator()).append(indent);
effectiveWidth = maxWidth - indent.length();
}

int remaining = text.length() - lineStart;
if (remaining <= effectiveWidth) {
result.append(text.substring(lineStart));
break;
}

// Find last space within the width limit
int lineEnd = lineStart + effectiveWidth;
int lastSpace = text.lastIndexOf(' ', lineEnd);

if (lastSpace <= lineStart) {
// No space found, force break at width
result.append(text, lineStart, lineEnd);
lineStart = lineEnd; // Continue from break point (no space to skip)
} else {
result.append(text, lineStart, lastSpace);
lineStart = lastSpace + 1; // Skip the space
}
firstLine = false;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

return ObjectUtils.notNull(result.toString());
}

/**
* Output the help text to the console.
*
Expand All @@ -548,9 +644,9 @@ private static CharSequence getExtraArguments(@NonNull ICommand targetCommand) {
*/
public void showHelp() {
PrintStream out = cliProcessor.getOutputStream();
int terminalWidth = (out instanceof AnsiPrintStream)
? ((AnsiPrintStream) out).getTerminalWidth()
: 80;
// Get terminal width from environment variable COLUMNS, or default to 80
// This avoids native terminal detection which triggers Java 21+ warnings
int terminalWidth = getTerminalWidth();

try (PrintWriter writer = new PrintWriter( // NOPMD not owned
AutoCloser.preventClose(out),
Expand All @@ -562,19 +658,24 @@ public void showHelp() {
HelpFormatter formatter = HelpFormatter.builder()
.setHelpAppendable(appendable)
.setOptionFormatBuilder(OptionFormatter.builder().setOptArgSeparator("="))
.setShowSince(false)
.get();

try {
// Print main help (syntax, header, options) through the formatter
formatter.printHelp(
buildHelpCliSyntax(),
buildHelpHeader(),
toOptions(),
buildHelpFooter(),
"", // Empty footer - we print it directly below
false);
} catch (IOException ex) {
throw new UncheckedIOException("Failed to write help output", ex);
}

// Print footer directly to bypass TextHelpAppendable's text wrapping,
// which doesn't account for ANSI escape sequence lengths
writer.print(buildHelpFooter(terminalWidth));
writer.flush();
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;

import java.io.ByteArrayOutputStream;
import java.io.PrintStream;
Expand Down Expand Up @@ -78,6 +80,78 @@ void testQuietOption() {
}
}

@Nested
@DisplayName("Version Output Tests")
class VersionOutputTests {

@ParameterizedTest(name = "version output contains {1}")
@CsvSource({
"test-cli, app name",
"1.0.0-test, version number",
"2025-01-01, build timestamp",
"test-branch, git branch",
"abc1234, git commit",
"https://example.com/test.git, git origin URL"
})
void testVersionOutputContainsExpectedElement(String expectedSubstring, String description) {
processor.process("--version");

String output = outputCapture.toString(StandardCharsets.UTF_8);
assertTrue(output.contains(expectedSubstring),
"Version output should contain " + description);
}

@Test
@DisplayName("version output contains descriptive text")
void testVersionOutputContainsDescriptiveText() {
processor.process("--version");

String output = outputCapture.toString(StandardCharsets.UTF_8);
assertAll(
() -> assertTrue(output.contains("built at"), "Version output should contain 'built at'"),
() -> assertTrue(output.contains("from branch"), "Version output should contain 'from branch'"));
}
}

@Nested
@DisplayName("No-Color Mode Tests")
class NoColorModeTests {

@Test
@DisplayName("--no-color option is accepted with command")
void testNoColorOptionAccepted() {
processor.addCommandHandler(new TestCommand());

ExitStatus status = processor.process("--no-color", "test-cmd");

assertEquals(ExitCode.OK, status.getExitCode());
}

@Test
@DisplayName("--no-color with --help produces output")
void testNoColorWithHelp() {
// Note: --help must come first for phase 1 parsing to recognize it
ExitStatus status = processor.process("--help", "--no-color");

String output = outputCapture.toString(StandardCharsets.UTF_8);
assertAll(
() -> assertEquals(ExitCode.OK, status.getExitCode()),
() -> assertTrue(output.contains("--help"), "Output should contain '--help'"));
}

@Test
@DisplayName("--no-color with --version produces output")
void testNoColorWithVersion() {
// Note: --version must come first for phase 1 parsing to recognize it
ExitStatus status = processor.process("--version", "--no-color");

String output = outputCapture.toString(StandardCharsets.UTF_8);
assertAll(
() -> assertEquals(ExitCode.OK, status.getExitCode()),
() -> assertTrue(output.contains("test-cli"), "Output should contain app name"));
}
}

@Nested
@DisplayName("Command Execution")
class CommandExecutionTests {
Expand Down
Loading