Initial commit: noz-cli REPL skeleton

This commit is contained in:
kryptomrx 2026-05-16 19:25:39 +02:00
commit 765ff7a976
25 changed files with 1024 additions and 0 deletions

5
.gitignore vendored Normal file
View file

@ -0,0 +1,5 @@
bin/
obj/
*.user
.vs/
~/.config/noz/

View file

@ -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();
}

View file

@ -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<ServerCapabilities> GetConfigAsync()
{
var res = await _http.GetFromJsonAsync<JsonElement>("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<List<NoteInfo>> GetNotesAsync()
{
var res = await _http.GetFromJsonAsync<JsonElement>("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<NoteContent> GetNoteAsync(string slug)
{
var res = await _http.GetFromJsonAsync<JsonElement>($"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<string> 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<JsonElement>();
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();
}

View file

@ -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<LocalConfig>(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,
};
}

View file

@ -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
);

View file

@ -0,0 +1,10 @@
namespace NozCli.Core.Models;
public record ServerCapabilities(
string Server,
bool Enabled,
bool AllowPush,
bool AllowDelete,
bool AllowVault,
bool AllowRebuild
);

View file

@ -0,0 +1,13 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="System.Text.Json" Version="10.0.8" />
</ItemGroup>
</Project>

View file

@ -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 <slug>";
public async Task ExecuteAsync(string[] args, ReplContext ctx)
{
var slug = args.FirstOrDefault();
if (slug is null) { AnsiConsole.MarkupLine("[red]Usage: cat <slug>[/]"); return; }
slug = ctx.ResolveSlug(slug);
await AnsiConsole.Status().StartAsync("Fetching…", async _ =>
{
var note = await ctx.Api.GetNoteAsync(slug);
AnsiConsole.WriteLine(note.Markdown);
});
}
}

View file

@ -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 <folder> | 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;
}
}

View file

@ -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 <slug>";
public async Task ExecuteAsync(string[] args, ReplContext ctx)
{
var slug = args.FirstOrDefault();
if (slug is null) { AnsiConsole.MarkupLine("[red]Usage: edit <slug>[/]"); 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();
}

View file

@ -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<ICommand> _commands;
public HelpCommand(IReadOnlyList<ICommand> 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;
}
}

View file

@ -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);
}

View file

@ -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;
}
}

View file

@ -0,0 +1,87 @@
using NozCli.Repl;
using Spectre.Console;
namespace NozCli.Commands;
/// push <local-path> — 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 <local-path> [--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 <local-path> [--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();
}
}

View file

@ -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 <slug>";
public async Task ExecuteAsync(string[] args, ReplContext ctx)
{
var slug = args.FirstOrDefault();
if (slug is null) { AnsiConsole.MarkupLine("[red]Usage: rm <slug>[/]"); 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");
}
}

View file

@ -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 <local-path>";
public async Task ExecuteAsync(string[] args, ReplContext ctx)
{
var localPath = args.FirstOrDefault();
if (localPath is null) { AnsiConsole.MarkupLine("[red]Usage: status <local-path>[/]"); 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<string>();
var modified = new List<string>();
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();
}

View file

@ -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 <local-path>";
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[/]");
}
}

18
NozCli/NozCli.csproj Normal file
View file

@ -0,0 +1,18 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<ProjectReference Include="..\NozCli.Core\NozCli.Core.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Spectre.Console" Version="0.55.2" />
</ItemGroup>
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>

30
NozCli/Program.cs Normal file
View file

@ -0,0 +1,30 @@
using NozCli.Core.Config;
using NozCli.Repl;
using Spectre.Console;
// ── noz init <url> <token> ────────────────────────────────────────────────────
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 <server-url> <token>[/]");
return 1;
}
// ── Interactive REPL ──────────────────────────────────────────────────────────
await ReplSession.RunAsync(config);
return 0;

View file

@ -0,0 +1,34 @@
using NozCli.Commands;
namespace NozCli.Repl;
public class CommandRegistry
{
private readonly Dictionary<string, ICommand> _map;
public IReadOnlyList<ICommand> All { get; }
public CommandRegistry()
{
var list = new List<ICommand>
{
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<string> Names => _map.Keys;
}

View file

@ -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.

View file

@ -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<NoteInfo> 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<NoteInfo> 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<string> 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)!;
}
}

155
NozCli/Repl/ReplInput.cs Normal file
View file

@ -0,0 +1,155 @@
using Spectre.Console;
namespace NozCli.Repl;
/// Minimal readline with history + Tab-completion.
public class ReplInput
{
private readonly List<string> _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<char>();
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<char> 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();
}
}

View file

@ -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}"); }
}
}
}

4
noz-cli.slnx Normal file
View file

@ -0,0 +1,4 @@
<Solution>
<Project Path="NozCli.Core/NozCli.Core.csproj" />
<Project Path="NozCli/NozCli.csproj" />
</Solution>