From 32cb5f75166a140a2550ec615345c386af5428cb Mon Sep 17 00:00:00 2001 From: Joseph O'Brien Date: Sun, 14 Jun 2026 00:24:05 -0400 Subject: [PATCH] feat(nushell): adopt full nushell config from dotfiles Moves config.nu, env.nu, and all autoload/*.nu files into the notfiles nushell package. All symlinks in ~/.config/nushell/ now point to notfiles instead of dotfiles. --- nushell/.config/nushell/autoload/aliases.nu | 102 +++++++++++ nushell/.config/nushell/autoload/audit.nu | 131 +++++++++++++ .../.config/nushell/autoload/did-you-mean.nu | 55 ++++++ nushell/.config/nushell/autoload/functions.nu | 168 +++++++++++++++++ nushell/.config/nushell/autoload/nuenv.nu | 84 +++++++++ .../.config/nushell/autoload/session-guard.nu | 51 ++++++ nushell/.config/nushell/autoload/settings.nu | 70 +++++++ .../.config/nushell/autoload/toolkit-hook.nu | 18 ++ nushell/.config/nushell/config.nu | 94 ++++++++++ nushell/.config/nushell/env.nu | 173 ++++++++++++++++++ 10 files changed, 946 insertions(+) create mode 100644 nushell/.config/nushell/autoload/aliases.nu create mode 100644 nushell/.config/nushell/autoload/audit.nu create mode 100644 nushell/.config/nushell/autoload/did-you-mean.nu create mode 100644 nushell/.config/nushell/autoload/functions.nu create mode 100644 nushell/.config/nushell/autoload/nuenv.nu create mode 100644 nushell/.config/nushell/autoload/session-guard.nu create mode 100644 nushell/.config/nushell/autoload/settings.nu create mode 100644 nushell/.config/nushell/autoload/toolkit-hook.nu create mode 100644 nushell/.config/nushell/config.nu create mode 100644 nushell/.config/nushell/env.nu diff --git a/nushell/.config/nushell/autoload/aliases.nu b/nushell/.config/nushell/autoload/aliases.nu new file mode 100644 index 0000000..e404996 --- /dev/null +++ b/nushell/.config/nushell/autoload/aliases.nu @@ -0,0 +1,102 @@ +# Aliases + +# ── Shell ───────────────────────────────────────────────────────────────────── +alias rr = exec nu # reload nushell (re-reads config.nu and env.nu) + +# ── Files ──────────────────────────────────────────────────────────────────── +alias ll = ls -l +alias la = ls -la + +# ── Editor ─────────────────────────────────────────────────────────────────── +alias ide = zed . +alias ocm = opencode -m ollama/gpt-mbx + +# ── mise ───────────────────────────────────────────────────────────────────── +alias m = mise +alias mr = mise run +alias mi = mise install +alias mt = mise tasks ls + +# ── Git (gitoxide) ─────────────────────────────────────────────────────────── +# gix-supported: status, branch, log, fetch, clone, diff(objects), blame, worktree +# not yet in gix: add, commit, checkout/switch/restore, push, stash, rebase, merge +def --wrapped g [...args] { gix ...$args } +def gs [] { gix status } +def --wrapped gb [...args] { gix branch list ...$args } +def --wrapped ga [...args] { git add ...$args } +def --wrapped gc [...args] { git commit ...$args } +def --wrapped gco [...args] { git checkout ...$args } +def --wrapped gd [...args] { git diff ...$args } # gix diff = object-level only +def gl [] { git pull --ff-only } +def --wrapped gp [...args] { git push ...$args } +def --wrapped gpf [...args] { git push --force-with-lease ...$args } +def gitgood [] { git stash; git pull --rebase; git stash pop; git push } + +# ── GitHub CLI ─────────────────────────────────────────────────────────────── +alias ghst = gh auth status +alias ghrepo = gh repo view --web +alias ghpr = gh pr create +alias ghprv = gh pr view +alias ghprw = gh pr view --web +alias ghiss = gh issue list +alias ghrun = gh run list + +# ── BAML ───────────────────────────────────────────────────────────────────── +alias baml = uvx --from baml-py baml-cli + +# ── Python / uv ────────────────────────────────────────────────────────────── +alias pip = uv pip +alias pip3 = uv pip +alias py = uv run python + +# ── JS / Bun ───────────────────────────────────────────────────────────────── +alias npm = bun +alias npx = bunx +alias pnpm = bun +alias yarn = bun + +# ── Zerobrew ───────────────────────────────────────────────────────────────── +alias zbi = zb install +alias zbl = zb list +alias zbu = zb update + +# ── Dotfiles ───────────────────────────────────────────────────────────────── +alias dotfiles = cd ~/dotfiles +def --env dotgs [] { cd ~/dotfiles; git status -sb } +def --env dotpull [] { cd ~/dotfiles; git pull --ff-only } +def --env dotpush [] { cd ~/dotfiles; git push } +def --env dotopen [] { cd ~/dotfiles; gh repo view --web } + +# ── Docker / Colima ────────────────────────────────────────────────────────── +alias dps = docker ps +alias dpsa = docker ps -a +alias di = docker images +alias dstop = docker stop +alias drm = docker rm +alias drmi = docker rmi +alias drmif = docker rmi -f +alias colima-start = colima start --profile dev --cpu 4 --memory 6 --disk 60 --runtime docker +alias colima-stop = colima stop --profile dev +alias colima-status = colima status --profile dev + +# ── Kubernetes ─────────────────────────────────────────────────────────────── +alias kctx = kubectl config current-context +alias kpods = kubectl get pods -A +alias kmpods = kubectl --context=gke_toptal-maestro_us-east1_main-0 -n team-maestro get pods +alias kmlogs = kubectl --context=gke_toptal-maestro_us-east1_main-0 -n team-maestro logs +alias kmexec = kubectl --context=gke_toptal-maestro_us-east1_main-0 -n team-maestro exec -it + +# ── Maestro ────────────────────────────────────────────────────────────────── +alias ms = maestro start +alias mst = maestro stop +alias ml = maestro list +alias mlogs = maestro logs +alias mwork = maestro work +alias mcfg = maestro config show +alias mpurge = maestro purge +alias mauth = maestro auth login + +alias maestro-attach = docker exec -it -u vscode (docker ps --filter name=maestro-maestro-dev --format "{{.ID}}" | head -1) tmux -S /tmp/tmux-shared/maestro.sock -u attach-session + +# ── Secrets / obfsck ───────────────────────────────────────────────────────── +alias obfs = pj secret redact diff --git a/nushell/.config/nushell/autoload/audit.nu b/nushell/.config/nushell/autoload/audit.nu new file mode 100644 index 0000000..7f772a4 --- /dev/null +++ b/nushell/.config/nushell/autoload/audit.nu @@ -0,0 +1,131 @@ +# Redaction audit helpers — wraps scripts/redact-audit.sh and .logs/redact-audit.jsonl + +# ── Internal ───────────────────────────────────────────────────────────────── + +def _dotfiles [] { $env.HOME | path join "dotfiles" } +def _audit_log [] { $env.HOME | path join "dotfiles/.logs/redact-audit.jsonl" } + +def _read_audit_log [] { + let log = _audit_log + if not ($log | path exists) { return [] } + open $log + | lines + | where { |l| ($l | str trim) != "" } + | each { |l| $l | from json } + | update ts { |r| $r.ts | into datetime } +} + +# ── audit-log ──────────────────────────────────────────────────────────────── + +# Read the redaction audit log as a table. +# Filter by --tier, --file, --hook, or --since (e.g. "1day", "2hr", "30min") +def audit-log [ + --tier: string # Filter to a specific tier (critical, high, medium, low) + --file: string # Filter by file path substring + --hook: string # Filter by hook name (pre-commit, manual, ...) + --since: string # Only show entries newer than this duration (e.g. 1day, 2hr) +] { + mut rows = _read_audit_log + + if ($tier | is-not-empty) { + $rows = ($rows | where tier == $tier) + } + if ($file | is-not-empty) { + $rows = ($rows | where { |r| $r.file | str contains $file }) + } + if ($hook | is-not-empty) { + $rows = ($rows | where hook == $hook) + } + if ($since | is-not-empty) { + let cutoff = (date now) - ($since | into duration) + $rows = ($rows | where ts > $cutoff) + } + + $rows | select ts hook commit file tier group label match_count +} + +# ── audit-summary ───────────────────────────────────────────────────────────── + +# Summarise audit log hits grouped by tier and group. +# Pass --since to limit to a recent window (e.g. "7day"). +def audit-summary [ + --since: string # Limit to entries newer than this duration (e.g. 7day) +] { + mut rows = _read_audit_log + + if ($since | is-not-empty) { + let cutoff = (date now) - ($since | into duration) + $rows = ($rows | where ts > $cutoff) + } + + if ($rows | is-empty) { + print "No audit entries found." + return + } + + $rows + | group-by tier + | transpose tier entries + | each { |t| + let total = ($t.entries | get match_count | math sum) + let by_group = ( + $t.entries + | group-by group + | transpose group rows + | each { |g| { group: $g.group, hits: ($g.rows | get match_count | math sum) } } + | sort-by hits -r + ) + { tier: $t.tier, total_hits: $total, breakdown: $by_group } + } + | sort-by total_hits -r +} + +# ── audit-scan ─────────────────────────────────────────────────────────────── + +# Scan files through the redaction auditor and log findings. +# With no args, scans staged git files (same as pre-commit hook). +# Pass file paths to scan specific files. +def audit-scan [ + ...files: string # Files to scan (default: staged git files) + --verbose (-v) # Print findings to terminal as well as logging +] { + let script = (_dotfiles | path join "scripts/redact-audit.sh") + + if not ($script | path exists) { + error make { msg: $"audit script not found: ($script)" } + } + + mut args = ["--hook", "manual"] + + if $verbose { $args = ($args | append "--verbose") } + + if ($files | is-empty) { + $args = ($args | append "--staged") + } else { + $args = ($args | append $files) + } + + ^bash $script ...$args +} + +# ── audit-top ───────────────────────────────────────────────────────────────── + +# Show the most frequently hit files in the audit log. +def audit-top [ + --n: int = 10 # Number of results to show + --since: string # Limit to entries newer than this duration (e.g. 7day) +] { + mut rows = _read_audit_log + + if ($since | is-not-empty) { + let cutoff = (date now) - ($since | into duration) + $rows = ($rows | where ts > $cutoff) + } + + $rows + | group-by file + | transpose file entries + | each { |f| { file: $f.file, hits: ($f.entries | get match_count | math sum), scans: ($f.entries | length) } } + | sort-by hits -r + | first $n +} diff --git a/nushell/.config/nushell/autoload/did-you-mean.nu b/nushell/.config/nushell/autoload/did-you-mean.nu new file mode 100644 index 0000000..251ed33 --- /dev/null +++ b/nushell/.config/nushell/autoload/did-you-mean.nu @@ -0,0 +1,55 @@ +# did-you-mean.nu — command_not_found hook +# Source: nushell/nu_scripts (nu-hooks/command_not_found) +# Suggests 3 closest commands from PATH when a command is not found. + +export def hook [] { + {|cmd| + let commands_in_path = ( + if ($nu.os-info.name == windows) { + $env.Path | each {|directory| + if ($directory | path exists) { + let cmd_exts = ( + $env.PATHEXT | str downcase | split row ';' | str trim --char . + ) + ls $directory + | get name + | path parse + | where {|it| + $cmd_exts | any {|ext| $ext == ($it.extension | str downcase)} + } + | get stem + } + } + } else { + $env.PATH | each {|directory| + if ($directory | path exists) { + ls $directory + | get name + | path parse + | update parent "" + | path join + } + } + } + | flatten + | wrap cmd + ) + + let closest_commands = ( + $commands_in_path + | insert distance {|it| $it.cmd | str distance $cmd} + | uniq + | sort-by distance + | get cmd + | first 3 + ) + + let pretty_commands = ( + $closest_commands | each {|cmd| + $" (ansi {fg: "default" attr: "di"})($cmd)(ansi reset)" + } + ) + + $"\ndid you mean?\n($pretty_commands | str join "\n")" + } +} diff --git a/nushell/.config/nushell/autoload/functions.nu b/nushell/.config/nushell/autoload/functions.nu new file mode 100644 index 0000000..202afee --- /dev/null +++ b/nushell/.config/nushell/autoload/functions.nu @@ -0,0 +1,168 @@ +# Custom functions + +# ── nu_libs ────────────────────────────────────────────────────────────────── + +# Show all commands loaded from nu_libs, grouped by domain +def libs [ + --filter(-f): string = "" # filter by name substring +] { + let nu_libs_dir = "/Users/joe/dev/nu_libs/lib" + + # Build name→domain map by scanning export defs in each domain's files + let domain_map = ( + ls $nu_libs_dir + | where type == "dir" + | get name + | each {|dir| + let domain = $dir | path basename + let names = ( + glob $"($dir)/**/*.nu" + | each {|f| + open $f | lines + | where {|l| ($l | str starts-with "export def") or ($l | str starts-with "export alias")} + | each {|l| + # extract the command name: third or fourth word depending on flags + let parts = $l | split row " " | where {|p| ($p | str length) > 0} + # skip "export", "def"/"alias", and any --flags + $parts | skip 2 | where {|p| not ($p | str starts-with "-")} | first + } + | compact + } + | flatten + | compact + | each {|n| $n | str replace --all '"' "" | str replace --all "`" "" | str trim} + ) + $names | each {|n| {name: $n, domain: $domain}} + } + | flatten + | uniq-by name + | reduce -f {} {|x, acc| $acc | upsert $x.name $x.domain} + ) + + let nu_libs_names = $domain_map | columns + + help commands + | where command_type == "custom" + | where {|cmd| $cmd.name in $nu_libs_names} + | if ($filter | is-not-empty) { where name =~ $filter } else { $in } + | select name description + | each {|cmd| + $cmd | insert domain ($domain_map | get $cmd.name) + } + | sort-by domain name + | group-by domain + | transpose domain cmds + | each {|g| + print $"(ansi cyan_bold)── ($g.domain) ──(ansi reset)" + $g.cmds | select name description | print + } + null +} + +# ── Files ──────────────────────────────────────────────────────────────────── + +# Quick directory listing sorted by size +def dirsize [] { + ls | select name size type | sort-by size -r +} + +# Make a directory and cd into it +def --env mkcd [dir: string] { + mkdir $dir + cd $dir +} + +# ── Dotfiles ───────────────────────────────────────────────────────────────── + +# Run a mise task from the dotfiles repo +def dfr [...args: string] { + let root = ($env.HOME | path join "dotfiles") + ^mise --cd $root run ...$args +} + +# Run a just recipe from the dotfiles repo +def dfj [...args: string] { + let root = ($env.HOME | path join "dotfiles") + ^just --justfile $"($root)/Justfile" ...$args +} + +# ── Kubernetes ─────────────────────────────────────────────────────────────── + +# Tail logs across all namespaces (uses stern) +def klogs [pattern: string = "."] { + ^stern $pattern -A +} + +# ── Secrets / redaction ────────────────────────────────────────────────────── + +# Run a command and pipe its output through obfsck redact +def obfsrun [...args: string] { + let config = ($nu.home-path | path join "dotfiles/config/obfsck-secrets.yaml") + if (which obfsck | is-not-empty) { + ^$args.0 ...($args | skip 1) | ^obfsck --config $config + } else { + ^$args.0 ...($args | skip 1) + } +} + +# ── Docker / Colima ────────────────────────────────────────────────────────── + +def _colima_ensure_running [] { + if (which colima | is-empty) { return } + let profile = if ("COLIMA_PROFILE" in $env) { $env.COLIMA_PROFILE } else { "dev" } + let running = (^colima status --profile $profile | complete | get exit_code) == 0 + if not $running { + print $"[colima] Starting profile '($profile)' \(4 CPU, 6GB RAM, 60GB disk\)..." + ^colima start --profile $profile --cpu 4 --memory 6 --disk 60 --runtime docker + } +} + +def _colima_set_socket [] { + let dev_sock = ($env.HOME | path join ".colima/dev/docker.sock") + let default_sock = ($env.HOME | path join ".config/colima/default/docker.sock") + if ($dev_sock | path exists) { + $env.DOCKER_HOST = $"unix://($dev_sock)" + } else if ($default_sock | path exists) { + $env.DOCKER_HOST = $"unix://($default_sock)" + } +} + +def --wrapped docker [...args: string] { + _colima_ensure_running + _colima_set_socket + ^docker ...$args +} + +def --wrapped docker-compose [...args: string] { + _colima_ensure_running + _colima_set_socket + ^docker-compose ...$args +} + +def colima-restart [] { + colima-stop + colima-start +} + +# ── JS ─────────────────────────────────────────────────────────────────────── + +# Real npm bypass (bun alias doesn't cover maestro-ui which needs real npm) +def mnpm [...args: string] { + ^npm ...$args +} + +# ── Secrets helpers ────────────────────────────────────────────────────────── + +# Run a command with secrets injected from ~/.secrets via op run. +# Works around $HOME not being expanded by op CLI. +def oprun [...args: string] { + let secrets = ($env.HOME | path join ".secrets") + ^op run --account=my.1password.com $"--env-file=($secrets)" -- ...$args +} + +# ── Git helpers ────────────────────────────────────────────────────────────── + +# Push via gh credential helper +def _git_gh [...args: string] { + ^git -c credential.helper= -c "credential.helper=!/opt/homebrew/bin/gh auth git-credential" ...$args +} diff --git a/nushell/.config/nushell/autoload/nuenv.nu b/nushell/.config/nushell/autoload/nuenv.nu new file mode 100644 index 0000000..3a9005f --- /dev/null +++ b/nushell/.config/nushell/autoload/nuenv.nu @@ -0,0 +1,84 @@ +# nuenv.nu — secure .env.nu loader (replaces direnv for Nu-native projects) +# Source: nushell/nu_scripts (nu-hooks/nuenv), adapted for local conventions. +# +# Usage: +# source autoload/nuenv.nu +# # In config.nu hooks section: +# $env.config.hooks.env_change.PWD = (... | append (nuenv-hook)) +# # Then in any project dir: +# nuenv allow # approve .env.nu (sha256 allowlist) +# nuenv disallow # revoke + +const NUENV_FILE = ($nu.cache-dir | path join 'nuenv' 'allowed.txt') + +def get-allowed []: [ nothing -> list ] { + if ($NUENV_FILE | path exists) { + open $NUENV_FILE | lines + } else { + [] + } +} + +# Returns the PWD-change hook record for use in env_change.PWD +export def nuenv-hook []: [ nothing -> record ] { + { + condition: {|_, after| $after | path join '.env.nu' | path exists } + code: $" + let allowed = if \('($NUENV_FILE)' | path exists\) { + open ($NUENV_FILE) | lines + } else { + [] + } + + if \(open .env.nu | hash sha256\) not-in $allowed { + error make --unspanned { + msg: $'\(ansi purple\)\('.env.nu' | path expand\)\(ansi reset\) is not allowed', + help: $'please run \(ansi default_dimmed\)nuenv allow\(ansi reset\) first', + } + } + + print $'[\(ansi yellow_bold\)nuenv\(ansi reset\)] loading \(ansi purple\)\('.env.nu' | path expand\)\(ansi reset\)' + source .env.nu + " + } +} + +def _log [msg: string, color: string] { + print $'[(ansi $color)nuenv(ansi reset)] (ansi purple)(".env.nu" | path expand)(ansi reset) ($msg)' +} + +def check-env-file [] { + if not (".env.nu" | path exists) { + error make --unspanned { + msg: $"(ansi red_bold)env file not found(ansi reset)", + help: $"no (ansi yellow).env.nu(ansi reset) in (ansi purple)(pwd)(ansi reset)", + } + } +} + +# Adds ./.env.nu to the allowlist +export def "nuenv allow" [] { + check-env-file + let allowed = get-allowed + let hash = open .env.nu | hash sha256 + if $hash in $allowed { + _log "is already allowed" "yellow_bold" + return + } + mkdir ($nu.cache-dir | path join "nuenv") + $hash | $in + "\n" out>> $NUENV_FILE + _log "has been allowed" "green_bold" +} + +# Removes ./.env.nu from the allowlist +export def "nuenv disallow" [] { + check-env-file + let allowed = get-allowed + let hash = open .env.nu | hash sha256 + if $hash not-in $allowed { + _log "is already disallowed" "yellow_bold" + return + } + $allowed | find --invert $hash | to text | save --force $NUENV_FILE + _log "has been disallowed" "green_bold" +} diff --git a/nushell/.config/nushell/autoload/session-guard.nu b/nushell/.config/nushell/autoload/session-guard.nu new file mode 100644 index 0000000..5e65bcc --- /dev/null +++ b/nushell/.config/nushell/autoload/session-guard.nu @@ -0,0 +1,51 @@ +#!/usr/bin/env nu + +# Session Guard: Validates config/env file hashes and expected env keys at shell startup +# Runs at every nushell startup to detect configuration drift. + +def validate_session [] { + let baseline_path = $"($env.HOME)/.config/nushell/.session-baseline.json" + let files_to_check = [ + $"($env.HOME)/.config/nushell/env.nu" + $"($env.HOME)/.config/nushell/config.nu" + $"($env.HOME)/dev/.envrc" + $"($env.HOME)/dev/.env" + $"($env.HOME)/.mise.toml" + ] + let required_env_keys = [ + "ANTHROPIC_API_KEY" + "OPENAI_API_KEY" + "GITHUB_TOKEN" + ] + + # Check if baseline exists + if not ($baseline_path | path exists) { + print "[session-guard] No baseline found. Run session-baseline.nu to initialize." + return + } + + # Load baseline + let baseline = (open --raw $baseline_path | from json) + + # Check file hashes + for file_path in $files_to_check { + if ($file_path | path exists) { + let current_hash = (open --raw $file_path | hash sha256) + let file_name = ($file_path | path basename) + + if ($baseline.files | get -i $file_name) != $current_hash { + print $"[session-guard] DRIFT: ($file_name) hash changed" + } + } + } + + # Check required env vars + for key in $required_env_keys { + if ($env | get -i $key) == null { + print $"[session-guard] MISSING ENV: ($key)" + } + } +} + +# Run validation (silent if all OK) +validate_session diff --git a/nushell/.config/nushell/autoload/settings.nu b/nushell/.config/nushell/autoload/settings.nu new file mode 100644 index 0000000..36b29fb --- /dev/null +++ b/nushell/.config/nushell/autoload/settings.nu @@ -0,0 +1,70 @@ +# Shell settings + +$env.config.show_banner = false +$env.config.history.file_format = "sqlite" +$env.config.history.max_size = 100_000 +$env.config.completions.external.enable = true +$env.config.edit_mode = "emacs" # or "vi" + +# ── Colors ─────────────────────────────────────────────────────────────────── +$env.config.color_config = { + separator: default + leading_trailing_space_bg: { attr: n } + header: green_bold + empty: blue + bool: light_cyan + int: default + filesize: cyan + duration: default + datetime: purple + range: default + float: default + string: default + nothing: default + binary: default + cell-path: default + row_index: green_bold + record: default + list: default + closure: green_bold + glob: cyan_bold + block: default + hints: dark_gray + search_result: { bg: red fg: default } + shape_binary: purple_bold + shape_block: blue_bold + shape_bool: light_cyan + shape_closure: green_bold + shape_custom: green + shape_datetime: cyan_bold + shape_directory: cyan + shape_external: cyan + shape_externalarg: green_bold + shape_external_resolved: light_yellow_bold + shape_filepath: cyan + shape_flag: blue_bold + shape_float: purple_bold + shape_glob_interpolation: cyan_bold + shape_globpattern: cyan_bold + shape_int: purple_bold + shape_internalcall: cyan_bold + shape_keyword: cyan_bold + shape_list: cyan_bold + shape_literal: blue + shape_match_pattern: green + shape_matching_brackets: { attr: u } + shape_nothing: light_cyan + shape_operator: yellow + shape_pipe: purple_bold + shape_range: yellow_bold + shape_record: cyan_bold + shape_redirection: purple_bold + shape_signature: green_bold + shape_string: green + shape_string_interpolation: cyan_bold + shape_table: blue_bold + shape_variable: purple + shape_vardecl: purple + shape_raw_string: light_purple + shape_garbage: { fg: default bg: red attr: b } +} diff --git a/nushell/.config/nushell/autoload/toolkit-hook.nu b/nushell/.config/nushell/autoload/toolkit-hook.nu new file mode 100644 index 0000000..8e241b9 --- /dev/null +++ b/nushell/.config/nushell/autoload/toolkit-hook.nu @@ -0,0 +1,18 @@ +# toolkit-hook.nu — auto-load toolkit.nu overlay on cd +# Source: nushell/nu_scripts (nu-hooks/toolkit) +# +# When you cd into a directory containing toolkit.nu, it loads as an overlay +# with the prefix "tk". Define project-local commands in toolkit.nu files. + +export def toolkit-hook [ + --name: string = "tk", + --color: string = "yellow_bold", +]: [ nothing -> record ] { + { + condition: {|_, after| $after | path join 'toolkit.nu' | path exists } + code: $" + print $'[\(ansi ($color)\)toolkit\(ansi reset\)] loading \(ansi purple\)toolkit.nu\(ansi reset\) as overlay' + overlay use --prefix toolkit.nu as ($name) + " + } +} diff --git a/nushell/.config/nushell/config.nu b/nushell/.config/nushell/config.nu new file mode 100644 index 0000000..efb5ddb --- /dev/null +++ b/nushell/.config/nushell/config.nu @@ -0,0 +1,94 @@ +# config.nu — main nushell configuration +# env.nu and autoload/ are sourced automatically by nushell before this file. + +# ── Vendor/autoload seeding ─────────────────────────────────────────────────── +# Generates tool init scripts into $nu.data-dir/vendor/autoload/ on every +# shell startup. Nushell auto-sources everything in that directory. + +let vendor = $nu.data-dir | path join "vendor/autoload" +mkdir $vendor + +# Ensure nix and mise are on PATH for vendor seeding — may already be set by env.nu +$env.PATH = ($env.PATH | prepend [ + ($env.HOME | path join ".nix-profile/bin") + ($env.HOME | path join ".local/share/mise/shims") +] | uniq) + +try { starship init nu | save -f ($vendor | path join "starship.nu") } +try { zoxide init nushell | save -f ($vendor | path join "zoxide.nu") } +try { ^mise activate nu | save -f ($vendor | path join "mise.nu") } +try { atuin init nu | save -f ($vendor | path join "atuin.nu") } +try { carapace _carapace nushell | save -f ($vendor | path join "carapace.nu") } + + +# ── User autoload ──────────────────────────────────────────────────────────── +# Sourced explicitly so they are available immediately (vendor/autoload is +# sourced before config.nu, so copying there only takes effect next session). +source autoload/settings.nu +source autoload/aliases.nu +source autoload/functions.nu +source autoload/audit.nu +source autoload/nuenv.nu +source autoload/did-you-mean.nu +source autoload/toolkit-hook.nu +source autoload/nu_libs.nu + +# ── Keybindings ─────────────────────────────────────────────────────────────── + +$env.config.keybindings = ($env.config.keybindings? | default [] | append [ + # Ctrl+F — fzf file picker, inserts selected path at cursor + { + name: fzf_file_picker + modifier: control + keycode: char_f + mode: [emacs, vi_insert] + event: { + send: executehostcommand + cmd: "commandline edit --insert (fd --type f | fzf | str trim)" + } + } +]) + +# ── Hooks ───────────────────────────────────────────────────────────────────── +# Prevent accumulation from repeated config.nu sourcing by tracking registration + +let pwd_hooks = [ + (nuenv-hook) + (toolkit-hook) + {|before, after| + let cache = ($env.HOME | path join ".cache/doob/status.json") + if not ($cache | path exists) { return } + let data = (open $cache | from json) + let overdue = ($data.overdue_total? | default 0) + if $overdue == 0 { return } + let repo = ($after | path basename) + let repo_count = ($data.overdue_by_repo? | default {} | get -o $repo | default 0) + if $repo_count > 0 { + print $"(ansi yellow)doob: ($repo_count) overdue in ($repo)(ansi reset)" + } else { + print $"(ansi dim)doob: ($overdue) overdue total(ansi reset)" + } + } +] + +# Guard against accumulation from repeated sourcing (e.g., user runs 'source config.nu') +if ($env.NUSHELL_CONFIG_SOURCED? == "true") { + # Already sourced in this session, skip hook registration +} else { + $env.config.hooks.env_change.PWD = $pwd_hooks + $env.NUSHELL_CONFIG_SOURCED = "true" +} + +# display_output: expand table columns on wide terminals +$env.config.hooks.display_output = {|| + if (term size).columns >= 100 { table -e } else { table } +} + +# command_not_found: fuzzy-match 3 closest commands from PATH +$env.config.hooks.command_not_found = (use autoload/did-you-mean.nu; hook) + +# go wrapper: install to $HOME/go/bin by default +def go [...args] { with-env { GOBIN: $"($env.HOME)/go/bin" } { ^go ...$args } } + +# naptrace: run from source checkout so prompts/ is found +def --wrapped naptrace [...args] { cd ~/dev/naptrace; ^naptrace ...$args } diff --git a/nushell/.config/nushell/env.nu b/nushell/.config/nushell/env.nu new file mode 100644 index 0000000..a302d0b --- /dev/null +++ b/nushell/.config/nushell/env.nu @@ -0,0 +1,173 @@ +# Environment variables + +# Guard: reset PWD to $HOME if inherited value is not an absolute path. +# if not ($env.PWD | str starts-with "/") { +# $env.PWD = $env.HOME +# } + +# ── PATH ──────────────────────────────────────────────────────────────────── +$env.PATH = ( + $env.PATH + | prepend ($env.HOME | path join ".local/bin") + | prepend ($env.HOME | path join ".local/share/mise/shims") + | prepend ($env.HOME | path join ".bun/bin") + | prepend "/opt/zerobrew/bin" + | prepend ($env.HOME | path join ".zerobrew/bin") + | prepend ($env.HOME | path join ".nix-profile/bin") + | prepend "/nix/var/nix/profiles/default/bin" + | prepend ($env.HOME | path join ".cargo/bin") + | prepend "/opt/homebrew/bin" + | prepend "/opt/homebrew/sbin" + | prepend "/opt/homebrew/opt/openjdk/bin" + | prepend "/opt/homebrew/share/google-cloud-sdk/bin" + | uniq +) + +# ── ENV_CONVERSIONS ────────────────────────────────────────────────────────── +# Teach nushell to handle colon-separated vars from external tools +$env.ENV_CONVERSIONS = { + PATH: { + from_string: { |s| $s | split row (char esep) | path expand -n } + to_string: { |v| $v | str join (char esep) } + } + XDG_DATA_DIRS: { + from_string: { |s| $s | split row (char esep) } + to_string: { |v| $v | str join (char esep) } + } +} + +# ── Core env ───────────────────────────────────────────────────────────────── +$env.SHLVL = 1 +$env.EDITOR = "vim" +$env.VISUAL = "zed --wait" +$env.BUN_INSTALL = ($env.HOME | path join ".bun") +$env.JAVA_HOME = "/opt/homebrew/opt/openjdk" +$env.RTK_HOOK_AUDIT = "1" + +# ── mise ───────────────────────────────────────────────────────────────────── +# Shims are on PATH above. Full activation (hooks, cd triggers) requires +# sourcing `mise activate nu` in config.nu — see settings.nu note. +$env.MISE_SHELL = "nu" + +# ── Secrets / SOPS ────────────────────────────────────────────────────────── +let age_key = ($env.HOME | path join ".config/sops/age/keys.txt") +# Populate keys.txt from 1Password if it's missing or contains only a placeholder +if (which op | is-not-empty) { + let needs_refresh = ( + not ($age_key | path exists) or + (open $age_key | str trim) == "AGE-SECRET-KEY-1TESTKEY" + ) + if $needs_refresh { + try { + op item get 6meypnchchq3tsb32mdnzxtlia --fields notesPlain + | str replace --all '"' '' + | lines + | where { |l| $l | str starts-with "AGE-SECRET-KEY" } + | str join "\n" + | save --force $age_key + } + } +} +if ($age_key | path exists) { + $env.SOPS_AGE_KEY_FILE = $age_key + $env.MISE_SOPS_AGE_KEY_FILE = $age_key +} + +# Bootstrap secrets (dotenv format, no op:// refs) +let bootstrap_secrets = ($env.HOME | path join ".config/dev-bootstrap/secrets.env") +if ($bootstrap_secrets | path exists) { + open $bootstrap_secrets + | lines + | where { |l| not ($l | str starts-with "#") and ($l | str trim | str length) > 0 } + | each { |l| $l | parse "{key}={value}" | first } + | each { |kv| load-env {($kv.key): $kv.value} } + | ignore +} + +# Cache dotenvx private key so nuenv .env.nu never re-prompts 1Password +if (which op | is-not-empty) and ($env.DOTENV_PRIVATE_KEY? | default "" | is-empty) { + try { + $env.DOTENV_PRIVATE_KEY = ( + op read "op://Personal/nihl7o2bojy53zy4aqtr7txyqi/password" + --account=my.1password.com + ) + } +} + +# ── Colima / Docker ────────────────────────────────────────────────────────── +let colima_dev_sock = ($env.HOME | path join ".colima/dev/docker.sock") +let colima_default_sock = ($env.HOME | path join ".config/colima/default/docker.sock") +if ($colima_dev_sock | path exists) { + $env.DOCKER_HOST = $"unix://($colima_dev_sock)" +} else if ($colima_default_sock | path exists) { + $env.DOCKER_HOST = $"unix://($colima_default_sock)" +} + +# ── Maestro ────────────────────────────────────────────────────────────────── +$env.MAESTRO_API_URL = "https://api.maestro-staging.toptal.net" +$env.MAESTRO_RESOURCE_PROFILE = "development" + + +# ── Homebrew ────────────────────────────────────────────────────────────────── +$env.HOMEBREW_PREFIX = "/opt/homebrew" +$env.HOMEBREW_CELLAR = "/opt/homebrew/Cellar" +$env.HOMEBREW_REPOSITORY = "/opt/homebrew" + +# ── Handon banner ──────────────────────────────────────────────────────────── +# Show top handoff items for current repo on shell start. +if (which handoff-detect | is-not-empty) { + let _result = (do { run-external "handoff-detect" $env.PWD } | complete) + if $_result.exit_code == 0 and ($_result.stdout | str trim | is-not-empty) { + print $"(ansi cyan)--- handoff ---" + print $_result.stdout + print (ansi reset) + } +} + +# ── SSH agent ──────────────────────────────────────────────────────────────── +# Prefer gpg-agent (YubiKey/OpenPGP), fall back to native macOS launchd agent. +$env.SSH_AUTH_SOCK = ( + [ + (^gpgconf --list-dirs agent-ssh-socket | str trim) + (glob "/var/run/com.apple.launchd.*/Listeners" | first | default "") + ] + | where { |it| $it | path exists } + | first + | default "" +) +$env.PATH = ($env.PATH | split row (char esep) | where { $in != "/Users/joe/Library/Application Support/carapace/bin" } | prepend "/Users/joe/Library/Application Support/carapace/bin") + +def --env get-env [name] { $env | get $name } +def --env set-env [name, value] { load-env { $name: $value } } +def --env unset-env [name] { hide-env $name } + +let carapace_completer = {|spans| + load-env { + CARAPACE_SHELL_BUILTINS: (help commands | where category != "" | get name | each { split row " " | first } | uniq | str join "\n") + CARAPACE_SHELL_FUNCTIONS: (help commands | where category == "" | get name | each { split row " " | first } | uniq | str join "\n") + } + + # if the current command is an alias, get it's expansion + let expanded_alias = (scope aliases | where name == $spans.0 | $in.0?.expansion?) + + # overwrite + let spans = (if $expanded_alias != null { + # put the first word of the expanded alias first in the span + $spans | skip 1 | prepend ($expanded_alias | split row " " | take 1) + } else { + $spans | skip 1 | prepend ($spans.0) + }) + + carapace $spans.0 nushell ...$spans + | from json +} + +mut current = (($env | default {} config).config | default {} completions) +$current.completions = ($current.completions | default {} external) +$current.completions.external = ($current.completions.external +| default true enable +# backwards compatible workaround for default, see nushell #15654 +| upsert completer { if $in == null { $carapace_completer } else { $in } }) + +$env.config = $current +