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
8 changes: 5 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,8 @@ dotnet run -- --help
- ✅ Bill of Materials (BOM) generation
- ✅ NuGet package vulnerability scanning
- ✅ Multiple output formats (console, markdown)
- ✅ Path argument support (`-p` / `--path`) for all analysis commands
- ✅ Command-specific help with argument documentation

## 🎯 Current Commands

Expand All @@ -61,13 +63,13 @@ codemedic --version # Show version

# Analysis commands
codemedic health # Repository health dashboard
codemedic health --format markdown
codemedic health -p /path/to/repo --format markdown

codemedic bom # Bill of Materials
codemedic bom --format md > bom.md
codemedic bom --path /path/to/repo --format md > bom.md

codemedic vulnerabilities # Scan for NuGet vulnerabilities
codemedic vulnerabilities --format markdown > vulns.md
codemedic vulnerabilities -p /path/to/repo --format markdown > vulns.md
```

## 🔧 Technology Stack
Expand Down
117 changes: 117 additions & 0 deletions doc/plugin_architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,123 @@ public class PluginMetadata

---

## Command Registration System

Analysis engine plugins can register CLI commands with arguments using the `RegisterCommands()` method. This enables plugins to expose their functionality through dedicated CLI commands with proper argument parsing and help integration.

### CommandRegistration & CommandArgument

```csharp
namespace CodeMedic.Abstractions.Plugins;

/// <summary>
/// Represents a command that can be registered with the CLI.
/// </summary>
public class CommandRegistration
{
public required string Name { get; init; } // Command name (e.g., "health")
public required string Description { get; init; } // Command description for help
public required Func<string[], IRenderer, Task<int>> Handler { get; init; } // Command handler
public string[]? Examples { get; init; } // Usage examples
public CommandArgument[]? Arguments { get; init; } // Command arguments
}

/// <summary>
/// Represents a command-line argument specification.
/// </summary>
public record CommandArgument(
string Description, // Required: what this argument does
string? ShortName = null, // Short form: "p" for "-p"
string? LongName = null, // Long form: "path" for "--path"
bool IsRequired = false, // Whether argument is mandatory
bool HasValue = true, // Whether argument takes a value
string? DefaultValue = null, // Default value description
string? ValueName = null); // Value type for help display
```

### Command Registration Features

- **Automatic Help Integration**: Commands with arguments automatically appear in `--help` output
- **Command-Specific Help**: `codemedic mycommand --help` shows detailed argument information
- **Argument Parsing**: Use built-in utilities like `IdentifyTargetPathFromArgs()` for common patterns
- **Rich Help Display**: Arguments show descriptions, default values, and usage examples

### Built-in Path Argument Support

All current analysis plugins support the standard path argument pattern:

```bash
# Current directory (default)
codemedic health

# Specific path (short form)
codemedic health -p /path/to/repo

# Specific path (long form)
codemedic health --path /path/to/repo

# Combined with format
codemedic bom -p ../other-project --format markdown
```

### Example: Plugin with Command Registration

```csharp
public class MyAnalysisPlugin : IAnalysisEnginePlugin
{
// ... other plugin implementation ...

public CommandRegistration[]? RegisterCommands()
{
return
[
new CommandRegistration
{
Name = "myanalysis",
Description = "Run my custom analysis",
Handler = ExecuteMyAnalysisAsync,
Arguments =
[
new CommandArgument(
Description: "Path to the repository to analyze",
ShortName: "p",
LongName: "path",
ValueName: "path",
DefaultValue: "current directory"),
new CommandArgument(
Description: "Enable verbose output",
ShortName: "v",
LongName: "verbose",
HasValue: false) // Flag argument
],
Examples =
[
"codemedic myanalysis",
"codemedic myanalysis -p /path/to/repo",
"codemedic myanalysis --path /path/to/repo --verbose",
"codemedic myanalysis -p . -v --format markdown"
]
}
];
}

private async Task<int> ExecuteMyAnalysisAsync(string[] args, IRenderer renderer)
{
// Parse path argument using built-in extension
var targetPath = args.IdentifyTargetPathFromArgs();

// Run analysis on the specified path
var result = await AnalyzeAsync(targetPath);

// Render results
renderer.RenderReport(result);
return 0;
}
}
```

---

## Plugin Development Workflow

### Step 1: Create the Plugin Project
Expand Down
26 changes: 26 additions & 0 deletions src/CodeMedic.Abstractions/Plugins/CommandRegistration.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
using System.Collections.Generic;

namespace CodeMedic.Abstractions.Plugins;

/// <summary>
Expand All @@ -24,4 +26,28 @@ public class CommandRegistration
/// Gets or sets example usage strings for help text.
/// </summary>
public string[]? Examples { get; init; }

/// <summary>
/// Gets or sets the command arguments specification.
/// </summary>
public CommandArgument[]? Arguments { get; init; }
}

/// <summary>
/// Represents a command-line argument specification.
/// </summary>
/// <param name="Description">The description of what this argument does.</param>
/// <param name="ShortName">The short name of the argument (e.g., "p" for "-p").</param>
/// <param name="LongName">The long name of the argument (e.g., "path" for "--path").</param>
/// <param name="IsRequired">Whether this argument is required.</param>
/// <param name="HasValue">Whether this argument takes a value.</param>
/// <param name="DefaultValue">The default value for this argument.</param>
/// <param name="ValueName">The value type name for help display (e.g., "path", "format", "count").</param>
public record CommandArgument(
string Description,
string? ShortName = null,
string? LongName = null,
bool IsRequired = false,
bool HasValue = true,
string? DefaultValue = null,
string? ValueName = null);
112 changes: 109 additions & 3 deletions src/CodeMedic/Commands/RootCommandHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,8 @@ public static async Task<int> ProcessArguments(string[] args)
_pluginLoader = new PluginLoader();
await _pluginLoader.LoadInternalPluginsAsync();

// No arguments or help requested
if (args.Length == 0 || args.Contains("--help") || args.Contains("-h") || args.Contains("help"))
// No arguments or general help requested
if (args.Length == 0 || args[0] == "--help" || args[0] == "-h" || args[0] == "help")
{
console.RenderBanner(version);
RenderHelp();
Expand All @@ -51,6 +51,15 @@ public static async Task<int> ProcessArguments(string[] args)

if (commandRegistration != null)
{
// Check for command-specific help
var commandArgs = args.Skip(1).ToArray();
if (commandArgs.Contains("--help") || commandArgs.Contains("-h"))
{
console.RenderBanner(version);
RenderCommandHelp(commandRegistration);
return 0;
}

// Parse --format argument (default: console)
string format = "console";
var commandArgsList = args.Skip(1).ToList();
Expand Down Expand Up @@ -114,10 +123,36 @@ private static void RenderHelp()
AnsiConsole.MarkupLine(" [green]codemedic[/] [cyan]--version[/]");
AnsiConsole.WriteLine();

AnsiConsole.MarkupLine("[dim]Options:[/]");
AnsiConsole.MarkupLine("[dim]Global Options:[/]");
AnsiConsole.MarkupLine(" [yellow]--format <format>[/] Output format: [cyan]console[/] (default), [cyan]markdown[/] (or [cyan]md[/])");
AnsiConsole.WriteLine();

// Show command-specific arguments
if (_pluginLoader != null)
{
foreach (var command in _pluginLoader.Commands.Values.OrderBy(c => c.Name))
{
if (command.Arguments != null && command.Arguments.Length > 0)
{
AnsiConsole.MarkupLine($"[dim]{command.Name} Command Options:[/]");

foreach (var arg in command.Arguments)
{
var shortName = !string.IsNullOrEmpty(arg.ShortName) ? $"-{arg.ShortName}" : "";
var longName = !string.IsNullOrEmpty(arg.LongName) ? $"--{arg.LongName}" : "";
var names = string.Join(", ", new[] { shortName, longName }.Where(s => !string.IsNullOrEmpty(s)));

var valuePart = arg.HasValue && !string.IsNullOrEmpty(arg.ValueName) ? $" <{arg.ValueName}>" : "";
var requiredIndicator = arg.IsRequired ? " [red](required)[/]" : "";
var defaultPart = !string.IsNullOrEmpty(arg.DefaultValue) ? $" (default: {arg.DefaultValue})" : "";

AnsiConsole.MarkupLine($" [yellow]{names}{valuePart}[/] {arg.Description}{requiredIndicator}{defaultPart}");
}
AnsiConsole.WriteLine();
}
}
}

AnsiConsole.MarkupLine("[dim]Examples:[/]");

// Display examples from plugins
Expand All @@ -138,6 +173,77 @@ private static void RenderHelp()
AnsiConsole.MarkupLine(" [green]codemedic --version[/]");
}

/// <summary>
/// Renders help text for a specific command.
/// </summary>
private static void RenderCommandHelp(CodeMedic.Abstractions.Plugins.CommandRegistration command)
{
AnsiConsole.MarkupLine($"[bold]Command: {command.Name}[/]");
AnsiConsole.WriteLine();
AnsiConsole.MarkupLine($"{command.Description}");
AnsiConsole.WriteLine();

AnsiConsole.MarkupLine("[dim]Usage:[/]");
var usage = $"codemedic {command.Name}";

if (command.Arguments != null && command.Arguments.Length > 0)
{
foreach (var arg in command.Arguments)
{
var argName = !string.IsNullOrEmpty(arg.LongName) ? $"--{arg.LongName}" : $"-{arg.ShortName}";
var valuePart = arg.HasValue && !string.IsNullOrEmpty(arg.ValueName) ? $" <{arg.ValueName}>" : "";
var optionalWrapper = arg.IsRequired ? "" : "[ ]";

if (arg.IsRequired)
{
usage += $" {argName}{valuePart}";
}
else
{
usage += $" [{argName}{valuePart}]";
}
}
}

usage += " [--format <format>]";
AnsiConsole.MarkupLine($" [green]{usage.EscapeMarkup()}[/]");
AnsiConsole.WriteLine();

if (command.Arguments != null && command.Arguments.Length > 0)
{
AnsiConsole.MarkupLine("[dim]Command Options:[/]");

foreach (var arg in command.Arguments)
{
var shortName = !string.IsNullOrEmpty(arg.ShortName) ? $"-{arg.ShortName}" : "";
var longName = !string.IsNullOrEmpty(arg.LongName) ? $"--{arg.LongName}" : "";
var names = string.Join(", ", new[] { shortName, longName }.Where(s => !string.IsNullOrEmpty(s)));

var valuePart = arg.HasValue && !string.IsNullOrEmpty(arg.ValueName) ? $" <{arg.ValueName}>" : "";
var requiredIndicator = arg.IsRequired ? " [red](required)[/]" : "";
var defaultPart = !string.IsNullOrEmpty(arg.DefaultValue) ? $" (default: {arg.DefaultValue})" : "";

AnsiConsole.MarkupLine($" [yellow]{(names + valuePart).EscapeMarkup()}[/] {arg.Description.EscapeMarkup()}{requiredIndicator}{defaultPart.EscapeMarkup()}");
}
AnsiConsole.WriteLine();
}

AnsiConsole.MarkupLine("[dim]Global Options:[/]");
AnsiConsole.MarkupLine(" [yellow]--format <format>[/] Output format: [cyan]console[/] (default), [cyan]markdown[/] (or [cyan]md[/])");
AnsiConsole.MarkupLine(" [yellow]-h, --help[/] Show this help message");
AnsiConsole.WriteLine();

if (command.Examples != null && command.Examples.Length > 0)
{
AnsiConsole.MarkupLine("[dim]Examples:[/]");
foreach (var example in command.Examples)
{
AnsiConsole.MarkupLine($" [green]{example.EscapeMarkup()}[/]");
}
AnsiConsole.WriteLine();
}
}

/// <summary>
/// Renders information about loaded plugins.
/// </summary>
Expand Down
13 changes: 12 additions & 1 deletion src/CodeMedic/Plugins/BomAnalysis/BomAnalysisPlugin.cs
Original file line number Diff line number Diff line change
Expand Up @@ -60,10 +60,21 @@ public async Task<object> AnalyzeAsync(string repositoryPath, CancellationToken
Name = "bom",
Description = "Generate bill of materials report",
Handler = ExecuteBomCommandAsync,
Arguments =
[
new CommandArgument(
Description: "Path to the repository to analyze",
ShortName: "p",
LongName: "path",
HasValue: true,
ValueName: "path",
DefaultValue: "current directory")
],
Examples =
[
"codemedic bom",
"codemedic bom --format markdown",
"codemedic bom -p /path/to/repo",
"codemedic bom --path /path/to/repo --format markdown",
"codemedic bom --format md > bom.md"
]
}
Expand Down
22 changes: 13 additions & 9 deletions src/CodeMedic/Plugins/HealthAnalysis/HealthAnalysisPlugin.cs
Original file line number Diff line number Diff line change
Expand Up @@ -56,10 +56,21 @@ public async Task<object> AnalyzeAsync(string repositoryPath, CancellationToken
Name = "health",
Description = "Display repository health dashboard",
Handler = ExecuteHealthCommandAsync,
Arguments =
[
new CommandArgument(
Description: "Path to the repository to analyze",
ShortName: "p",
LongName: "path",
HasValue: true,
ValueName: "path",
DefaultValue: "current directory")
],
Examples =
[
"codemedic health",
"codemedic health --format markdown",
"codemedic health -p /path/to/repo",
"codemedic health --path /path/to/repo --format markdown",
"codemedic health --format md > report.md"
]
}
Expand All @@ -71,14 +82,7 @@ private async Task<int> ExecuteHealthCommandAsync(string[] args, IRenderer rende
try
{
// Parse arguments (target path only)
string? targetPath = null;
for (int i = 0; i < args.Length; i++)
{
if (!args[i].StartsWith("--"))
{
targetPath = args[i];
}
}
string? targetPath = args.IdentifyTargetPathFromArgs();

_limitPackageLists = renderer is ConsoleRenderer;

Expand Down
Loading