Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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