Initial commit: noz-cli REPL skeleton
This commit is contained in:
commit
765ff7a976
25 changed files with 1024 additions and 0 deletions
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
bin/
|
||||||
|
obj/
|
||||||
|
*.user
|
||||||
|
.vs/
|
||||||
|
~/.config/noz/
|
||||||
10
NozCli.Core/Client/HashHelper.cs
Normal file
10
NozCli.Core/Client/HashHelper.cs
Normal 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();
|
||||||
|
}
|
||||||
87
NozCli.Core/Client/NozApiClient.cs
Normal file
87
NozCli.Core/Client/NozApiClient.cs
Normal 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();
|
||||||
|
}
|
||||||
40
NozCli.Core/Config/LocalConfig.cs
Normal file
40
NozCli.Core/Config/LocalConfig.cs
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
17
NozCli.Core/Models/NoteInfo.cs
Normal file
17
NozCli.Core/Models/NoteInfo.cs
Normal 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
|
||||||
|
);
|
||||||
10
NozCli.Core/Models/ServerCapabilities.cs
Normal file
10
NozCli.Core/Models/ServerCapabilities.cs
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
namespace NozCli.Core.Models;
|
||||||
|
|
||||||
|
public record ServerCapabilities(
|
||||||
|
string Server,
|
||||||
|
bool Enabled,
|
||||||
|
bool AllowPush,
|
||||||
|
bool AllowDelete,
|
||||||
|
bool AllowVault,
|
||||||
|
bool AllowRebuild
|
||||||
|
);
|
||||||
13
NozCli.Core/NozCli.Core.csproj
Normal file
13
NozCli.Core/NozCli.Core.csproj
Normal 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>
|
||||||
25
NozCli/Commands/CatCommand.cs
Normal file
25
NozCli/Commands/CatCommand.cs
Normal 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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
48
NozCli/Commands/CdCommand.cs
Normal file
48
NozCli/Commands/CdCommand.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
97
NozCli/Commands/EditCommand.cs
Normal file
97
NozCli/Commands/EditCommand.cs
Normal 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();
|
||||||
|
}
|
||||||
27
NozCli/Commands/HelpCommand.cs
Normal file
27
NozCli/Commands/HelpCommand.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
12
NozCli/Commands/ICommand.cs
Normal file
12
NozCli/Commands/ICommand.cs
Normal 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);
|
||||||
|
}
|
||||||
79
NozCli/Commands/LsCommand.cs
Normal file
79
NozCli/Commands/LsCommand.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
87
NozCli/Commands/PushCommand.cs
Normal file
87
NozCli/Commands/PushCommand.cs
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
28
NozCli/Commands/RmCommand.cs
Normal file
28
NozCli/Commands/RmCommand.cs
Normal 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
55
NozCli/Commands/StatusCommand.cs
Normal file
55
NozCli/Commands/StatusCommand.cs
Normal 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();
|
||||||
|
}
|
||||||
24
NozCli/Commands/SyncCommand.cs
Normal file
24
NozCli/Commands/SyncCommand.cs
Normal 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
18
NozCli/NozCli.csproj
Normal 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
30
NozCli/Program.cs
Normal 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;
|
||||||
34
NozCli/Repl/CommandRegistry.cs
Normal file
34
NozCli/Repl/CommandRegistry.cs
Normal 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;
|
||||||
|
}
|
||||||
3
NozCli/Repl/NozAutoComplete.cs
Normal file
3
NozCli/Repl/NozAutoComplete.cs
Normal 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.
|
||||||
54
NozCli/Repl/ReplContext.cs
Normal file
54
NozCli/Repl/ReplContext.cs
Normal 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
155
NozCli/Repl/ReplInput.cs
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
62
NozCli/Repl/ReplSession.cs
Normal file
62
NozCli/Repl/ReplSession.cs
Normal 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
4
noz-cli.slnx
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
<Solution>
|
||||||
|
<Project Path="NozCli.Core/NozCli.Core.csproj" />
|
||||||
|
<Project Path="NozCli/NozCli.csproj" />
|
||||||
|
</Solution>
|
||||||
Loading…
Reference in a new issue