multica deployment

This commit is contained in:
rblundon committed 2026-10-20 13:41:11 -05:00
1 parent 63c5f7eed7
commit 39e2b87941
26 files changed
+2746 -394

No files matched your search

@@ -0,0 +1,128 @@
#!/bin/bash
# kanban-gateway: SSH forced-command wrapper for n8n -> Hermes kanban.
#
# Install on the Mac as the user that owns ~/.hermes, e.g. ~/bin/kanban-gateway (chmod 755).
# Wire it up in ~/.ssh/authorized_keys with a forced command (see the line in the chat).
#
# Verbs n8n may call (sent as the SSH command string):
# create <base64 of path relative to the vault, e.g. raw/clippings/foo.md>
# list [status] status in: triage todo ready running blocked review done archived
# show <task id> id like t_ab12cd34
#
# Nothing from the SSH command is ever passed through a shell or eval; the path
# travels base64-encoded so spaces/quotes/$()/newlines in filenames are inert.
set -euo pipefail
# ---- EDIT THESE ------------------------------------------------------------
HERMES_BIN="${HERMES_BIN:-$HOME/.local/bin/hermes}" # absolute path; non-interactive SSH has a minimal PATH
VAULT_ROOT="${VAULT_ROOT:-$HOME/Documents/wiki-llm}" # absolute path to the vault root
RAW_DIR="raw" # inbox folder inside the vault
LIBRARIAN_PROFILE="${LIBRARIAN_PROFILE:-librarian}" # assignee profile name
SKILL_NAME="${SKILL_NAME:-llm-wiki}" # skill installed on that profile ('' to skip pinning)
# ----------------------------------------------------------------------------
export PATH="/usr/bin:/bin:/usr/sbin:/sbin:/opt/homebrew/bin:/usr/local/bin:$HOME/.local/bin"
fail() { echo "kanban-gateway: $1" >&2; exit "${2:-2}"; }
cmd_line="${SSH_ORIGINAL_COMMAND:-}"
[[ "$cmd_line" != *$'\n'* ]] || fail "newline in command"
# n8n's SSH node prefixes the command with "cd <dir> ; " (or "&&") when its Working Directory
# field is set. The directory is never used or executed here; only that exact prefix shape is
# stripped, and whatever remains must still be one of the allowed verbs below.
cd_re='^cd[[:space:]]+("[^"]*"|'"'"'[^'"'"']*'"'"'|[^[:space:];&|`$()<>\\]+)[[:space:]]*(;|&&)[[:space:]]*'
if [[ "$cmd_line" =~ $cd_re ]]; then
cmd_line="${cmd_line#"${BASH_REMATCH[0]}"}"
fi
[[ -n "$cmd_line" ]] || fail "no command supplied"
# Split only on spaces; the verb and a single argument.
verb="${cmd_line%% *}"
arg=""
if [[ "$cmd_line" == *" "* ]]; then arg="${cmd_line#* }"; fi
decode_b64() {
# macOS older = -D, newer/GNU = -d
printf '%s' "$1" | { base64 -d 2>/dev/null || base64 -D; }
}
case "$verb" in
create)
[[ "$arg" =~ ^[A-Za-z0-9+/=]+$ ]] || fail "create expects base64 argument"
rel="$(decode_b64 "$arg")" || fail "bad base64"
[[ -n "$rel" ]] || fail "empty path"
# Reject control characters (incl. newlines), absolute paths, and traversal.
if printf '%s' "$rel" | LC_ALL=C grep -q '[[:cntrl:]]'; then fail "control characters in path"; fi
[[ "$rel" != /* ]] || fail "absolute path rejected"
case "/$rel/" in */../*) fail "path traversal rejected" ;; esac
[[ "$rel" == "$RAW_DIR"/* ]] || fail "path must be under $RAW_DIR/"
vault_real="$(cd "$VAULT_ROOT" && pwd -P)" || fail "VAULT_ROOT not found"
abs="$vault_real/$rel"
[[ -f "$abs" ]] || fail "file not found: $rel" 3
# Resolve symlinks in the directory part and confirm it is still inside raw/.
dir_real="$(cd "$(dirname "$abs")" && pwd -P)" || fail "cannot resolve directory"
case "$dir_real/" in
"$vault_real/$RAW_DIR/"*) ;;
*) fail "resolved path escapes $RAW_DIR/" ;;
esac
# Folder-scoped rulesets live IN the vault as AGENTS.md files. Collect every AGENTS.md from the
# vault root down to the file's folder (shallow -> deep) and name them on the card, so the
# agent reads them deterministically instead of relying on lazy discovery.
rules=()
if [[ -f "$vault_real/AGENTS.md" && ! -L "$vault_real/AGENTS.md" ]]; then rules+=("AGENTS.md"); fi
cur=""
IFS='/' read -r -a parts <<< "$(dirname "$rel")"
for part in "${parts[@]}"; do
cur="${cur:+$cur/}$part"
if [[ -f "$vault_real/$cur/AGENTS.md" && ! -L "$vault_real/$cur/AGENTS.md" ]]; then
rules+=("$cur/AGENTS.md")
fi
done
if (( ${#rules[@]} > 0 )); then
rules_text="Rulesets for this file's folder. Read ALL of them before doing anything else, in this order (later ones are more specific and take precedence):"
for r in "${rules[@]}"; do rules_text+=$'\n'"- $r"; done
else
rules_text="No AGENTS.md rulesets found for this folder; follow the llm-wiki skill defaults."
fi
title="Catalog: $(basename "$rel")"
body="Catalog this new raw file per the llm-wiki skill.
Relative path: $rel
Absolute path: $abs
$rules_text"
args=( kanban create "$title"
--body "$body"
--assignee "$LIBRARIAN_PROFILE"
--workspace "dir:$vault_real"
--idempotency-key "$rel"
--json )
if [[ -n "$SKILL_NAME" ]]; then args+=( --skill "$SKILL_NAME" ); fi
exec "$HERMES_BIN" "${args[@]}"
;;
list)
if [[ -z "$arg" ]]; then
exec "$HERMES_BIN" kanban list --json
fi
case "$arg" in
triage|todo|ready|running|blocked|review|done|archived)
exec "$HERMES_BIN" kanban list --status "$arg" --json ;;
*) fail "invalid status" ;;
esac
;;
show)
[[ "$arg" =~ ^t_[A-Za-z0-9]+$ ]] || fail "invalid task id"
exec "$HERMES_BIN" kanban show "$arg" --json
;;
*)
fail "verb not allowed: $verb"
;;
esac