commit 765ff7a976abf4776e3f190aa8152a5f4358dad3 Author: kryptomrx Date: Sat May 16 19:25:39 2026 +0200 Initial commit: noz-cli REPL skeleton diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0c851da --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +bin/ +obj/ +*.user +.vs/ +~/.config/noz/ diff --git a/NozCli.Core/Client/HashHelper.cs b/NozCli.Core/Client/HashHelper.cs new file mode 100644 index 0000000..b16d03f --- /dev/null +++ b/NozCli.Core/Client/HashHelper.cs @@ -0,0 +1,10 @@ +using System.Security.Cryptography; +using System.Text; + +namespace NozCli.Core.Client; + +public static class HashHelper +{ + public static string Sha256(string text) => + Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(text))).ToLower(); +} diff --git a/NozCli.Core/Client/NozApiClient.cs b/NozCli.Core/Client/NozApiClient.cs new file mode 100644 index 0000000..6a5a03d --- /dev/null +++ b/NozCli.Core/Client/NozApiClient.cs @@ -0,0 +1,87 @@ +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Text; +using System.Text.Json; +using NozCli.Core.Config; +using NozCli.Core.Models; + +namespace NozCli.Core.Client; + +public class NozApiClient : IDisposable +{ + private readonly HttpClient _http; + + public NozApiClient(LocalConfig cfg) + { + _http = new HttpClient + { + BaseAddress = new Uri(cfg.ServerUrl.TrimEnd('/') + "/"), + // Enforce TLS — reject plain HTTP at the OS level + DefaultRequestVersion = new Version(2, 0), + }; + _http.DefaultRequestHeaders.Authorization = + new AuthenticationHeaderValue("Bearer", cfg.Token); + } + + public async Task GetConfigAsync() + { + var res = await _http.GetFromJsonAsync("api/cli/config"); + return new ServerCapabilities( + Server: res.GetProperty("server").GetString()!, + Enabled: res.GetProperty("enabled").GetBoolean(), + AllowPush: res.GetProperty("allow_push").GetBoolean(), + AllowDelete: res.GetProperty("allow_delete").GetBoolean(), + AllowVault: res.GetProperty("allow_vault").GetBoolean(), + AllowRebuild: res.GetProperty("allow_rebuild").GetBoolean() + ); + } + + public async Task> GetNotesAsync() + { + var res = await _http.GetFromJsonAsync("api/cli/notes"); + var notes = res.GetProperty("notes"); + return notes.EnumerateArray().Select(n => new NoteInfo( + Slug: n.GetProperty("slug").GetString()!, + Title: n.GetProperty("title").GetString()!, + Status: n.GetProperty("status").GetString()!, + Topic: n.GetProperty("topic").GetString()!, + Lang: n.GetProperty("lang").GetString()!, + Private: n.GetProperty("private").GetBoolean(), + Hash: n.TryGetProperty("hash", out var h) ? h.GetString() : null + )).ToList(); + } + + public async Task GetNoteAsync(string slug) + { + var res = await _http.GetFromJsonAsync($"api/cli/notes/{slug}"); + return new NoteContent( + Slug: res.GetProperty("slug").GetString()!, + Markdown: res.GetProperty("markdown").GetString()!, + Hash: res.GetProperty("hash").GetString()! + ); + } + + public async Task PutNoteAsync(string slug, string markdown) + { + var body = JsonSerializer.Serialize(new { markdown }); + var content = new StringContent(body, Encoding.UTF8, "application/json"); + var res = await _http.PutAsync($"api/cli/notes/{slug}", content); + res.EnsureSuccessStatusCode(); + var json = await res.Content.ReadFromJsonAsync(); + return json.GetProperty("hash").GetString()!; + } + + public async Task DeleteNoteAsync(string slug) + { + var res = await _http.DeleteAsync($"api/cli/notes/{slug}"); + res.EnsureSuccessStatusCode(); + } + + public async Task RebuildIndexAsync() + { + var res = await _http.PostAsync("api/cli/rebuild", null); + res.EnsureSuccessStatusCode(); + } + + public void Dispose() => _http.Dispose(); +} diff --git a/NozCli.Core/Config/LocalConfig.cs b/NozCli.Core/Config/LocalConfig.cs new file mode 100644 index 0000000..9678e0f --- /dev/null +++ b/NozCli.Core/Config/LocalConfig.cs @@ -0,0 +1,40 @@ +using System.Text.Json; + +namespace NozCli.Core.Config; + +public class LocalConfig +{ + public string ServerUrl { get; set; } = string.Empty; + public string Token { get; set; } = string.Empty; + + // ~/.config/noz/config.json + private static readonly string ConfigPath = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + ".config", "noz", "config.json" + ); + + public static LocalConfig? Load() + { + if (!File.Exists(ConfigPath)) return null; + var json = File.ReadAllText(ConfigPath); + return JsonSerializer.Deserialize(json, JsonOptions); + } + + public void Save() + { + Directory.CreateDirectory(Path.GetDirectoryName(ConfigPath)!); + // 0600 — only owner can read + var json = JsonSerializer.Serialize(this, JsonOptions); + File.WriteAllText(ConfigPath, json); + if (!OperatingSystem.IsWindows()) + File.SetUnixFileMode(ConfigPath, UnixFileMode.UserRead | UnixFileMode.UserWrite); + } + + public static bool Exists() => File.Exists(ConfigPath); + + private static readonly JsonSerializerOptions JsonOptions = new() + { + WriteIndented = true, + PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, + }; +} diff --git a/NozCli.Core/Models/NoteInfo.cs b/NozCli.Core/Models/NoteInfo.cs new file mode 100644 index 0000000..584288c --- /dev/null +++ b/NozCli.Core/Models/NoteInfo.cs @@ -0,0 +1,17 @@ +namespace NozCli.Core.Models; + +public record NoteInfo( + string Slug, + string Title, + string Status, + string Topic, + string Lang, + bool Private, + string? Hash +); + +public record NoteContent( + string Slug, + string Markdown, + string Hash +); diff --git a/NozCli.Core/Models/ServerCapabilities.cs b/NozCli.Core/Models/ServerCapabilities.cs new file mode 100644 index 0000000..6b0edc9 --- /dev/null +++ b/NozCli.Core/Models/ServerCapabilities.cs @@ -0,0 +1,10 @@ +namespace NozCli.Core.Models; + +public record ServerCapabilities( + string Server, + bool Enabled, + bool AllowPush, + bool AllowDelete, + bool AllowVault, + bool AllowRebuild +); diff --git a/NozCli.Core/NozCli.Core.csproj b/NozCli.Core/NozCli.Core.csproj new file mode 100644 index 0000000..758aaa1 --- /dev/null +++ b/NozCli.Core/NozCli.Core.csproj @@ -0,0 +1,13 @@ + + + + net10.0 + enable + enable + + + + + + + diff --git a/NozCli/Commands/CatCommand.cs b/NozCli/Commands/CatCommand.cs new file mode 100644 index 0000000..f5ff990 --- /dev/null +++ b/NozCli/Commands/CatCommand.cs @@ -0,0 +1,25 @@ +using NozCli.Repl; +using Spectre.Console; + +namespace NozCli.Commands; + +public class CatCommand : ICommand +{ + public string Name => "cat"; + public string Description => "Print the raw markdown of a note"; + public string Usage => "cat "; + + public async Task ExecuteAsync(string[] args, ReplContext ctx) + { + var slug = args.FirstOrDefault(); + if (slug is null) { AnsiConsole.MarkupLine("[red]Usage: cat [/]"); return; } + + slug = ctx.ResolveSlug(slug); + + await AnsiConsole.Status().StartAsync("Fetching…", async _ => + { + var note = await ctx.Api.GetNoteAsync(slug); + AnsiConsole.WriteLine(note.Markdown); + }); + } +} diff --git a/NozCli/Commands/CdCommand.cs b/NozCli/Commands/CdCommand.cs new file mode 100644 index 0000000..ba99978 --- /dev/null +++ b/NozCli/Commands/CdCommand.cs @@ -0,0 +1,48 @@ +using NozCli.Repl; +using Spectre.Console; + +namespace NozCli.Commands; + +public class CdCommand : ICommand +{ + public string Name => "cd"; + public string Description => "Navigate into a topic or folder"; + public string Usage => "cd | cd .. | cd ~"; + + public async Task ExecuteAsync(string[] args, ReplContext ctx) + { + var target = args.FirstOrDefault(); + + if (target is null or "~") + { + ctx.CurrentPath = "~"; + return; + } + + if (target == "..") + { + var parts = ctx.CurrentPath.TrimEnd('/').Split('/'); + ctx.CurrentPath = parts.Length > 1 + ? string.Join("/", parts[..^1]) + : "~"; + return; + } + + var candidate = ctx.CurrentPath == "~" + ? $"~/noosphere/{target}" + : $"{ctx.CurrentPath}/{target}"; + + // Check the path has at least one note under it + var prefix = candidate.Replace("~/noosphere/", "").Replace("~/noosphere", ""); + var exists = ctx.NoteCache.Any(n => n.Slug.StartsWith(prefix + "/") || n.Slug == prefix); + + if (!exists) + { + AnsiConsole.MarkupLine($"[red]cd: {target}: no such folder[/]"); + return; + } + + ctx.CurrentPath = candidate; + await Task.CompletedTask; + } +} diff --git a/NozCli/Commands/EditCommand.cs b/NozCli/Commands/EditCommand.cs new file mode 100644 index 0000000..1d566b1 --- /dev/null +++ b/NozCli/Commands/EditCommand.cs @@ -0,0 +1,97 @@ +using System.Diagnostics; +using System.Security.Cryptography; +using System.Text; +using NozCli.Repl; +using Spectre.Console; + +namespace NozCli.Commands; + +public class EditCommand : ICommand +{ + public string Name => "edit"; + public string Description => "Open a note in your default editor, then offer to push"; + public string Usage => "edit "; + + public async Task ExecuteAsync(string[] args, ReplContext ctx) + { + var slug = args.FirstOrDefault(); + if (slug is null) { AnsiConsole.MarkupLine("[red]Usage: edit [/]"); return; } + + slug = ctx.ResolveSlug(slug); + + // 1. Fetch note from server + NozCli.Core.Models.NoteContent note = default!; + await AnsiConsole.Status().StartAsync("Fetching…", async _ => + note = await ctx.Api.GetNoteAsync(slug)); + + // 2. Write to temp file + var tmp = Path.Combine(Path.GetTempPath(), $"noz-{slug.Replace('/', '-')}.md"); + await File.WriteAllTextAsync(tmp, note.Markdown); + + // 3. Open editor + var editor = ResolveEditor(); + AnsiConsole.MarkupLine($"[dim]Opening {editor} …[/]"); + var proc = Process.Start(new ProcessStartInfo + { + FileName = editor, + Arguments = $"\"{tmp}\"", + UseShellExecute = !OperatingSystem.IsWindows(), + }); + proc?.WaitForExit(); + + // 4. Compare hashes + var edited = await File.ReadAllTextAsync(tmp); + var newHash = HashContent(edited); + + File.Delete(tmp); + + if (newHash == note.Hash) + { + AnsiConsole.MarkupLine("[dim]No changes.[/]"); + return; + } + + // 5. Ask to push + AnsiConsole.MarkupLine("[green]Changes detected.[/]"); + var push = AnsiConsole.Confirm("Push changes to server?"); + if (!push) { AnsiConsole.MarkupLine("[dim]Discarded.[/]"); return; } + + await AnsiConsole.Status().StartAsync("Pushing…", async _ => + await ctx.Api.PutNoteAsync(slug, edited)); + + AnsiConsole.MarkupLine($"[green]✓[/] {slug} updated"); + } + + private static string ResolveEditor() + { + // Priority: $EDITOR → $VISUAL → VS Code → nano → vi + var env = Environment.GetEnvironmentVariable("EDITOR") + ?? Environment.GetEnvironmentVariable("VISUAL"); + if (env is not null) return env; + + foreach (var candidate in new[] { "code", "nano", "vi", "notepad" }) + if (IsOnPath(candidate)) return candidate; + + return "nano"; + } + + private static bool IsOnPath(string name) + { + try + { + var result = Process.Start(new ProcessStartInfo + { + FileName = OperatingSystem.IsWindows() ? "where" : "which", + Arguments = name, + RedirectStandardOutput = true, + UseShellExecute = false, + }); + result?.WaitForExit(); + return result?.ExitCode == 0; + } + catch { return false; } + } + + private static string HashContent(string content) => + Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(content))).ToLower(); +} diff --git a/NozCli/Commands/HelpCommand.cs b/NozCli/Commands/HelpCommand.cs new file mode 100644 index 0000000..f75fabe --- /dev/null +++ b/NozCli/Commands/HelpCommand.cs @@ -0,0 +1,27 @@ +using NozCli.Repl; +using Spectre.Console; + +namespace NozCli.Commands; + +public class HelpCommand : ICommand +{ + public string Name => "help"; + public string Description => "Show available commands"; + public string Usage => "help"; + + private readonly IReadOnlyList _commands; + + public HelpCommand(IReadOnlyList commands) => _commands = commands; + + public Task ExecuteAsync(string[] args, ReplContext ctx) + { + var table = new Table().BorderStyle(Style.Plain).HideHeaders(); + table.AddColumns("cmd", "desc"); + + foreach (var cmd in _commands.OrderBy(c => c.Name)) + table.AddRow($"[cyan]{cmd.Usage,-28}[/]", $"[dim]{cmd.Description}[/]"); + + AnsiConsole.Write(table); + return Task.CompletedTask; + } +} diff --git a/NozCli/Commands/ICommand.cs b/NozCli/Commands/ICommand.cs new file mode 100644 index 0000000..2208a18 --- /dev/null +++ b/NozCli/Commands/ICommand.cs @@ -0,0 +1,12 @@ +using NozCli.Repl; + +namespace NozCli.Commands; + +public interface ICommand +{ + string Name { get; } + string Description { get; } + string Usage { get; } + + Task ExecuteAsync(string[] args, ReplContext ctx); +} diff --git a/NozCli/Commands/LsCommand.cs b/NozCli/Commands/LsCommand.cs new file mode 100644 index 0000000..b2f0916 --- /dev/null +++ b/NozCli/Commands/LsCommand.cs @@ -0,0 +1,79 @@ +using NozCli.Repl; +using Spectre.Console; + +namespace NozCli.Commands; + +public class LsCommand : ICommand +{ + public string Name => "ls"; + public string Description => "List notes and folders at current path"; + public string Usage => "ls [-l]"; + + public async Task ExecuteAsync(string[] args, ReplContext ctx) + { + var detailed = args.Contains("-l"); + var folders = ctx.FoldersInView().OrderBy(f => f).ToList(); + var prefix = ctx.PathPrefix(); + + // Determine which segments are folders (have children) vs direct notes + var directNotes = ctx.NotesInView() + .Where(n => + { + var depth = prefix is null ? 0 : prefix.Split('/').Length; + return n.Slug.Split('/').Length == depth + 1; + }) + .OrderBy(n => n.Title) + .ToList(); + + var subFolders = ctx.FoldersInView() + .Where(f => directNotes.All(n => n.Slug.Split('/').Last() != f)) + .OrderBy(f => f) + .ToList(); + + if (!detailed) + { + foreach (var folder in subFolders) + AnsiConsole.MarkupLine($" [blue]{folder}/[/]"); + foreach (var note in directNotes) + AnsiConsole.MarkupLine($" [white]{note.Slug.Split('/').Last()}[/]"); + return; + } + + var table = new Table().BorderStyle(Style.Plain).HideHeaders(); + table.AddColumns("", "name", "status", "lang", "title"); + + foreach (var folder in subFolders) + { + var count = ctx.NotesInView() + .Count(n => n.Slug.StartsWith((prefix is null ? "" : prefix + "/") + folder + "/")); + table.AddRow( + "[blue]📁[/]", + $"[blue]{folder}/[/]", + $"[dim]{count} notes[/]", + "", + "" + ); + } + + foreach (var note in directNotes) + { + var statusColor = note.Status switch + { + "seed" => "yellow", + "budding" => "purple", + "evergreen" => "teal", + _ => "grey" + }; + table.AddRow( + "●", + $"[white]{note.Slug.Split('/').Last()}[/]", + $"[{statusColor}]{note.Status}[/]", + $"[dim]{note.Lang}[/]", + $"[dim]{note.Title}[/]" + ); + } + + AnsiConsole.Write(table); + await Task.CompletedTask; + } +} diff --git a/NozCli/Commands/PushCommand.cs b/NozCli/Commands/PushCommand.cs new file mode 100644 index 0000000..0f5dbe4 --- /dev/null +++ b/NozCli/Commands/PushCommand.cs @@ -0,0 +1,87 @@ +using NozCli.Repl; +using Spectre.Console; + +namespace NozCli.Commands; + +/// push — reads .md files from a local directory and uploads changed ones. +public class PushCommand : ICommand +{ + public string Name => "push"; + public string Description => "Push local markdown files to the server"; + public string Usage => "push [--all]"; + + public async Task ExecuteAsync(string[] args, ReplContext ctx) + { + var localPath = args.FirstOrDefault(a => !a.StartsWith("--")); + var force = args.Contains("--all"); + + if (localPath is null) + { + AnsiConsole.MarkupLine("[red]Usage: push [--all][/]"); + return; + } + + localPath = Path.GetFullPath(localPath); + if (!Directory.Exists(localPath)) + { + AnsiConsole.MarkupLine($"[red]Directory not found: {localPath}[/]"); + return; + } + + var files = Directory.EnumerateFiles(localPath, "*.md", SearchOption.AllDirectories) + .Where(f => !Path.GetFileName(f).StartsWith("_")) + .ToList(); + + if (files.Count == 0) + { + AnsiConsole.MarkupLine("[yellow]No markdown files found.[/]"); + return; + } + + // Build slug → server hash map for incremental push + var serverHashes = ctx.NoteCache.ToDictionary(n => n.Slug, n => n.Hash ?? ""); + + int pushed = 0, skipped = 0, failed = 0; + + await AnsiConsole.Progress() + .Columns(new TaskDescriptionColumn(), new ProgressBarColumn(), new SpinnerColumn()) + .StartAsync(async progress => + { + var task = progress.AddTask("Pushing notes…", maxValue: files.Count); + + foreach (var file in files) + { + var relative = Path.GetRelativePath(localPath, file); + var slug = relative.Replace(Path.DirectorySeparatorChar, '/').Replace(".md", ""); + + var markdown = await File.ReadAllTextAsync(file); + var localHash = NozCli.Core.Client.HashHelper.Sha256(markdown); + + if (!force && serverHashes.TryGetValue(slug, out var serverHash) && serverHash == localHash) + { + skipped++; + task.Increment(1); + continue; + } + + try + { + await ctx.Api.PutNoteAsync(slug, markdown); + pushed++; + } + catch (Exception ex) + { + AnsiConsole.MarkupLine($"[red]✗ {slug}: {ex.Message}[/]"); + failed++; + } + + task.Increment(1); + } + }); + + AnsiConsole.MarkupLine($"[green]↑ {pushed} pushed[/] [dim]{skipped} unchanged {(failed > 0 ? $"[red]{failed} failed[/]" : "")}[/]"); + + // Refresh note cache + ctx.NoteCache = await ctx.Api.GetNotesAsync(); + } +} diff --git a/NozCli/Commands/RmCommand.cs b/NozCli/Commands/RmCommand.cs new file mode 100644 index 0000000..8a63002 --- /dev/null +++ b/NozCli/Commands/RmCommand.cs @@ -0,0 +1,28 @@ +using NozCli.Repl; +using Spectre.Console; + +namespace NozCli.Commands; + +public class RmCommand : ICommand +{ + public string Name => "rm"; + public string Description => "Delete a note from the server"; + public string Usage => "rm "; + + public async Task ExecuteAsync(string[] args, ReplContext ctx) + { + var slug = args.FirstOrDefault(); + if (slug is null) { AnsiConsole.MarkupLine("[red]Usage: rm [/]"); return; } + + slug = ctx.ResolveSlug(slug); + + if (!AnsiConsole.Confirm($"[red]Delete[/] {slug} from server?", defaultValue: false)) + return; + + await AnsiConsole.Status().StartAsync("Deleting…", async _ => + await ctx.Api.DeleteNoteAsync(slug)); + + ctx.NoteCache.RemoveAll(n => n.Slug == slug); + AnsiConsole.MarkupLine($"[green]✓[/] {slug} deleted"); + } +} diff --git a/NozCli/Commands/StatusCommand.cs b/NozCli/Commands/StatusCommand.cs new file mode 100644 index 0000000..fc7401d --- /dev/null +++ b/NozCli/Commands/StatusCommand.cs @@ -0,0 +1,55 @@ +using System.Security.Cryptography; +using System.Text; +using NozCli.Repl; +using Spectre.Console; + +namespace NozCli.Commands; + +public class StatusCommand : ICommand +{ + public string Name => "status"; + public string Description => "Show diff between a local folder and the server"; + public string Usage => "status "; + + public async Task ExecuteAsync(string[] args, ReplContext ctx) + { + var localPath = args.FirstOrDefault(); + if (localPath is null) { AnsiConsole.MarkupLine("[red]Usage: status [/]"); return; } + + localPath = Path.GetFullPath(localPath); + if (!Directory.Exists(localPath)) { AnsiConsole.MarkupLine($"[red]Not found: {localPath}[/]"); return; } + + var serverMap = ctx.NoteCache.ToDictionary(n => n.Slug, n => n.Hash ?? ""); + var localFiles = Directory.EnumerateFiles(localPath, "*.md", SearchOption.AllDirectories) + .Where(f => !Path.GetFileName(f).StartsWith("_")); + + var added = new List(); + var modified = new List(); + + foreach (var file in localFiles) + { + var slug = Path.GetRelativePath(localPath, file).Replace(Path.DirectorySeparatorChar, '/').Replace(".md", ""); + var markdown = await File.ReadAllTextAsync(file); + var localHash = Sha256(markdown); + + if (!serverMap.ContainsKey(slug)) + added.Add(slug); + else if (serverMap[slug] != localHash) + modified.Add(slug); + } + + if (added.Count == 0 && modified.Count == 0) + { + AnsiConsole.MarkupLine("[green]✓ Everything up to date[/]"); + return; + } + + foreach (var s in added) AnsiConsole.MarkupLine($" [green]+[/] {s}"); + foreach (var s in modified) AnsiConsole.MarkupLine($" [yellow]~[/] {s}"); + + AnsiConsole.MarkupLine($"\n[dim]{added.Count} new · {modified.Count} modified[/]"); + } + + private static string Sha256(string text) => + Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(text))).ToLower(); +} diff --git a/NozCli/Commands/SyncCommand.cs b/NozCli/Commands/SyncCommand.cs new file mode 100644 index 0000000..9380ff9 --- /dev/null +++ b/NozCli/Commands/SyncCommand.cs @@ -0,0 +1,24 @@ +using NozCli.Repl; +using Spectre.Console; + +namespace NozCli.Commands; + +/// sync = push + rebuild index +public class SyncCommand : ICommand +{ + public string Name => "sync"; + public string Description => "Push notes and rebuild the server index"; + public string Usage => "sync "; + + private readonly PushCommand _push = new(); + + public async Task ExecuteAsync(string[] args, ReplContext ctx) + { + await _push.ExecuteAsync(args, ctx); + + await AnsiConsole.Status().StartAsync("Triggering index rebuild…", async _ => + await ctx.Api.RebuildIndexAsync()); + + AnsiConsole.MarkupLine("[green]✓ Index rebuild queued[/]"); + } +} diff --git a/NozCli/NozCli.csproj b/NozCli/NozCli.csproj new file mode 100644 index 0000000..5dc2529 --- /dev/null +++ b/NozCli/NozCli.csproj @@ -0,0 +1,18 @@ + + + + + + + + + + + + Exe + net10.0 + enable + enable + + + diff --git a/NozCli/Program.cs b/NozCli/Program.cs new file mode 100644 index 0000000..018abb4 --- /dev/null +++ b/NozCli/Program.cs @@ -0,0 +1,30 @@ +using NozCli.Core.Config; +using NozCli.Repl; +using Spectre.Console; + +// ── noz init ──────────────────────────────────────────────────── +if (args is ["init", var url, var token]) +{ + if (!url.StartsWith("https://", StringComparison.OrdinalIgnoreCase)) + { + AnsiConsole.MarkupLine("[red]Error:[/] Only HTTPS servers are allowed."); + return 1; + } + + var cfg = new LocalConfig { ServerUrl = url, Token = token }; + cfg.Save(); + AnsiConsole.MarkupLine($"[green]✓[/] Config saved → ~/.config/noz/config.json"); + return 0; +} + +// ── All other commands need a saved config ──────────────────────────────────── +var config = LocalConfig.Load(); +if (config is null) +{ + AnsiConsole.MarkupLine("[red]Not initialised.[/] Run: [cyan]noz init [/]"); + return 1; +} + +// ── Interactive REPL ────────────────────────────────────────────────────────── +await ReplSession.RunAsync(config); +return 0; diff --git a/NozCli/Repl/CommandRegistry.cs b/NozCli/Repl/CommandRegistry.cs new file mode 100644 index 0000000..15904cc --- /dev/null +++ b/NozCli/Repl/CommandRegistry.cs @@ -0,0 +1,34 @@ +using NozCli.Commands; + +namespace NozCli.Repl; + +public class CommandRegistry +{ + private readonly Dictionary _map; + + public IReadOnlyList All { get; } + + public CommandRegistry() + { + var list = new List + { + new LsCommand(), + new CdCommand(), + new CatCommand(), + new EditCommand(), + new PushCommand(), + new SyncCommand(), + new StatusCommand(), + new RmCommand(), + }; + // HelpCommand gets the full list so it can print them all + list.Add(new HelpCommand(list)); + + All = list.AsReadOnly(); + _map = list.ToDictionary(c => c.Name, StringComparer.OrdinalIgnoreCase); + } + + public ICommand? Find(string name) => _map.GetValueOrDefault(name); + + public IEnumerable Names => _map.Keys; +} diff --git a/NozCli/Repl/NozAutoComplete.cs b/NozCli/Repl/NozAutoComplete.cs new file mode 100644 index 0000000..3ecd90f --- /dev/null +++ b/NozCli/Repl/NozAutoComplete.cs @@ -0,0 +1,3 @@ +namespace NozCli.Repl; +// Autocomplete is now handled directly in ReplInput.cs. +// This file is kept as a placeholder for future PowerShell module integration. diff --git a/NozCli/Repl/ReplContext.cs b/NozCli/Repl/ReplContext.cs new file mode 100644 index 0000000..df7822b --- /dev/null +++ b/NozCli/Repl/ReplContext.cs @@ -0,0 +1,54 @@ +using NozCli.Core.Client; +using NozCli.Core.Models; + +namespace NozCli.Repl; + +/// Shared mutable state across the REPL session. +public class ReplContext +{ + public NozApiClient Api { get; } + public string CurrentPath { get; set; } = "~"; + public List NoteCache { get; set; } = []; + public bool Running { get; set; } = true; + + public ReplContext(NozApiClient api) => Api = api; + + /// Returns the topic/folder prefix implied by CurrentPath, or null if at root. + public string? PathPrefix() + { + if (CurrentPath is "~" or "~/noosphere") return null; + var stripped = CurrentPath.Replace("~/noosphere/", "").Replace("~/noosphere", ""); + return string.IsNullOrEmpty(stripped) ? null : stripped; + } + + /// Notes visible at the current path level. + public IEnumerable NotesInView() + { + var prefix = PathPrefix(); + return prefix is null + ? NoteCache + : NoteCache.Where(n => n.Slug.StartsWith(prefix + "/")); + } + + /// Resolves a short name (e.g. "hydroponik") to a full slug using the current path as prefix. + public string ResolveSlug(string nameOrSlug) + { + // Already a full slug + if (NoteCache.Any(n => n.Slug == nameOrSlug)) return nameOrSlug; + var prefix = PathPrefix(); + var candidate = prefix is null ? nameOrSlug : $"{prefix}/{nameOrSlug}"; + return NoteCache.Any(n => n.Slug == candidate) ? candidate : nameOrSlug; + } + + /// Immediate sub-segments (topics or subfolders) at current path. + public IEnumerable FoldersInView() + { + var prefix = PathPrefix(); + var depth = prefix is null ? 0 : prefix.Split('/').Length; + return NoteCache + .Where(n => prefix is null || n.Slug.StartsWith(prefix + "/")) + .Select(n => n.Slug.Split('/').ElementAtOrDefault(depth)) + .Where(s => s is not null) + .Distinct(StringComparer.OrdinalIgnoreCase)!; + } +} diff --git a/NozCli/Repl/ReplInput.cs b/NozCli/Repl/ReplInput.cs new file mode 100644 index 0000000..eae435a --- /dev/null +++ b/NozCli/Repl/ReplInput.cs @@ -0,0 +1,155 @@ +using Spectre.Console; + +namespace NozCli.Repl; + +/// Minimal readline with history + Tab-completion. +public class ReplInput +{ + private readonly List _history = []; + private int _histIdx = -1; + private readonly CommandRegistry _registry; + private ReplContext? _ctx; + + public ReplInput(CommandRegistry registry) => _registry = registry; + + public void SetContext(ReplContext ctx) => _ctx = ctx; + + public string? Read(string prompt) + { + AnsiConsole.Markup(prompt); + + var buffer = new List(); + int cursor = 0; + string? tab = null; // last Tab suggestion + int tabIdx = 0; + + while (true) + { + var key = Console.ReadKey(intercept: true); + + // ── Enter ───────────────────────────────────────────────── + if (key.Key == ConsoleKey.Enter) + { + Console.WriteLine(); + var result = new string(buffer.ToArray()); + if (!string.IsNullOrWhiteSpace(result)) + { + _history.Add(result); + _histIdx = -1; + } + return result; + } + + // ── Ctrl-C / Ctrl-D ─────────────────────────────────────── + if (key.Key == ConsoleKey.C && key.Modifiers.HasFlag(ConsoleModifiers.Control)) + { + Console.WriteLine(); + return null; + } + + // ── Backspace ───────────────────────────────────────────── + if (key.Key == ConsoleKey.Backspace) + { + if (cursor > 0) + { + buffer.RemoveAt(cursor - 1); + cursor--; + RedrawLine(prompt, buffer, cursor); + } + tab = null; + continue; + } + + // ── Arrow up/down — history ─────────────────────────────── + if (key.Key == ConsoleKey.UpArrow && _history.Count > 0) + { + _histIdx = Math.Clamp(_histIdx < 0 ? _history.Count - 1 : _histIdx - 1, 0, _history.Count - 1); + buffer = [.. _history[_histIdx]]; + cursor = buffer.Count; + RedrawLine(prompt, buffer, cursor); + continue; + } + if (key.Key == ConsoleKey.DownArrow) + { + if (_histIdx >= 0 && _histIdx < _history.Count - 1) + { + _histIdx++; + buffer = [.. _history[_histIdx]]; + } + else { _histIdx = -1; buffer.Clear(); } + cursor = buffer.Count; + RedrawLine(prompt, buffer, cursor); + continue; + } + + // ── Arrow left/right — cursor move ──────────────────────── + if (key.Key == ConsoleKey.LeftArrow && cursor > 0) { cursor--; MoveCursorTo(prompt, cursor); continue; } + if (key.Key == ConsoleKey.RightArrow && cursor < buffer.Count) { cursor++; MoveCursorTo(prompt, cursor); continue; } + + // ── Tab — autocomplete ──────────────────────────────────── + if (key.Key == ConsoleKey.Tab) + { + var suggestions = GetSuggestions(new string(buffer.ToArray())); + if (suggestions.Length == 0) continue; + + if (tab is null) tabIdx = 0; + else tabIdx = (tabIdx + 1) % suggestions.Length; + + tab = suggestions[tabIdx]; + + // Replace word after last space with suggestion + var parts = new string(buffer.ToArray()).Split(' '); + parts[^1] = tab; + var completed = string.Join(' ', parts); + buffer = [.. completed]; + cursor = buffer.Count; + RedrawLine(prompt, buffer, cursor); + continue; + } + + tab = null; + + // ── Regular character ───────────────────────────────────── + if (!char.IsControl(key.KeyChar)) + { + buffer.Insert(cursor, key.KeyChar); + cursor++; + RedrawLine(prompt, buffer, cursor); + } + } + } + + private static void RedrawLine(string prompt, List buffer, int cursor) + { + var col = Markup.Remove(prompt).Length; + Console.CursorLeft = 0; + Console.Write(new string(' ', Console.WindowWidth - 1)); + Console.CursorLeft = 0; + AnsiConsole.Markup(prompt); + Console.Write(new string(buffer.ToArray())); + Console.CursorLeft = col + cursor; + } + + private static void MoveCursorTo(string prompt, int cursor) => + Console.CursorLeft = Markup.Remove(prompt).Length + cursor; + + private string[] GetSuggestions(string input) + { + if (_ctx is null) return []; + + var parts = input.TrimStart().Split(' ', StringSplitOptions.RemoveEmptyEntries); + var partial = parts.Length == 0 ? "" : parts[^1]; + + if (parts.Length <= 1) + return _registry.Names + .Where(n => n.StartsWith(partial, StringComparison.OrdinalIgnoreCase)) + .Order().ToArray(); + + var slugs = _ctx.NotesInView().Select(n => n.Slug.Split('/').Last()); + var folders = _ctx.FoldersInView().Select(f => f + "/"); + + return slugs.Concat(folders) + .Where(s => s.StartsWith(partial, StringComparison.OrdinalIgnoreCase)) + .Distinct().Order().ToArray(); + } +} diff --git a/NozCli/Repl/ReplSession.cs b/NozCli/Repl/ReplSession.cs new file mode 100644 index 0000000..e6a3288 --- /dev/null +++ b/NozCli/Repl/ReplSession.cs @@ -0,0 +1,62 @@ +using NozCli.Core.Client; +using NozCli.Core.Config; +using NozCli.Core.Models; +using Spectre.Console; + +namespace NozCli.Repl; + +public static class ReplSession +{ + public static async Task RunAsync(LocalConfig config) + { + using var api = new NozApiClient(config); + + ServerCapabilities caps = default!; + await AnsiConsole.Status().StartAsync("Connecting…", async _ => + caps = await api.GetConfigAsync()); + + AnsiConsole.MarkupLine($"[bold green]noz[/] [dim]· {caps.Server}[/]"); + AnsiConsole.MarkupLine($"[dim]push={caps.AllowPush} delete={caps.AllowDelete} vault={caps.AllowVault}[/]\n"); + + var ctx = new ReplContext(api); + var registry = new CommandRegistry(); + var input = new ReplInput(registry); + + await AnsiConsole.Status().StartAsync("Loading notes…", async _ => + ctx.NoteCache = await api.GetNotesAsync()); + + AnsiConsole.MarkupLine($"[dim]● {ctx.NoteCache.Count} notes cached[/]\n"); + input.SetContext(ctx); + + while (ctx.Running) + { + var prompt = $"[bold cyan]{ctx.CurrentPath}[/] [dim]»[/] "; + var line = input.Read(prompt); + + if (line is null) break; // Ctrl-C / Ctrl-D + + var parts = line.Trim().Split(' ', StringSplitOptions.RemoveEmptyEntries); + if (parts.Length == 0) continue; + + var name = parts[0]; + var args = parts[1..]; + + if (name is "exit" or "quit" or "q") + { + AnsiConsole.MarkupLine("[dim]Bye.[/]"); + break; + } + + var cmd = registry.Find(name); + if (cmd is null) + { + AnsiConsole.MarkupLine($"[red]Unknown command:[/] {name} [dim](type 'help')[/]"); + continue; + } + + try { await cmd.ExecuteAsync(args, ctx); } + catch (HttpRequestException ex) { AnsiConsole.MarkupLine($"[red]Server error:[/] {ex.Message}"); } + catch (Exception ex) { AnsiConsole.MarkupLine($"[red]Error:[/] {ex.Message}"); } + } + } +} diff --git a/noz-cli.slnx b/noz-cli.slnx new file mode 100644 index 0000000..9c52bf0 --- /dev/null +++ b/noz-cli.slnx @@ -0,0 +1,4 @@ + + + +