diff --git a/Noz.PowerShell/Cmdlets/AddNozLinkCmdlet.cs b/Noz.PowerShell/Cmdlets/AddNozLinkCmdlet.cs
new file mode 100644
index 0000000..d7a3c05
--- /dev/null
+++ b/Noz.PowerShell/Cmdlets/AddNozLinkCmdlet.cs
@@ -0,0 +1,43 @@
+using System.Management.Automation;
+using NozCli.Core.Client;
+
+namespace Noz.PowerShell.Cmdlets;
+
+[Cmdlet(VerbsCommon.Add, "NozLink", SupportsShouldProcess = true)]
+public sealed class AddNozLinkCmdlet : NozCmdletBase
+{
+ [Parameter(Mandatory = true, Position = 0, ValueFromPipelineByPropertyName = true)]
+ public string Source { get; set; } = "";
+
+ [Parameter(Mandatory = true, Position = 1)]
+ public string Target { get; set; } = "";
+
+ [Parameter]
+ public SwitchParameter Both { get; set; }
+
+ [Parameter]
+ public string Section { get; set; } = "Siehe auch";
+
+ protected override void ProcessRecord()
+ {
+ if (!ShouldProcess(Source, $"Add-NozLink → {Target}")) return;
+
+ LinkOne(Source, Target);
+ if (Both) LinkOne(Target, Source);
+ }
+
+ private void LinkOne(string source, string target)
+ {
+ var note = Run(Api.GetNoteAsync(source));
+ var updated = NoteLinkHelper.AddLink(note.Markdown, target, Section);
+
+ if (updated == note.Markdown)
+ {
+ WriteVerbose($"{source} → {target}: bereits verlinkt.");
+ return;
+ }
+
+ Run(Api.PutNoteAsync(source, updated));
+ WriteVerbose($"{source} → {target}: verlinkt.");
+ }
+}
diff --git a/Noz.PowerShell/Cmdlets/GetNozTemplateCmdlet.cs b/Noz.PowerShell/Cmdlets/GetNozTemplateCmdlet.cs
new file mode 100644
index 0000000..cc25213
--- /dev/null
+++ b/Noz.PowerShell/Cmdlets/GetNozTemplateCmdlet.cs
@@ -0,0 +1,23 @@
+using System.Management.Automation;
+
+namespace Noz.PowerShell.Cmdlets;
+
+[Cmdlet(VerbsCommon.Get, "NozTemplate")]
+[OutputType(typeof(NozTemplate))]
+public sealed class GetNozTemplateCmdlet : NozCmdletBase
+{
+ [Parameter(Position = 0)]
+ public string? Name { get; set; }
+
+ protected override void ProcessRecord()
+ {
+ var templates = Run(Api.GetTemplatesAsync());
+
+ var filtered = Name is null
+ ? templates
+ : templates.Where(t => t.Name.Contains(Name, StringComparison.OrdinalIgnoreCase)).ToList();
+
+ foreach (var t in filtered.OrderBy(t => t.Name))
+ WriteObject(new NozTemplate { Name = t.Name, Description = t.Description });
+ }
+}
diff --git a/Noz.PowerShell/NozTemplate.cs b/Noz.PowerShell/NozTemplate.cs
new file mode 100644
index 0000000..f3c0b69
--- /dev/null
+++ b/Noz.PowerShell/NozTemplate.cs
@@ -0,0 +1,9 @@
+namespace Noz.PowerShell;
+
+public sealed class NozTemplate
+{
+ public string Name { get; init; } = "";
+ public string? Description { get; init; }
+
+ public override string ToString() => Name;
+}
diff --git a/NozCli.Core/Client/NoteLinkHelper.cs b/NozCli.Core/Client/NoteLinkHelper.cs
new file mode 100644
index 0000000..afbe4ea
--- /dev/null
+++ b/NozCli.Core/Client/NoteLinkHelper.cs
@@ -0,0 +1,60 @@
+using System.Text.RegularExpressions;
+
+namespace NozCli.Core.Client;
+
+public static class NoteLinkHelper
+{
+ /// Inserts a wikilink to into .
+ /// Appends to an existing section matching ,
+ /// or creates that section at the end of the note if absent.
+ public static string AddLink(string markdown, string targetSlug, string sectionHeader = "Siehe auch")
+ {
+ var link = $"- [[{targetSlug}]]";
+
+ // Find the section heading (## Siehe auch, ## See also, etc.)
+ var sectionMatch = Regex.Match(
+ markdown,
+ $@"(?m)^##\s+{Regex.Escape(sectionHeader)}\s*$",
+ RegexOptions.IgnoreCase);
+
+ if (sectionMatch.Success)
+ {
+ // Already has this link?
+ if (markdown.Contains($"[[{targetSlug}]]", StringComparison.Ordinal))
+ return markdown;
+
+ // Insert at the end of this section's content (before next ## heading or EOF)
+ var afterSection = markdown.IndexOf('\n', sectionMatch.Index + sectionMatch.Length);
+ if (afterSection < 0) afterSection = markdown.Length;
+
+ var nextHeading = Regex.Match(
+ markdown[afterSection..],
+ @"(?m)^##\s",
+ RegexOptions.IgnoreCase);
+
+ var insertAt = nextHeading.Success
+ ? afterSection + nextHeading.Index
+ : markdown.Length;
+
+ var before = markdown[..insertAt].TrimEnd();
+ var after = markdown[insertAt..];
+
+ return before + "\n" + link + "\n" + (after.Length > 0 ? "\n" + after.TrimStart() : "");
+ }
+
+ // No section found — create one at the end
+ return markdown.TrimEnd() + $"\n\n## {sectionHeader}\n\n{link}\n";
+ }
+
+ /// Replaces template placeholders with actual values.
+ public static string ApplyPlaceholders(
+ string template, string title, string topic, string lang, string status)
+ {
+ return template
+ .Replace("{{title}}", title, StringComparison.Ordinal)
+ .Replace("{{topic}}", topic, StringComparison.Ordinal)
+ .Replace("{{lang}}", lang, StringComparison.Ordinal)
+ .Replace("{{status}}", status, StringComparison.Ordinal)
+ .Replace("{{date}}", DateTime.UtcNow.ToString("yyyy-MM-dd"), StringComparison.Ordinal);
+ }
+}
diff --git a/NozCli.Core/Models/TemplateInfo.cs b/NozCli.Core/Models/TemplateInfo.cs
new file mode 100644
index 0000000..e0b07be
--- /dev/null
+++ b/NozCli.Core/Models/TemplateInfo.cs
@@ -0,0 +1,4 @@
+namespace NozCli.Core.Models;
+
+public record TemplateInfo(string Name, string? Description);
+public record TemplateContent(string Name, string? Description, string Body);
diff --git a/NozCli.Tests/NoteLinkHelperTests.cs b/NozCli.Tests/NoteLinkHelperTests.cs
new file mode 100644
index 0000000..c13083d
--- /dev/null
+++ b/NozCli.Tests/NoteLinkHelperTests.cs
@@ -0,0 +1,86 @@
+using Xunit;
+using NozCli.Core.Client;
+
+namespace NozCli.Tests;
+
+public class NoteLinkHelperTests
+{
+ // ── AddLink — neue Sektion ────────────────────────────────────────────────
+
+ [Fact]
+ public void AddLink_NoSection_AppendsSieheAuch()
+ {
+ var md = "# Notiz\n\nEtwas Text.";
+ var result = NoteLinkHelper.AddLink(md, "psychologie/flow");
+
+ Assert.Contains("## Siehe auch", result);
+ Assert.Contains("- [[psychologie/flow]]", result);
+ }
+
+ [Fact]
+ public void AddLink_NoSection_BodyUntouched()
+ {
+ var md = "# Notiz\n\nEtwas Text.";
+ var result = NoteLinkHelper.AddLink(md, "psychologie/flow");
+
+ Assert.StartsWith("# Notiz\n\nEtwas Text.", result);
+ }
+
+ // ── AddLink — existierende Sektion ────────────────────────────────────────
+
+ [Fact]
+ public void AddLink_ExistingSection_AppendsToSection()
+ {
+ var md = "# Notiz\n\nText.\n\n## Siehe auch\n\n- [[anderes/link]]\n";
+ var result = NoteLinkHelper.AddLink(md, "psychologie/flow");
+
+ Assert.Contains("- [[anderes/link]]", result);
+ Assert.Contains("- [[psychologie/flow]]", result);
+ }
+
+ [Fact]
+ public void AddLink_AlreadyLinked_NoChange()
+ {
+ var md = "# Notiz\n\n## Siehe auch\n\n- [[psychologie/flow]]\n";
+ var result = NoteLinkHelper.AddLink(md, "psychologie/flow");
+
+ Assert.Equal(md, result);
+ }
+
+ [Fact]
+ public void AddLink_ExistingSection_DoesNotBleedIntoNextSection()
+ {
+ var md = "# Notiz\n\n## Siehe auch\n\n- [[a]]\n\n## Weiteres\n\nAnderer Inhalt.\n";
+ var result = NoteLinkHelper.AddLink(md, "b/new-note");
+
+ Assert.Contains("- [[b/new-note]]", result);
+ // The new link should appear before "## Weiteres"
+ var linkIdx = result.IndexOf("- [[b/new-note]]", StringComparison.Ordinal);
+ var weitersIdx = result.IndexOf("## Weiteres", StringComparison.Ordinal);
+ Assert.True(linkIdx < weitersIdx);
+ }
+
+ // ── ApplyPlaceholders ─────────────────────────────────────────────────────
+
+ [Fact]
+ public void ApplyPlaceholders_ReplacesAllTokens()
+ {
+ var body = "# {{title}}\n\ntopic: {{topic}}\nlang: {{lang}}\nstatus: {{status}}";
+ var result = NoteLinkHelper.ApplyPlaceholders(body, "Mein Rezept", "kochen", "de", "seed");
+
+ Assert.Contains("# Mein Rezept", result);
+ Assert.Contains("topic: kochen", result);
+ Assert.Contains("lang: de", result);
+ Assert.Contains("status: seed", result);
+ Assert.DoesNotContain("{{", result);
+ }
+
+ [Fact]
+ public void ApplyPlaceholders_DateIsCurrentDay()
+ {
+ var body = "Datum: {{date}}";
+ var result = NoteLinkHelper.ApplyPlaceholders(body, "", "", "", "");
+ var today = DateTime.UtcNow.ToString("yyyy-MM-dd");
+ Assert.Contains(today, result);
+ }
+}
diff --git a/NozCli/Commands/CatCommand.cs b/NozCli/Commands/CatCommand.cs
index 898b018..46d6dc0 100644
--- a/NozCli/Commands/CatCommand.cs
+++ b/NozCli/Commands/CatCommand.cs
@@ -1,3 +1,4 @@
+using NozCli.Rendering;
using NozCli.Repl;
using Spectre.Console;
@@ -6,13 +7,15 @@ namespace NozCli.Commands;
public class CatCommand : ICommand
{
public string Name => "cat";
- public string Description => "Print the raw markdown of a note";
- public string Usage => "cat ";
+ public string Description => "Print a note — rendered by default, --raw for plain markdown";
+ public string Usage => "cat [--raw] ";
public async Task ExecuteAsync(string[] args, ReplContext ctx, CancellationToken ct = default)
{
- var slug = args.FirstOrDefault();
- if (slug is null) { AnsiConsole.MarkupLine("[red]Usage: cat [/]"); return; }
+ var raw = args.Contains("--raw") || args.Contains("-r");
+ var slug = args.FirstOrDefault(a => !a.StartsWith('-'));
+
+ if (slug is null) { AnsiConsole.MarkupLine("[red]Usage: cat [--raw] [/]"); return; }
slug = ctx.ResolveSlug(slug);
@@ -20,6 +23,9 @@ public class CatCommand : ICommand
await AnsiConsole.Status().StartAsync("Fetching…", async _ =>
note = await ctx.Api.GetNoteAsync(slug, ct));
- AnsiConsole.WriteLine(note.Markdown);
+ if (raw)
+ AnsiConsole.WriteLine(note.Markdown);
+ else
+ MarkdownRenderer.Render(note.Markdown);
}
}
diff --git a/NozCli/Commands/LinkCommand.cs b/NozCli/Commands/LinkCommand.cs
new file mode 100644
index 0000000..a69bf30
--- /dev/null
+++ b/NozCli/Commands/LinkCommand.cs
@@ -0,0 +1,67 @@
+using NozCli.Core.Client;
+using NozCli.Repl;
+using Spectre.Console;
+
+namespace NozCli.Commands;
+
+public class LinkCommand : ICommand
+{
+ public string Name => "link";
+ public string Description => "Add a wikilink from one note to another (builds the graph)";
+ public string Usage => "link [--both] [--section \"Siehe auch\"]";
+
+ public async Task ExecuteAsync(string[] args, ReplContext ctx, CancellationToken ct = default)
+ {
+ var both = args.Contains("--both");
+ var section = ParseFlag(args, "--section") ?? "Siehe auch";
+ var positional = args.Where(a => !a.StartsWith('-')).ToArray();
+
+ if (positional.Length < 2)
+ {
+ AnsiConsole.MarkupLine("[red]Usage:[/] link [--both] [--section \"Heading\"]");
+ return;
+ }
+
+ var source = ctx.ResolveSlug(positional[0]);
+ var target = ctx.ResolveSlug(positional[1]);
+
+ if (source == target)
+ {
+ AnsiConsole.MarkupLine("[yellow]Quelle und Ziel sind identisch.[/]");
+ return;
+ }
+
+ await LinkOneWay(ctx.Api, source, target, section, ct);
+
+ if (both)
+ await LinkOneWay(ctx.Api, target, source, section, ct);
+ }
+
+ private static async Task LinkOneWay(
+ NozCli.Core.Client.NozApiClient api,
+ string source, string target, string section, CancellationToken ct)
+ {
+ NozCli.Core.Models.NoteContent note = default!;
+ await AnsiConsole.Status().StartAsync($"Lese {source}…", async _ =>
+ note = await api.GetNoteAsync(source, ct));
+
+ var updated = NoteLinkHelper.AddLink(note.Markdown, target, section);
+
+ if (updated == note.Markdown)
+ {
+ AnsiConsole.MarkupLine($"[dim]{source}[/] → [dim]{target}[/] [yellow]bereits verlinkt[/]");
+ return;
+ }
+
+ await AnsiConsole.Status().StartAsync($"Aktualisiere {source}…", async _ =>
+ await api.PutNoteAsync(source, updated, ct));
+
+ AnsiConsole.MarkupLine($"[green]✓[/] [cyan]{source}[/] → [cyan]{target}[/] verlinkt");
+ }
+
+ private static string? ParseFlag(string[] args, string flag)
+ {
+ var idx = Array.IndexOf(args, flag);
+ return idx >= 0 && idx + 1 < args.Length ? args[idx + 1] : null;
+ }
+}
diff --git a/NozCli/Commands/NewCommand.cs b/NozCli/Commands/NewCommand.cs
index be84167..b1623ea 100644
--- a/NozCli/Commands/NewCommand.cs
+++ b/NozCli/Commands/NewCommand.cs
@@ -1,4 +1,5 @@
using System.Diagnostics;
+using NozCli.Core.Client;
using NozCli.Core.Models;
using NozCli.Repl;
using Spectre.Console;
@@ -9,19 +10,20 @@ public class NewCommand : ICommand
{
public string Name => "new";
public string Description => "Create a new note and open it in nano (or -c for VS Code)";
- public string Usage => "new [-c] ";
+ public string Usage => "new [-c] [--template ] ";
public async Task ExecuteAsync(string[] args, ReplContext ctx, CancellationToken ct = default)
{
- var useCode = args.Contains("-c") || args.Contains("--code");
- var nameArg = args.FirstOrDefault(a => !a.StartsWith('-'));
- if (nameArg is null) { AnsiConsole.MarkupLine("[red]Usage: new [-c] [/]"); return; }
+ var useCode = args.Contains("-c") || args.Contains("--code");
+ var templateName = ParseFlag(args, "--template") ?? ParseFlag(args, "-t");
+ var nameArg = args.FirstOrDefault(a => !a.StartsWith('-'));
+ if (nameArg is null) { AnsiConsole.MarkupLine("[red]Usage: new [-c] [--template ] [/]"); return; }
var slug = ctx.ResolveNewSlug(nameArg);
if (ctx.NoteCache.Any(n => n.Slug == slug))
{
- AnsiConsole.MarkupLine($"[yellow]Note already exists:[/] {slug} [dim](use 'edit' to modify it)[/]");
+ AnsiConsole.MarkupLine($"[yellow]Notiz existiert bereits:[/] {slug} [dim](use 'edit' to modify it)[/]");
return;
}
@@ -29,6 +31,27 @@ public class NewCommand : ICommand
var title = ToTitle(slug.Split('/').Last());
var lang = "de";
+ string noteBody;
+
+ if (templateName is not null)
+ {
+ TemplateContent? tmpl = null;
+ await AnsiConsole.Status().StartAsync($"Lade Template '{templateName}'…", async _ =>
+ tmpl = await ctx.Api.GetTemplateAsync(templateName, ct));
+
+ if (tmpl is null)
+ {
+ AnsiConsole.MarkupLine($"[red]Template '{templateName}' nicht gefunden.[/] Verfügbare Templates mit [white]templates[/] anzeigen.");
+ return;
+ }
+
+ noteBody = NoteLinkHelper.ApplyPlaceholders(tmpl.Body, title, topic, lang, "seed");
+ }
+ else
+ {
+ noteBody = $"# {title}\n\n";
+ }
+
var template = $"""
---
title: "{title}"
@@ -38,8 +61,7 @@ public class NewCommand : ICommand
lang: {lang}
---
- # {title}
-
+ {noteBody.TrimStart()}
""";
var tmp = Path.Combine(Path.GetTempPath(), $"noz-new-{slug.Replace('/', '-')}.md");
@@ -90,4 +112,9 @@ public class NewCommand : ICommand
string.Join(' ', slug.Split('-').Select(w =>
w.Length > 0 ? char.ToUpper(w[0]) + w[1..] : w));
+ private static string? ParseFlag(string[] args, string flag)
+ {
+ var idx = Array.IndexOf(args, flag);
+ return idx >= 0 && idx + 1 < args.Length ? args[idx + 1] : null;
+ }
}
diff --git a/NozCli/Commands/TemplatesCommand.cs b/NozCli/Commands/TemplatesCommand.cs
new file mode 100644
index 0000000..641f5b0
--- /dev/null
+++ b/NozCli/Commands/TemplatesCommand.cs
@@ -0,0 +1,39 @@
+using NozCli.Repl;
+using Spectre.Console;
+
+namespace NozCli.Commands;
+
+public class TemplatesCommand : ICommand
+{
+ public string Name => "templates";
+ public string Description => "List available note templates";
+ public string Usage => "templates";
+
+ public async Task ExecuteAsync(string[] args, ReplContext ctx, CancellationToken ct = default)
+ {
+ List templates = [];
+ await AnsiConsole.Status().StartAsync("Lade Templates…", async _ =>
+ templates = await ctx.Api.GetTemplatesAsync(ct));
+
+ if (templates.Count == 0)
+ {
+ AnsiConsole.MarkupLine("[dim]Keine Templates vorhanden.[/]");
+ AnsiConsole.MarkupLine("[dim]Lege Templates in [white]content/noosphere/_templates/.md[/] an.[/]");
+ return;
+ }
+
+ var table = new Table()
+ .AddColumn("[dim]Name[/]")
+ .AddColumn("[dim]Beschreibung[/]")
+ .Border(TableBorder.None)
+ .HideHeaders();
+
+ foreach (var t in templates.OrderBy(t => t.Name))
+ table.AddRow(
+ $"[cyan]{Markup.Escape(t.Name)}[/]",
+ Markup.Escape(t.Description ?? "—"));
+
+ AnsiConsole.Write(table);
+ AnsiConsole.MarkupLine($"[dim]Verwenden mit:[/] [white]new --template [/]");
+ }
+}
diff --git a/NozCli/Security/SlugGuard.cs b/NozCli/Security/SlugGuard.cs
deleted file mode 100644
index 668f183..0000000
--- a/NozCli/Security/SlugGuard.cs
+++ /dev/null
@@ -1,34 +0,0 @@
-using System.Text.RegularExpressions;
-
-namespace NozCli.Security;
-
-/// Validates slugs before they are sent to the server or written to disk.
-public static partial class SlugGuard
-{
- // Valid slug: lowercase alphanumeric + hyphens, optionally separated by /
- // Examples: "hydroponik", "biosysteme/hydroponik", "a/b/c"
- [GeneratedRegex(@"^[a-z0-9][a-z0-9\-]*(?:/[a-z0-9][a-z0-9\-]*)*$")]
- private static partial Regex ValidSlugRegex();
-
- /// Returns true if the slug is safe to use (no path traversal, valid chars).
- public static bool IsValid(string slug) =>
- !string.IsNullOrWhiteSpace(slug) &&
- !slug.Contains("..") &&
- !slug.Contains("//") &&
- ValidSlugRegex().IsMatch(slug);
-
- /// Resolves a slug to a file path inside baseDir.
- /// Returns null if the resulting path would escape baseDir (path traversal).
- public static string? ToSafePath(string baseDir, string slug, string extension = ".md")
- {
- var rel = slug.Replace('/', Path.DirectorySeparatorChar) + extension;
- var fullPath = Path.GetFullPath(Path.Combine(baseDir, rel));
- var baseFull = Path.GetFullPath(baseDir);
-
- // Ensure the resolved path stays strictly inside baseDir
- return fullPath.StartsWith(baseFull + Path.DirectorySeparatorChar,
- StringComparison.OrdinalIgnoreCase)
- ? fullPath
- : null;
- }
-}