add configuration profiles with per-invocation --profile flag
- Add SystemPrompt field to HushConfig (empty = built-in default) - Refactor ConfigManager: extract ApplyTomlFields, add LoadWithProfile(), ListProfiles(), GetProfilePath(), EnsureProfilesDirExists(); remove HUSH_PROFILE env-var logic (profiles are now resolved by the CLI) - Extend socket protocol: action commands (START/STOP/TOGGLE/ABORT) now carry a [4-byte LE length][optional HushConfig JSON] payload so the CLI can pass a per-invocation config override without restarting the daemon - Add GENERATE_PROFILE (cmd 7) socket command: CLI sends a description, daemon calls the LLM and returns a generated system prompt - Orchestrator: StopAndProcessAsync accepts optional HushConfig override; ProcessWithLlmAsync uses proper system/user chat roles and respects config.SystemPrompt; add GenerateProfilePromptAsync - Split CompleteTextAsync signature to (systemPrompt, userMessage, model) across ITextStreamingProvider, GroqProvider, FireworksProvider - Add --profile/-p flag to hush toggle and hush stop - Add hush profiles subcommand: list, get, new (manual or AI-generated), edit
This commit is contained in:
@@ -30,6 +30,7 @@ public class Program
|
||||
rootCommand.AddCommand(SetupCommand.Create());
|
||||
rootCommand.AddCommand(LatencyTestCommand.Create());
|
||||
rootCommand.AddCommand(ShowCommand.Create());
|
||||
rootCommand.AddCommand(ProfilesCommand.Create());
|
||||
|
||||
return rootCommand;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
using System.CommandLine;
|
||||
using System.Text.Json;
|
||||
using Hush.Config;
|
||||
using Hush.Daemon;
|
||||
using Spectre.Console;
|
||||
|
||||
namespace Hush.Cli.Commands;
|
||||
|
||||
public static class ProfilesCommand
|
||||
{
|
||||
private static readonly string ProfileTemplate =
|
||||
"# Hush profile — only fields listed here override the base config.\n" +
|
||||
"# All fields are optional. Delete any line you don't want to override.\n" +
|
||||
"#\n" +
|
||||
"# Available fields:\n" +
|
||||
"# whisper_provider = \"groq\" # or \"fireworks\"\n" +
|
||||
"# llm_provider = \"groq\" # or \"fireworks\"\n" +
|
||||
"# llm_model = \"openai/gpt-oss-20b\"\n" +
|
||||
"# whisper_model = \"whisper-large-v3-turbo\"\n" +
|
||||
"# whisper_language = \"en\" # ISO-639-1, empty = auto-detect\n" +
|
||||
"# system_prompt = \"\"\"\n" +
|
||||
"# Your custom instruction for the LLM goes here.\n" +
|
||||
"# Output only the final result with no explanation.\n" +
|
||||
"# \"\"\"\n" +
|
||||
"\n" +
|
||||
"system_prompt = \"\"\"\n" +
|
||||
"You are a transcription post-processor. Clean up the raw speech-to-text output\n" +
|
||||
"and return polished, ready-to-type text. Fix grammar, punctuation, and remove\n" +
|
||||
"filler words. Output only the corrected text with no explanation.\n" +
|
||||
"\"\"\"\n";
|
||||
|
||||
public static Command Create()
|
||||
{
|
||||
var profiles = new Command("profiles", "Manage configuration profiles");
|
||||
|
||||
profiles.AddCommand(CreateListCommand());
|
||||
profiles.AddCommand(CreateGetCommand());
|
||||
profiles.AddCommand(CreateNewCommand());
|
||||
profiles.AddCommand(CreateEditCommand());
|
||||
|
||||
return profiles;
|
||||
}
|
||||
|
||||
// ── list ─────────────────────────────────────────────────────────────────
|
||||
|
||||
private static Command CreateListCommand()
|
||||
{
|
||||
var cmd = new Command("list", "List all available profiles");
|
||||
cmd.SetHandler(() =>
|
||||
{
|
||||
var manager = new ConfigManager();
|
||||
var profiles = manager.ListProfiles().ToList();
|
||||
|
||||
if (profiles.Count == 0)
|
||||
{
|
||||
AnsiConsole.MarkupLine("[grey]No profiles found. Use 'hush profiles new <name>' to create one.[/]");
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var name in profiles)
|
||||
AnsiConsole.WriteLine(name);
|
||||
});
|
||||
return cmd;
|
||||
}
|
||||
|
||||
// ── get ──────────────────────────────────────────────────────────────────
|
||||
|
||||
private static Command CreateGetCommand()
|
||||
{
|
||||
var nameArg = new Argument<string>("name", "Profile name");
|
||||
var cmd = new Command("get", "Print the contents of a profile");
|
||||
cmd.AddArgument(nameArg);
|
||||
|
||||
cmd.SetHandler((context) =>
|
||||
{
|
||||
var name = context.ParseResult.GetValueForArgument(nameArg);
|
||||
var manager = new ConfigManager();
|
||||
var path = manager.GetProfilePath(name);
|
||||
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
AnsiConsole.MarkupLine($"[red]Profile '{name}' not found.[/]");
|
||||
context.ExitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
Console.Write(File.ReadAllText(path));
|
||||
});
|
||||
return cmd;
|
||||
}
|
||||
|
||||
// ── new ──────────────────────────────────────────────────────────────────
|
||||
|
||||
private static Command CreateNewCommand()
|
||||
{
|
||||
var nameArg = new Argument<string>("name", "Profile name");
|
||||
var cmd = new Command("new", "Create a new profile");
|
||||
cmd.AddArgument(nameArg);
|
||||
|
||||
cmd.SetHandler(async (context) =>
|
||||
{
|
||||
var name = context.ParseResult.GetValueForArgument(nameArg);
|
||||
var manager = new ConfigManager();
|
||||
var path = manager.GetProfilePath(name);
|
||||
|
||||
if (File.Exists(path))
|
||||
{
|
||||
AnsiConsole.MarkupLine($"[red]Profile '{name}' already exists. Use 'hush profiles edit {name}' to edit it.[/]");
|
||||
context.ExitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
// Ask creation mode
|
||||
var mode = AnsiConsole.Prompt(
|
||||
new SelectionPrompt<string>()
|
||||
.Title("How do you want to create this profile?")
|
||||
.AddChoices("Generate with AI (describe what you want)", "Create manually (edit a template)"));
|
||||
|
||||
string initialContent;
|
||||
|
||||
if (mode.StartsWith("Generate"))
|
||||
{
|
||||
var description = AnsiConsole.Ask<string>("Describe what you want this profile to do:");
|
||||
|
||||
var generatedPrompt = await AnsiConsole.Status()
|
||||
.Spinner(Spinner.Known.Dots)
|
||||
.StartAsync("Generating system prompt...", async _ =>
|
||||
await GenerateSystemPromptAsync(description));
|
||||
|
||||
if (generatedPrompt == null)
|
||||
{
|
||||
context.ExitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
initialContent =
|
||||
$"# Hush profile — AI-generated for: {description}\n" +
|
||||
"# Review and adjust as needed, then save and close your editor.\n" +
|
||||
"\n" +
|
||||
"system_prompt = \"\"\"\n" +
|
||||
$"{generatedPrompt}\n" +
|
||||
"\"\"\"\n";
|
||||
}
|
||||
else
|
||||
{
|
||||
initialContent = ProfileTemplate;
|
||||
}
|
||||
|
||||
manager.EnsureProfilesDirExists();
|
||||
File.WriteAllText(path, initialContent);
|
||||
|
||||
OpenInEditor(path);
|
||||
AnsiConsole.MarkupLine($"[green]Profile '{name}' saved.[/]");
|
||||
});
|
||||
return cmd;
|
||||
}
|
||||
|
||||
// ── edit ─────────────────────────────────────────────────────────────────
|
||||
|
||||
private static Command CreateEditCommand()
|
||||
{
|
||||
var nameArg = new Argument<string>("name", "Profile name");
|
||||
var cmd = new Command("edit", "Edit an existing profile in $EDITOR");
|
||||
cmd.AddArgument(nameArg);
|
||||
|
||||
cmd.SetHandler((context) =>
|
||||
{
|
||||
var name = context.ParseResult.GetValueForArgument(nameArg);
|
||||
var manager = new ConfigManager();
|
||||
var path = manager.GetProfilePath(name);
|
||||
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
AnsiConsole.MarkupLine($"[red]Profile '{name}' not found. Use 'hush profiles new {name}' to create it.[/]");
|
||||
context.ExitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
OpenInEditor(path);
|
||||
});
|
||||
return cmd;
|
||||
}
|
||||
|
||||
// ── helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
private static async Task<string?> GenerateSystemPromptAsync(string description)
|
||||
{
|
||||
try
|
||||
{
|
||||
await using var client = new SocketClient();
|
||||
await client.ConnectAsync(TimeSpan.FromSeconds(2));
|
||||
|
||||
var request = new GenerateProfileRequest(description);
|
||||
await client.SendRequestAsync(
|
||||
DaemonProtocol.GENERATE_PROFILE,
|
||||
request,
|
||||
DaemonJsonContext.Default.GenerateProfileRequest);
|
||||
|
||||
var json = await client.ReceiveRawJsonAsync(TimeSpan.FromSeconds(30));
|
||||
if (json == null)
|
||||
{
|
||||
AnsiConsole.MarkupLine("[red]No response from daemon.[/]");
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check for error response first
|
||||
var error = JsonSerializer.Deserialize(json, DaemonJsonContext.Default.ErrorResponse);
|
||||
if (error?.Error != null)
|
||||
{
|
||||
AnsiConsole.MarkupLine($"[red]Daemon error: {error.Error}[/]");
|
||||
return null;
|
||||
}
|
||||
|
||||
var result = JsonSerializer.Deserialize(json, DaemonJsonContext.Default.GenerateProfileResponse);
|
||||
return result?.SystemPrompt;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AnsiConsole.MarkupLine($"[red]Error: {ex.Message}[/]");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static void OpenInEditor(string path)
|
||||
{
|
||||
var editor = Environment.GetEnvironmentVariable("EDITOR");
|
||||
if (string.IsNullOrEmpty(editor))
|
||||
editor = "nano";
|
||||
|
||||
try
|
||||
{
|
||||
var process = new System.Diagnostics.Process
|
||||
{
|
||||
StartInfo = new System.Diagnostics.ProcessStartInfo
|
||||
{
|
||||
FileName = editor,
|
||||
Arguments = $"\"{path}\"",
|
||||
UseShellExecute = false
|
||||
}
|
||||
};
|
||||
process.Start();
|
||||
process.WaitForExit();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AnsiConsole.MarkupLine($"[red]Could not open editor '{editor}': {ex.Message}[/]");
|
||||
AnsiConsole.MarkupLine($"Profile saved at: {path}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.CommandLine;
|
||||
using Hush.Config;
|
||||
using Hush.Daemon;
|
||||
using Spectre.Console;
|
||||
|
||||
@@ -9,13 +10,28 @@ public static class StopCommand
|
||||
public static Command Create()
|
||||
{
|
||||
var command = new Command("stop", "Stop recording and process");
|
||||
|
||||
var profileOption = new Option<string?>(["--profile", "-p"], "Profile name to apply when processing");
|
||||
command.AddOption(profileOption);
|
||||
|
||||
command.SetHandler(async (context) =>
|
||||
{
|
||||
var profileName = context.ParseResult.GetValueForOption(profileOption);
|
||||
try
|
||||
{
|
||||
await using var client = new SocketClient();
|
||||
await client.ConnectAsync(TimeSpan.FromSeconds(2));
|
||||
await client.SendCommandAsync(DaemonProtocol.STOP);
|
||||
|
||||
if (!string.IsNullOrEmpty(profileName))
|
||||
{
|
||||
var config = new ConfigManager().LoadWithProfile(profileName);
|
||||
await client.SendCommandWithConfigAsync(DaemonProtocol.STOP, config);
|
||||
}
|
||||
else
|
||||
{
|
||||
await client.SendCommandAsync(DaemonProtocol.STOP);
|
||||
}
|
||||
|
||||
AnsiConsole.MarkupLine("[green]Stop command sent[/]");
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.CommandLine;
|
||||
using Hush.Config;
|
||||
using Hush.Daemon;
|
||||
using Spectre.Console;
|
||||
|
||||
@@ -9,13 +10,28 @@ public static class ToggleCommand
|
||||
public static Command Create()
|
||||
{
|
||||
var command = new Command("toggle", "Toggle recording (start if idle, stop if recording)");
|
||||
|
||||
var profileOption = new Option<string?>(["--profile", "-p"], "Profile name to apply when processing stops");
|
||||
command.AddOption(profileOption);
|
||||
|
||||
command.SetHandler(async (context) =>
|
||||
{
|
||||
var profileName = context.ParseResult.GetValueForOption(profileOption);
|
||||
try
|
||||
{
|
||||
await using var client = new SocketClient();
|
||||
await client.ConnectAsync(TimeSpan.FromSeconds(2));
|
||||
await client.SendCommandAsync(DaemonProtocol.TOGGLE);
|
||||
|
||||
if (!string.IsNullOrEmpty(profileName))
|
||||
{
|
||||
var config = new ConfigManager().LoadWithProfile(profileName);
|
||||
await client.SendCommandWithConfigAsync(DaemonProtocol.TOGGLE, config);
|
||||
}
|
||||
else
|
||||
{
|
||||
await client.SendCommandAsync(DaemonProtocol.TOGGLE);
|
||||
}
|
||||
|
||||
AnsiConsole.MarkupLine("[green]Toggle command sent[/]");
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
using System.Net.Sockets;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization.Metadata;
|
||||
using Hush.Config;
|
||||
using Hush.Daemon;
|
||||
|
||||
namespace Hush.Cli;
|
||||
@@ -15,7 +17,7 @@ public class SocketClient : IAsyncDisposable
|
||||
var runtimeDir = Environment.GetEnvironmentVariable("XDG_RUNTIME_DIR");
|
||||
var baseDir = string.IsNullOrEmpty(runtimeDir) ? Path.GetTempPath() : runtimeDir;
|
||||
var socketPath = Path.Combine(baseDir, "hush.sock");
|
||||
|
||||
|
||||
_endPoint = new UnixDomainSocketEndPoint(socketPath);
|
||||
_socket = new Socket(AddressFamily.Unix, SocketType.Stream, ProtocolType.Unspecified);
|
||||
}
|
||||
@@ -26,9 +28,58 @@ public class SocketClient : IAsyncDisposable
|
||||
await _socket.ConnectAsync(_endPoint, cts.Token);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sends a command with no config payload.
|
||||
/// Action commands (START/STOP/TOGGLE/ABORT) always include a 4-byte zero length prefix
|
||||
/// so the daemon can read the same framing unconditionally.
|
||||
/// </summary>
|
||||
public async Task SendCommandAsync(byte command)
|
||||
{
|
||||
await _socket.SendAsync(new[] { command }, SocketFlags.None);
|
||||
if (IsActionCommand(command))
|
||||
{
|
||||
// [cmd][4 zero bytes] — signals no config override
|
||||
var frame = new byte[5];
|
||||
frame[0] = command;
|
||||
await _socket.SendAsync(frame, SocketFlags.None);
|
||||
}
|
||||
else
|
||||
{
|
||||
await _socket.SendAsync(new[] { command }, SocketFlags.None);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sends an action command with a HushConfig override payload.
|
||||
/// Format: [1 byte cmd][4-byte LE length][N bytes JSON]
|
||||
/// </summary>
|
||||
public async Task SendCommandWithConfigAsync(byte command, HushConfig config)
|
||||
{
|
||||
var jsonBytes = JsonSerializer.SerializeToUtf8Bytes(config, HushConfigContext.Default.HushConfig);
|
||||
var lenBytes = BitConverter.GetBytes(jsonBytes.Length);
|
||||
|
||||
var frame = new byte[1 + 4 + jsonBytes.Length];
|
||||
frame[0] = command;
|
||||
lenBytes.CopyTo(frame, 1);
|
||||
jsonBytes.CopyTo(frame, 5);
|
||||
|
||||
await _socket.SendAsync(frame, SocketFlags.None);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sends a request with a typed JSON payload (e.g. GENERATE_PROFILE).
|
||||
/// Format: [1 byte cmd][4-byte LE length][N bytes JSON]
|
||||
/// </summary>
|
||||
public async Task SendRequestAsync<TRequest>(byte command, TRequest payload, JsonTypeInfo<TRequest> typeInfo)
|
||||
{
|
||||
var jsonBytes = JsonSerializer.SerializeToUtf8Bytes(payload, typeInfo);
|
||||
var lenBytes = BitConverter.GetBytes(jsonBytes.Length);
|
||||
|
||||
var frame = new byte[1 + 4 + jsonBytes.Length];
|
||||
frame[0] = command;
|
||||
lenBytes.CopyTo(frame, 1);
|
||||
jsonBytes.CopyTo(frame, 5);
|
||||
|
||||
await _socket.SendAsync(frame, SocketFlags.None);
|
||||
}
|
||||
|
||||
public async Task<T?> ReceiveJsonAsync<T>(TimeSpan timeout)
|
||||
@@ -36,10 +87,10 @@ public class SocketClient : IAsyncDisposable
|
||||
var cts = new CancellationTokenSource(timeout);
|
||||
var buffer = new byte[4096];
|
||||
var bytesRead = await _socket.ReceiveAsync(buffer, SocketFlags.None, cts.Token);
|
||||
|
||||
|
||||
if (bytesRead == 0)
|
||||
return default;
|
||||
|
||||
|
||||
var json = Encoding.UTF8.GetString(buffer, 0, bytesRead);
|
||||
return (T?)JsonSerializer.Deserialize(json, typeof(T), DaemonJsonContext.Default);
|
||||
}
|
||||
@@ -61,4 +112,8 @@ public class SocketClient : IAsyncDisposable
|
||||
_socket.Dispose();
|
||||
await ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
private static bool IsActionCommand(byte command) =>
|
||||
command is DaemonProtocol.START or DaemonProtocol.STOP
|
||||
or DaemonProtocol.ABORT or DaemonProtocol.TOGGLE;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user