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