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:
2026-03-23 00:38:29 +01:00
parent 70e784a1cc
commit eb0619dea2
14 changed files with 659 additions and 372 deletions
+250
View File
@@ -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}");
}
}
}
+17 -1
View File
@@ -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)
+17 -1
View File
@@ -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)
+59 -4
View File
@@ -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;
}