Files
grapho/cli/grapho.lux
Brandon Lucas 63fedfb525 Add grapho CLI with improved UX
New CLI features:
- One-liner health check as default (grapho)
- Component status dashboard (grapho status)
- Verbose mode with details (grapho status -v)
- System diagnostics with fix commands (grapho doctor)
- Machine-readable output (grapho --json)
- Actionable fix suggestions for all warnings/errors

Also adds documentation:
- docs/MARKDOWN-EDITORS.md - Editor recommendations for mobile/desktop
- docs/LUX-LIMITATIONS.md - Tracking Lux language issues

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-16 01:06:55 -05:00

453 lines
17 KiB
Plaintext

// grapho - Unified CLI for the Ultimate Notetaking, Sync & Backup System
//
// Usage:
// grapho One-line health check (default)
// grapho status Full status dashboard
// grapho status -v Verbose status with all details
// grapho doctor Diagnose issues and suggest fixes
// grapho sync Sync all (nb + syncthing)
// grapho backup Run backup now
// grapho help Show help
// grapho --json Machine-readable JSON output
// =============================================================================
// Types
// =============================================================================
type SyncStatus =
| StatusOk
| StatusWarn
| StatusErr
| StatusNone
// =============================================================================
// Status icons
// =============================================================================
fn statusIcon(s: SyncStatus): String =
match s {
StatusOk => "[ok]",
StatusWarn => "[!!]",
StatusErr => "[ERR]",
StatusNone => "[--]"
}
fn statusEmoji(s: SyncStatus): String =
match s {
StatusOk => "✓",
StatusWarn => "⚠",
StatusErr => "✗",
StatusNone => "-"
}
// =============================================================================
// Shell helpers
// =============================================================================
fn hasCommand(cmd: String): Bool with {Process} = {
let result = Process.exec("command -v " + cmd + " 2>/dev/null || true")
String.length(String.trim(result)) > 0
}
fn execQuiet(cmd: String): String with {Process} =
String.trim(Process.exec(cmd + " 2>/dev/null || true"))
fn homeDir(): String with {Process} =
match Process.env("HOME") {
Some(h) => h,
None => "/tmp"
}
// =============================================================================
// Syncthing helpers
// =============================================================================
fn syncthingRunning(): Bool with {Process} = {
let result = Process.exec("syncthing cli show system 2>/dev/null | jq -r '.myID' 2>/dev/null || true")
String.length(String.trim(result)) > 10
}
fn getSyncthingUptime(): String with {Process} =
Process.exec("syncthing cli show system 2>/dev/null | jq -r '.uptime // 0' 2>/dev/null || echo 0")
fn getSyncthingFolderCount(): String with {Process} =
String.trim(Process.exec("syncthing cli show config 2>/dev/null | jq -r '.folders | length' 2>/dev/null || echo 0"))
fn getSyncthingDeviceCount(): String with {Process} =
String.trim(Process.exec("syncthing cli show config 2>/dev/null | jq -r '.devices | length' 2>/dev/null || echo 0"))
// =============================================================================
// One-liner health check (new default)
// =============================================================================
fn showHealthCheck(): Unit with {Console, Process} = {
let hasIssues = false
let hasWarnings = false
let summary = ""
// Check nb
let nbOk = hasCommand("nb")
let nbCount = if nbOk then execQuiet("nb notebooks --names 2>/dev/null | wc -l") else "0"
// Check syncthing
let stOk = hasCommand("syncthing")
let stRunning = if stOk then syncthingRunning() else false
let stFolders = if stRunning then getSyncthingFolderCount() else "0"
let stDevices = if stRunning then getSyncthingDeviceCount() else "0"
// Check backup
let resticOk = hasCommand("restic")
let timerActive = execQuiet("systemctl is-active restic-backup.timer")
let backupOk = if timerActive == "active" then true else false
// Determine overall status and print appropriate message
if nbOk == false || stOk == false || resticOk == false then {
Console.print(statusEmoji(StatusErr) + " Issues detected")
if nbOk == false then
Console.print(" " + statusIcon(StatusErr) + " nb: not installed -> nix develop")
else ()
if stOk == false then
Console.print(" " + statusIcon(StatusErr) + " syncthing: not installed -> nix develop")
else ()
if resticOk == false then
Console.print(" " + statusIcon(StatusErr) + " restic: not installed -> nix develop")
else ()
} else if stRunning == false || backupOk == false then {
Console.print(statusEmoji(StatusWarn) + " Warnings")
if stRunning == false then
Console.print(" " + statusIcon(StatusWarn) + " syncthing: not running -> systemctl --user start syncthing")
else ()
if backupOk == false then
Console.print(" " + statusIcon(StatusWarn) + " backup: timer inactive -> sudo systemctl enable --now restic-backup.timer")
else ()
} else {
Console.print(statusEmoji(StatusOk) + " All systems healthy (" + nbCount + " notebooks, " + stFolders + " folders, " + stDevices + " devices, backup active)")
}
}
// =============================================================================
// Status dashboard
// =============================================================================
fn showStatus(verbose: Bool): Unit with {Console, Process} = {
Console.print("grapho status")
Console.print("")
// nb
if hasCommand("nb") then {
let nbCount = execQuiet("nb notebooks --names 2>/dev/null | wc -l")
Console.print(statusIcon(StatusOk) + " nb: " + nbCount + " notebooks")
if verbose then {
let notebooks = execQuiet("nb notebooks --names")
if String.length(notebooks) > 0 then {
let lines = String.lines(notebooks)
printNotebooks(lines)
} else ()
} else ()
} else {
Console.print(statusIcon(StatusErr) + " nb: not installed")
Console.print(" Fix: nix develop")
}
// Syncthing
if hasCommand("syncthing") then {
if syncthingRunning() then {
let folders = getSyncthingFolderCount()
let devices = getSyncthingDeviceCount()
Console.print(statusIcon(StatusOk) + " syncthing: " + folders + " folders, " + devices + " devices")
if verbose then {
let folderLabels = execQuiet("syncthing cli show config | jq -r '.folders[].label'")
if String.length(folderLabels) > 0 then {
Console.print(" Folders:")
let flines = String.lines(folderLabels)
printLinesIndented(flines)
} else ()
let deviceNames = execQuiet("syncthing cli show config | jq -r '.devices[].name'")
if String.length(deviceNames) > 0 then {
Console.print(" Devices:")
let dlines = String.lines(deviceNames)
printLinesIndented(dlines)
} else ()
} else ()
} else {
Console.print(statusIcon(StatusWarn) + " syncthing: not running")
Console.print(" Fix: systemctl --user start syncthing")
}
} else {
Console.print(statusIcon(StatusErr) + " syncthing: not installed")
Console.print(" Fix: nix develop")
}
// Backup
if hasCommand("restic") then {
let timerActive = execQuiet("systemctl is-active restic-backup.timer")
if timerActive == "active" then {
Console.print(statusIcon(StatusOk) + " backup: timer active")
} else {
Console.print(statusIcon(StatusWarn) + " backup: timer inactive")
Console.print(" Fix: sudo systemctl enable --now restic-backup.timer")
}
} else {
Console.print(statusIcon(StatusErr) + " backup: restic not installed")
Console.print(" Fix: nix develop")
}
}
fn printNotebooks(lines: List<String>): Unit with {Console, Process} =
match List.head(lines) {
Some(name) => {
if String.length(name) > 0 then {
let count = execQuiet("find ~/.nb/" + name + " -name '*.md' 2>/dev/null | wc -l")
Console.print(" " + name + ": " + count + " notes")
} else ()
match List.tail(lines) {
Some(rest) => printNotebooks(rest),
None => ()
}
},
None => ()
}
fn printLinesIndented(lines: List<String>): Unit with {Console} =
match List.head(lines) {
Some(line) => {
if String.length(line) > 0 then
Console.print(" - " + line)
else ()
match List.tail(lines) {
Some(rest) => printLinesIndented(rest),
None => ()
}
},
None => ()
}
// =============================================================================
// Doctor command
// =============================================================================
fn showDoctor(): Unit with {Console, Process} = {
Console.print("grapho doctor")
Console.print("")
Console.print("Checking system health...")
Console.print("")
// Check nix
if hasCommand("nix") then {
let nixVer = execQuiet("nix --version")
Console.print(statusIcon(StatusOk) + " nix: " + nixVer)
} else {
Console.print(statusIcon(StatusErr) + " nix: not found")
Console.print(" Install: https://nixos.org/download")
}
// Check git
if hasCommand("git") then {
let gitVer = execQuiet("git --version | cut -d' ' -f3")
Console.print(statusIcon(StatusOk) + " git: " + gitVer)
} else {
Console.print(statusIcon(StatusErr) + " git: not found")
}
// Check nb
if hasCommand("nb") then {
let nbCount = execQuiet("nb notebooks --names 2>/dev/null | wc -l")
Console.print(statusIcon(StatusOk) + " nb: " + nbCount + " notebooks")
} else {
Console.print(statusIcon(StatusErr) + " nb: not installed")
Console.print(" Fix: nix develop")
}
// Check syncthing
if hasCommand("syncthing") then {
if syncthingRunning() then {
let folders = getSyncthingFolderCount()
Console.print(statusIcon(StatusOk) + " syncthing: running (" + folders + " folders)")
} else {
Console.print(statusIcon(StatusWarn) + " syncthing: not running")
Console.print(" Fix: systemctl --user start syncthing")
}
} else {
Console.print(statusIcon(StatusErr) + " syncthing: not installed")
Console.print(" Fix: nix develop")
}
// Check backup
if hasCommand("restic") then {
let timerActive = execQuiet("systemctl is-active restic-backup.timer")
if timerActive == "active" then {
Console.print(statusIcon(StatusOk) + " backup: timer active")
} else {
Console.print(statusIcon(StatusWarn) + " backup: timer inactive")
Console.print(" Fix: sudo systemctl enable --now restic-backup.timer")
}
} else {
Console.print(statusIcon(StatusErr) + " restic: not installed")
Console.print(" Fix: nix develop")
}
// Additional checks
Console.print("")
Console.print("Additional checks:")
// Check age key
let ageKey = execQuiet("test -f ~/.config/sops/age/keys.txt && echo yes")
if ageKey == "yes" then
Console.print(statusIcon(StatusOk) + " Age encryption key exists")
else {
Console.print(statusIcon(StatusWarn) + " No age key")
Console.print(" Fix: age-keygen -o ~/.config/sops/age/keys.txt")
}
Console.print("")
Console.print("Run 'grapho status -v' for detailed component info")
}
// =============================================================================
// JSON output
// =============================================================================
fn showJson(): Unit with {Console, Process} = {
// Note: JSON output with quotes is difficult in Lux due to escape sequence limitations
// Using a simple key=value format instead
let nbOk = hasCommand("nb")
let nbCount = if nbOk then execQuiet("nb notebooks --names 2>/dev/null | wc -l") else "0"
let nbStatus = if nbOk then "healthy" else "error"
let nbMsg = if nbOk then nbCount + " notebooks" else "not installed"
let stOk = hasCommand("syncthing")
let stRunning = if stOk then syncthingRunning() else false
let stStatus = if stOk == false then "error" else if stRunning then "healthy" else "warning"
let stMsg = if stOk == false then "not installed" else if stRunning then "running" else "not running"
let resticOk = hasCommand("restic")
let timerResult = execQuiet("systemctl is-active restic-backup.timer")
let timerActive = if timerResult == "active" then true else false
let backupStatus = if resticOk == false then "error" else if timerActive then "healthy" else "warning"
let backupMsg = if resticOk == false then "not installed" else if timerActive then "timer active" else "timer inactive"
// Output in a simple parseable format
Console.print("nb.status=" + nbStatus)
Console.print("nb.message=" + nbMsg)
Console.print("syncthing.status=" + stStatus)
Console.print("syncthing.message=" + stMsg)
Console.print("backup.status=" + backupStatus)
Console.print("backup.message=" + backupMsg)
}
// =============================================================================
// Sync command
// =============================================================================
fn doSync(): Unit with {Console, Process} = {
Console.print("Syncing...")
// Sync nb notebooks
if hasCommand("nb") then {
Console.print("-> nb sync --all")
let result = Process.exec("nb sync --all 2>&1 || true")
if String.contains(result, "error") then
Console.print(statusIcon(StatusWarn) + " nb sync had issues")
else
Console.print(statusIcon(StatusOk) + " nb synced")
} else ()
// Trigger Syncthing scan
if hasCommand("syncthing") then {
if syncthingRunning() then {
Console.print("-> syncthing cli scan")
let scanResult = Process.exec("syncthing cli scan 2>/dev/null || true")
Console.print(statusIcon(StatusOk) + " Syncthing scan triggered")
} else ()
} else ()
Console.print("Done!")
}
// =============================================================================
// Backup command
// =============================================================================
fn doBackup(): Unit with {Console, Process} = {
Console.print("Running backup...")
if hasCommand("restic") then {
let hasService = execQuiet("systemctl cat restic-backup.service")
if String.length(hasService) > 0 then {
Console.print("-> sudo systemctl start restic-backup.service")
let startResult = Process.exec("sudo systemctl start restic-backup.service 2>&1 || true")
Console.print(statusIcon(StatusOk) + " Backup service triggered")
} else {
Console.print(statusIcon(StatusWarn) + " No systemd backup service configured")
Console.print(" Configure in modules/backup.nix")
}
} else {
Console.print(statusIcon(StatusErr) + " restic not installed")
}
}
// =============================================================================
// Help
// =============================================================================
fn showHelp(): Unit with {Console} = {
Console.print("grapho - Personal Data Infrastructure")
Console.print("")
Console.print("Usage:")
Console.print(" grapho Health check (one-liner)")
Console.print(" grapho status Component status")
Console.print(" grapho status -v Verbose status with details")
Console.print(" grapho doctor Diagnose issues and fixes")
Console.print(" grapho sync Sync all (nb + syncthing)")
Console.print(" grapho backup Run backup now")
Console.print(" grapho --json Machine-readable output (key=value format)")
Console.print(" grapho help Show this help")
Console.print("")
Console.print("Quick start:")
Console.print(" nb add Create a new note")
Console.print(" nb search <term> Search notes")
Console.print(" grapho sync Sync to all devices")
Console.print("")
Console.print("More info: https://git.qrty.ink/blu/grapho")
}
// =============================================================================
// Main
// =============================================================================
fn main(): Unit with {Console, Process} = {
let args = Process.args()
let cmd = match List.get(args, 1) {
Some(c) => c,
None => ""
}
match cmd {
"" => showHealthCheck(),
"status" => {
let subcmd = match List.get(args, 2) {
Some(s) => s,
None => ""
}
match subcmd {
"-v" => showStatus(true),
"--verbose" => showStatus(true),
"verbose" => showStatus(true),
_ => showStatus(false)
}
},
"doctor" => showDoctor(),
"sync" => doSync(),
"backup" => doBackup(),
"help" => showHelp(),
"-h" => showHelp(),
"--help" => showHelp(),
"--json" => showJson(),
"-j" => showJson(),
"json" => showJson(),
_ => showHelp()
}
}
let result = run main() with {}