Creating a CLI -Part 1: Create a CLI command line tool in .Net and never forget terminal commands again using AI

Fábio Salomão
8 min readMay 16, 2024

--

If you’ve ever wondered how command-line tools that facilitate terminal operations are developed, such as those widely used in the Node.js ecosystem for installing NPM packages and starting services, you’ll be happy to know that with .NET it’s It is possible to create console applications for this purpose in a simple and efficient way.

With this in mind, I am developing an application that uses Artificial Intelligence to make requests to the OpenAI API, using the GPT-3.5 model, which is more than sufficient for the purpose. This application will be able to return terminal commands that the developer may have forgotten, such as those needed to create a Docker image or perform a git push to a remote repository.

Before we start coding, we need to create an OpenAI API token to authorize calls to the server. Go to https://platform.openai.com/api-keys and generate your API key.

After creation, copy and store it temporarily, for example, in Notepad, as once generated, the key will no longer be displayed.

Next, we will start creating the application in Visual Studio 2022. To do this, I configured a .NET 8 application, selecting the “Console App” template in Visual Studio 2022. In the “Additional information” window, to optimize development, make sure Make sure the option “Do not use top-level statements” is checked.

Initially we will create 3 folders in Visual Studio’s “Solution Explorer”, as shown in the following image:

public class ContentResponse
{
public List<TerminalCommand> Content { get; set; } = [];
}
public class TerminalCommand
{
public string? Tech { get; set; }
public List<string> Commands { get; set; } = [];
}

After defining the models, we will implement the GptService service class, which will allow our CLI to communicate with the ChatGPT API and process the responses received.

using CliNet.Credentials;
using CliNet.Models;
using Newtonsoft.Json;
using OpenAI_API.Chat;
using OpenAI_API.Models;

namespace CliNet.Services
{
public static class GptService
{
public static async Task<List<TerminalCommand>> CallApiGpt(string value)
{
var prompt = $@"Retorne um ou mais comandos de terminal utilizados por Desenvolvedores de Software que contenha obrigatoriamente os seguintes termos: {value.ToUpper()}. " +
"Modelo da LISTA de objetos JSON: [content: [tech: string, commands: string[]]]. " +
"Legenda: " +
"tech = tecnologia do comando (exemplo de tecnologia: git)" +
"commands = lista de comandos (exemplo: git clone [source])";

var apiKey = CredentialManager.GetCredential();
if (string.IsNullOrEmpty(apiKey))
{
var newApyValid = false;
Console.WriteLine("---------------");
Console.WriteLine("");
Console.ResetColor();
while (!newApyValid)
{
Console.WriteLine("");
Console.Write("Por favor, insira uma nova chave da API da OpenAI: ");
var newApiKey = Console.ReadLine();
if (string.IsNullOrEmpty(newApiKey) || newApiKey.Length < 45)
{
Console.ForegroundColor = ConsoleColor.Magenta;
Console.WriteLine("API Key inválida. Insira um API válida");
}
else
{
CredentialManager.SaveCredential(newApiKey);
apiKey = newApiKey;
newApyValid = true;
Console.ResetColor();
}
}
}

try
{
var api = new OpenAI_API.OpenAIAPI(apiKey);
var result = await api.Chat.CreateChatCompletionAsync(new ChatRequest
{
Model = Model.ChatGPTTurbo_1106,
Temperature = 0.0,
MaxTokens = 250,
ResponseFormat = ChatRequest.ResponseFormats.JsonObject,
Messages =
[
new ChatMessage(ChatMessageRole.System, "You are a helpful assistant designed to output LIST of JSON objects."),
new ChatMessage(ChatMessageRole.User, prompt)
]
});

var jsonResult = result?.Choices[0].Message.TextContent;
if (jsonResult == null) return [];
var contentResponse = JsonConvert.DeserializeObject<ContentResponse>(jsonResult, new JsonSerializerSettings { Formatting = Formatting.None }) ?? new ContentResponse();
return contentResponse.Content;

}
catch (Exception e)
{
Console.WriteLine(e);
throw;
}
}
}
}

To integrate our application with the OpenAI API, we will use the “OpenAI” NuGet package. This package simplifies the configurations required to interact with the endpoint, including the key required properties. To get started, add the following package:

$ dotnet add package OpenAI --version 1.11.0

As previously mentioned, to use the OpenAI API, it is necessary to use the API Key that has already been generated. However, we need to define a way for the application to retrieve this key and send it with the request.

There are several strategies that we can use to persist the API, however, to leave the CLI with a more professional functionality and the API key can be replaced whenever necessary, we can inform it in the Terminal prompt itself, if the key does not exist in Credential Manager.

This way, we will create the CredentialManager class in the Credentials folder as follows:

using System.Runtime.InteropServices;
using System.Text;

namespace CliNet.Credentials
{
public static class CredentialManager
{
private const int CredTypeGeneric = 1;
private const int CredPersistLocalMachine = 2;

[DllImport("advapi32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
public static extern bool CredWrite([In] ref NativeCredential userCredential, [In] uint flags);

[DllImport("advapi32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
public static extern bool CredRead(string target, int type, int reservedFlag, out IntPtr credentialPtr);

[DllImport("advapi32.dll", SetLastError = true)]
public static extern bool CredFree([In] IntPtr cred);

[DllImport("advapi32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
public static extern bool CredDelete(string target, int type, int reservedFlag);


[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
public struct NativeCredential
{
public uint Flags;
public int Type;
public IntPtr TargetName;
public IntPtr Comment;
public System.Runtime.InteropServices.ComTypes.FILETIME LastWritten;
public uint CredentialBlobSize;
public IntPtr CredentialBlob;
public uint Persist;
public uint AttributeCount;
public IntPtr Attributes;
public IntPtr TargetAlias;
public IntPtr UserName;
}

public static void SaveCredential(string value)
{
var byteArray = Encoding.Unicode.GetBytes(value);
var credential = new NativeCredential
{
AttributeCount = 0,
Attributes = IntPtr.Zero,
Comment = IntPtr.Zero,
TargetAlias = IntPtr.Zero,
Type = CredTypeGeneric,
Persist = CredPersistLocalMachine,
CredentialBlobSize = (uint)byteArray.Length,
TargetName = Marshal.StringToCoTaskMemUni("OpenAiToken"),
CredentialBlob = Marshal.UnsafeAddrOfPinnedArrayElement(byteArray, 0),
UserName = Marshal.StringToCoTaskMemUni(Environment.UserName)
};
if (CredWrite(ref credential, 0)) return;
var lastError = Marshal.GetLastWin32Error();
throw new System.ComponentModel.Win32Exception(lastError);
}

public static string GetCredential()
{
if (!CredRead("OpenAiToken", CredTypeGeneric, 0, out var credentialPtr)) return null;
try
{
var credential = (NativeCredential)Marshal.PtrToStructure(credentialPtr, typeof(NativeCredential))!;
var byteArray = new byte[credential.CredentialBlobSize];
Marshal.Copy(credential.CredentialBlob, byteArray, 0, (int)credential.CredentialBlobSize);
return Encoding.Unicode.GetString(byteArray);
}
finally
{
CredFree(credentialPtr);
}
}

public static bool DeleteCredential()
{
if (CredDelete("OpenAiToken", CredTypeGeneric, 0))
{
return true;
}

var lastError = Marshal.GetLastWin32Error();
throw new System.ComponentModel.Win32Exception(lastError);
}
}
}

To conclude, in the Program.cs class, we will implement a switch case to capture the arguments provided via the command line (CLI) and the ReturnCommands method, essential for invoking the ChatGPT API and displaying the results in the console.

using CliNet.Services;

namespace CliNet
{
internal class Program
{
static async Task Main(string[] args)
{
switch (args.Length)
{
case > 0 when args[0] == "help":
Console.WriteLine("Comandos da CLI.Net:");
Console.WriteLine("------------##------------");
Console.WriteLine();
Console.WriteLine("help Mostra os principais comandos da aplicação");
Console.ResetColor();
Console.WriteLine("");
break;
case > 0 when args[0] != "help":
await ReturnCommands(string.Join(" ", args));
break;
}
}

private static async Task ReturnCommands(string value)
{
Console.WriteLine("");
Console.ForegroundColor = ConsoleColor.Yellow;
Console.WriteLine($"CLI.Net - Gerando comandos para: {value}...");
Console.WriteLine("-----------------------------------------------");
Console.ResetColor();
Console.WriteLine("");

var commands = await GptService.CallApiGpt(value);
if (!commands.Any())
{
Console.ForegroundColor = ConsoleColor.Red;
Console.WriteLine("Não há comandos com o(s) termo(s) informado(s). Tente novamente.");
Console.ResetColor();
return;
}
foreach (var techCommand in commands)
{
Console.WriteLine("");
Console.ForegroundColor = ConsoleColor.DarkGreen;
Console.WriteLine($"Tecnologia: {techCommand.Tech}");
Console.WriteLine("-----------------------------------------------");
Console.ResetColor();
Console.ForegroundColor = ConsoleColor.Yellow;
foreach (var command in techCommand.Commands)
{
Console.WriteLine($"Comando: {command}");
}
Console.ResetColor();
Console.WriteLine("");
}
}
}
}

It is important to note that we repeatedly use the Console class to style the displayed messages, employing methods such as:

WriteLine: to print messages on a new line.
ForegroundColor: to set the color of the text.
ResetColor: to revert to the default text color.
With the application complete, when we try to run it, we will notice that the interaction is non-existent. This is because a CLI requires its installation path to be added to the operating system’s PATH, not just running an .exe file.

Initially, we will do the basic procedure: locate the application’s executable directory and include it in the PATH of the Windows Environment Variables manually.

First, we need to adjust the build type in .NET by changing the setting in the Visual Studio toolbar from “Debug” to “Release”.

After this change, perform a “Rebuild” of the application. Then go to the directory where the .exe file was generated, which in my case is H:\CliNet\CliNet\bin\Release\net8.0.

Proceed to the Windows Environment Variables window and add the full path H:\CliNet\CliNet\bin\Release\net8.0to the user’s Path variable, as illustrated in the attached image.

Once the directory is included in the Path, open a new instance of Windows Terminal or the terminal of your choice and run the desired command. Just like in the following example:

$ clinet pull build

E o resultado deverá ser parecido com a imagem a seguir:

You can also define the technology (nuget or npm) that you will use with the terminal command you are researching, simply entering it as an argument:

$ clinet nuget install

Or:

$ clinet nextjs create

See the result:

Remember that the CLI user can add as many terms as they want on the command line to refine their search, as follows:

$ clinet npm run docker build restore
ou
$ clinet nextjs create install run build

From now on, when typing the clinet command (which is the name of the project and, consequently, of the generated executable) followed by the search terms, the system will provide command suggestions relevant to the specified technologies. This eliminates the need to consult documentation to remember essential terminal commands.

Additionally, the application can be adapted to integrate other artificial intelligence APIs, such as Google Gemini, simply by creating a new class of service for communicating with such technology.

In the next article, we will explore how to package the CLI, publish it in the NUGET Package on Github and, finally, how to install the package on our computer via the dotnet command line, without the need for manual adjustments to the PATH of the environment variables.

See you then!

Repositório GitHub do projeto:

--

--

Fábio Salomão

Developer since 2009 with experience in Microsoft .Net and Full Stack skills. React specialist, focused on creating innovative and efficient solutions.