diff --git a/.planning/PROJECT.md b/.planning/PROJECT.md new file mode 100644 index 0000000..16372e5 --- /dev/null +++ b/.planning/PROJECT.md @@ -0,0 +1,77 @@ +# CrewDock: Obsidian Vault Sync + +## What This Is + +A Syncthing sidecar container for CrewDock that provides bidirectional sync with the user's Obsidian vault. All agents (Alfred, Forge, Scouter, Overlord) gain read/write access to the vault as a shared knowledge base, mounted at `/home/node/vault`. + +## Core Value + +Every agent in CrewDock can read and write to the user's Obsidian vault in real time, keeping the knowledge base synchronized bidirectionally with the user's local Obsidian instance. + +## Requirements + +### Validated + +(None yet — ship to validate) + +### Active + +- [ ] Syncthing runs as a Docker sidecar container alongside existing CrewDock services +- [ ] Vault mounts at `/home/node/vault` inside the main container, accessible by all agents +- [ ] Bidirectional sync: agents can read and write notes that sync back to local Obsidian +- [ ] install.sh wizard includes an optional step to enable vault sync +- [ ] Minimal config: installer only asks for local vault path on the host +- [ ] Syncthing peers with the user's existing Syncthing instance +- [ ] Sync works with the full vault (no folder filtering) + +### Out of Scope + +- Per-agent vault access control — all agents share the same mount +- Vault folder filtering or partial sync — full vault only for v1 +- Conflict resolution UI — rely on Syncthing's built-in conflict handling +- Obsidian plugin integration — this is file-level sync, not plugin-level + +## Context + +- CrewDock runs 4 agents: Alfred (GWS assistant), Forge (dev orchestrator), Scouter (content radar), Overlord (sysadmin) +- User already runs Syncthing to sync the Obsidian vault across devices +- The vault lives at `/Users/dasirra/Vault` locally (the "Second Brain") +- Docker setup uses host networking, single persistent volume at `./home` -> `/home/node` +- install.sh is a TUI wizard with modular setup scripts under `installer/` +- The existing install flow supports selective reconfiguration of integrations + +## Constraints + +- **Docker**: Syncthing must run as a separate container (sidecar), not inside the main OpenClaw container +- **Networking**: CrewDock uses host networking; Syncthing needs ports 8384 (web UI, optional) and 22000 (sync protocol) +- **Permissions**: Container user is `node`; vault files must be readable/writable by this user +- **Existing Syncthing**: Must peer with an already-running Syncthing instance, not replace it + +## Key Decisions + +| Decision | Rationale | Outcome | +|----------|-----------|---------| +| Sidecar container vs in-container install | Separation of concerns, independent updates, cleaner Docker architecture | — Pending | +| Mount at /home/node/vault | Consistent with existing home volume pattern, accessible by all agents | — Pending | +| Optional install step | Not all users have Obsidian; keep core CrewDock lightweight | — Pending | +| Minimal config (vault path only) | Reduce friction; Syncthing device pairing can happen via web UI post-install | — Pending | + +## Evolution + +This document evolves at phase transitions and milestone boundaries. + +**After each phase transition** (via `/gsd:transition`): +1. Requirements invalidated? -> Move to Out of Scope with reason +2. Requirements validated? -> Move to Validated with phase reference +3. New requirements emerged? -> Add to Active +4. Decisions to log? -> Add to Key Decisions +5. "What This Is" still accurate? -> Update if drifted + +**After each milestone** (via `/gsd:complete-milestone`): +1. Full review of all sections +2. Core Value check — still the right priority? +3. Audit Out of Scope — reasons still valid? +4. Update Context with current state + +--- +*Last updated: 2026-03-25 after initialization* diff --git a/.planning/config.json b/.planning/config.json new file mode 100644 index 0000000..cd614aa --- /dev/null +++ b/.planning/config.json @@ -0,0 +1,35 @@ +{ + "model_profile": "balanced", + "commit_docs": true, + "parallelization": true, + "search_gitignored": false, + "brave_search": false, + "firecrawl": false, + "exa_search": false, + "git": { + "branching_strategy": "none", + "phase_branch_template": "gsd/phase-{phase}-{slug}", + "milestone_branch_template": "gsd/{milestone}-{slug}", + "quick_branch_template": null + }, + "workflow": { + "research": false, + "plan_check": true, + "verifier": true, + "nyquist_validation": true, + "auto_advance": false, + "node_repair": true, + "node_repair_budget": 2, + "ui_phase": true, + "ui_safety_gate": true, + "text_mode": false, + "research_before_questions": false, + "discuss_mode": "discuss", + "skip_discuss": false + }, + "hooks": { + "context_warnings": true + }, + "mode": "yolo", + "granularity": "standard" +} \ No newline at end of file diff --git a/install.sh b/install.sh index dc83302..fa34273 100755 --- a/install.sh +++ b/install.sh @@ -26,6 +26,32 @@ if [ ! -f "$MANIFEST" ]; then exit 1 fi +# --- Helper: git identity prompts --- +_run_git_identity() { + print_header "Git Identity" + print_info "Used for commits made by Forge." + echo "" + local existing_name existing_email git_name git_email + existing_name=$(env_get "GIT_AUTHOR_NAME") + existing_email=$(env_get "GIT_AUTHOR_EMAIL") + git_name=$(gum_input "Full name" "${existing_name:-Your Name}") + if [ -z "$git_name" ] && [ -n "$existing_name" ]; then + git_name="$existing_name" + fi + git_email=$(gum_input "Email address" "${existing_email:-you@example.com}") + if [ -z "$git_email" ] && [ -n "$existing_email" ]; then + git_email="$existing_email" + fi + if [ -z "$git_name" ] || [ -z "$git_email" ]; then + print_warn "Git name and email are required. Skipping." + echo "" + return + fi + env_set "GIT_AUTHOR_NAME" "$git_name" + env_set "GIT_AUTHOR_EMAIL" "$git_email" + echo "" +} + # --- Detect mode --- RECONFIG=0 EXISTING_ENV="$SCRIPT_DIR/.env" @@ -64,6 +90,237 @@ if [ "$RECONFIG" -eq 1 ]; then fi print_info "You can add, remove, or update settings." echo "" + + # Source all integration modules for reconfigure mode + # shellcheck source=installer/discord.sh + source "$SCRIPT_DIR/installer/discord.sh" + # shellcheck source=installer/github.sh + source "$SCRIPT_DIR/installer/github.sh" + # shellcheck source=installer/claude.sh + source "$SCRIPT_DIR/installer/claude.sh" + # shellcheck source=installer/gws.sh + source "$SCRIPT_DIR/installer/gws.sh" + # shellcheck source=installer/xurl.sh + source "$SCRIPT_DIR/installer/xurl.sh" + + # --- Reconfigure: agent submenu --- + _run_agent_submenu() { + local agent_id="$1" + local agent_name="$2" + while true; do + local menu_items="" + # Git Identity first for Forge + if [ "$agent_id" = "forge" ]; then + local git_name + git_name=$(env_get "GIT_AUTHOR_NAME") + local git_status + [ -n "$git_name" ] && git_status="configured" || git_status="not configured" + menu_items="${menu_items}Git Identity (${git_status})\n" + fi + # Integrations from manifest + local intg_ids + intg_ids=$(jq -r --arg id "$agent_id" '.agents[] | select(.id == $id) | .integrations | keys[]' "$MANIFEST") + for intg in $intg_ids; do + local intg_label intg_status + intg_label=$(jq -r --arg intg "$intg" '.integrations[$intg].label' "$MANIFEST") + intg_status=$(_integration_status "$agent_id" "$intg") + menu_items="${menu_items}${intg_label} (${intg_status})\n" + done + menu_items="${menu_items}Back" + + print_header "$agent_name" + local choice + choice=$(printf "%b" "$menu_items" | gum choose --header "") + [ -z "$choice" ] && break + [ "$choice" = "Back" ] && break + + # Strip " (status)" suffix to get the label + local label + label=$(echo "$choice" | sed -E 's/ \([^)]*\)$//') + if [ "$label" = "Git Identity" ]; then + _run_git_identity + else + # Resolve label back to integration key via manifest + local intg_key + intg_key=$(jq -r --arg label "$label" \ + '.integrations | to_entries[] | select(.value.label == $label) | .key' "$MANIFEST") + case "$intg_key" in + discord) + if [ -z "$(env_get "DISCORD_GUILD")" ]; then + run_discord_shared + fi + run_discord_agent "$agent_id" "$agent_name" + ;; + github) + run_github + ;; + claude) + run_claude + ;; + gws) + run_gws + ;; + xurl) + run_xurl + ;; + esac + fi + done + } + + # --- Reconfigure: top-level menu --- + run_reconfigure_menu() { + # Build id|name pairs once; reused for menu rendering and choice resolution + local agent_map + agent_map=$(jq -r '.agents[] | .id + "|" + .name' "$MANIFEST") + + while true; do + local menu_items="" + local aid aname aid_upper token_val + while IFS='|' read -r aid aname; do + aid_upper=$(echo "$aid" | tr '[:lower:]' '[:upper:]') + token_val=$(env_get "DISCORD_${aid_upper}_TOKEN") + if [ -n "$token_val" ]; then + menu_items="${menu_items}${aname}\n" + else + menu_items="${menu_items}${aname} (not installed)\n" + fi + done </dev/null; then + env_set "OPENCLAW_GATEWAY_TOKEN" "" + fi + + # Create runtime directories + mkdir -p \ + "$SCRIPT_DIR/home/.openclaw/workspace" \ + "$SCRIPT_DIR/home/.claude" \ + "$SCRIPT_DIR/home/.config/gh" \ + "$SCRIPT_DIR/home/.config/gws" + [ -f "$SCRIPT_DIR/home/.xurl" ] || touch "$SCRIPT_DIR/home/.xurl" + + echo "" + print_header "Configuration Updated" + echo "" + + gum style --foreground 212 "Configured agents:" + local all_agent_ids + all_agent_ids=$(jq -r '.agents[].id' "$MANIFEST") + for aid in $all_agent_ids; do + local aid_upper token aname + aid_upper=$(echo "$aid" | tr '[:lower:]' '[:upper:]') + token=$(env_get "DISCORD_${aid_upper}_TOKEN") + if [ -n "$token" ]; then + aname=$(jq -r --arg id "$aid" '.agents[] | select(.id == $id) | .name' "$MANIFEST") + echo " ✓ $aname" + fi + done + echo "" + + gum style --foreground 212 "Integrations:" + local discord_guild gh_token claude_token x_token + discord_guild=$(env_get "DISCORD_GUILD") + [ -n "$discord_guild" ] && echo " ✓ Discord (configured)" || true + gh_token=$(env_get "GH_TOKEN") + [ -n "$gh_token" ] && echo " ✓ GitHub (configured)" || true + claude_token=$(env_get "CLAUDE_CODE_OAUTH_TOKEN") + [ -n "$claude_token" ] && echo " ✓ Claude Code (configured)" || true + [ -f "$SCRIPT_DIR/home/.config/gws/credentials.json" ] && echo " ✓ Google Workspace (configured)" || true + x_token=$(env_get "X_BEARER_TOKEN") + [ -n "$x_token" ] && echo " ✓ X/Twitter (configured)" || true + echo "" + + if gum_confirm "Restart OpenClaw now? (make restart)"; then + print_info "Running make restart..." + make -C "$SCRIPT_DIR" restart + else + print_info "Run 'make restart' to apply changes." + fi + } + + run_reconfigure_menu + run_reconfigure_summary + exit 0 fi # --- Screen 2: Agent Selection --- @@ -82,7 +339,6 @@ $ALL_AGENTS EOF AGENT_CHOICES="${AGENT_CHOICES#$'\n'}" -# In reconfigure mode, we'll just show all options SELECTED_DISPLAY=$(echo "$AGENT_CHOICES" | gum choose --no-limit --header "Space to select, Enter to confirm:") if [ -z "$SELECTED_DISPLAY" ]; then print_warn "No agents selected. Exiting." @@ -140,26 +396,7 @@ agents_for_integration() { # --- Screen 3: Git Identity (only for Forge) --- case " $SELECTED_AGENT_IDS " in *" forge "*) - print_header "Git Identity" - print_info "Used for commits made by Forge." - echo "" - - EXISTING_NAME=$(env_get "GIT_AUTHOR_NAME") - EXISTING_EMAIL=$(env_get "GIT_AUTHOR_EMAIL") - - GIT_NAME=$(gum_input "Full name" "${EXISTING_NAME:-Your Name}") - if [ -z "$GIT_NAME" ] && [ -n "$EXISTING_NAME" ]; then - GIT_NAME="$EXISTING_NAME" - fi - - GIT_EMAIL=$(gum_input "Email address" "${EXISTING_EMAIL:-you@example.com}") - if [ -z "$GIT_EMAIL" ] && [ -n "$EXISTING_EMAIL" ]; then - GIT_EMAIL="$EXISTING_EMAIL" - fi - - env_set "GIT_AUTHOR_NAME" "$GIT_NAME" - env_set "GIT_AUTHOR_EMAIL" "$GIT_EMAIL" - echo "" + _run_git_identity ;; esac diff --git a/installer/lib.sh b/installer/lib.sh index 5575630..f170d92 100644 --- a/installer/lib.sh +++ b/installer/lib.sh @@ -189,3 +189,43 @@ env_blank() { local key="$1" env_set "$key" "" } + +# --------------------------------------------------------------------------- +# Integration status +# --------------------------------------------------------------------------- + +# _integration_status AGENT_ID INTEGRATION_KEY — returns "configured" or "not configured" +_integration_status() { + local agent_id="$1" + local intg="$2" + local agent_upper + agent_upper=$(echo "$agent_id" | tr '[:lower:]' '[:upper:]') + case "$intg" in + discord) + local token + token=$(env_get "DISCORD_${agent_upper}_TOKEN") + [ -n "$token" ] && echo "configured" || echo "not configured" + ;; + github) + local token + token=$(env_get "GH_TOKEN") + [ -n "$token" ] && echo "configured" || echo "not configured" + ;; + claude) + local token + token=$(env_get "CLAUDE_CODE_OAUTH_TOKEN") + [ -n "$token" ] && echo "configured" || echo "not configured" + ;; + gws) + [ -f "$SCRIPT_DIR/home/.config/gws/credentials.json" ] && echo "configured" || echo "not configured" + ;; + xurl) + local token + token=$(env_get "X_BEARER_TOKEN") + [ -n "$token" ] && echo "configured" || echo "not configured" + ;; + *) + echo "not configured" + ;; + esac +} diff --git a/tests/reconfigure.bats b/tests/reconfigure.bats new file mode 100644 index 0000000..5777ae4 --- /dev/null +++ b/tests/reconfigure.bats @@ -0,0 +1,145 @@ +#!/usr/bin/env bats +# Tests for _integration_status helper in install.sh + +load test_helper + +setup() { + setup_tmpdir + export SCRIPT_DIR="$TEST_TMPDIR" + # Stub gum so lib.sh can be sourced without it installed + gum() { :; } + export -f gum + source "$PROJECT_ROOT/installer/lib.sh" + export -f _integration_status +} + +teardown() { + teardown_tmpdir +} + +# --------------------------------------------------------------------------- +# discord +# --------------------------------------------------------------------------- + +@test "_integration_status: discord configured when DISCORD_FORGE_TOKEN set" { + echo "DISCORD_FORGE_TOKEN=some-token" > "$TEST_TMPDIR/.env" + run _integration_status "forge" "discord" + [ "$output" = "configured" ] +} + +@test "_integration_status: discord not configured when DISCORD_FORGE_TOKEN empty" { + echo "DISCORD_FORGE_TOKEN=" > "$TEST_TMPDIR/.env" + run _integration_status "forge" "discord" + [ "$output" = "not configured" ] +} + +@test "_integration_status: discord not configured when DISCORD_FORGE_TOKEN absent" { + run _integration_status "forge" "discord" + [ "$output" = "not configured" ] +} + +@test "_integration_status: discord uses agent id uppercased (scouter)" { + echo "DISCORD_SCOUTER_TOKEN=abc123" > "$TEST_TMPDIR/.env" + run _integration_status "scouter" "discord" + [ "$output" = "configured" ] +} + +# --------------------------------------------------------------------------- +# github +# --------------------------------------------------------------------------- + +@test "_integration_status: github configured when GH_TOKEN set" { + echo "GH_TOKEN=ghp_abc123" > "$TEST_TMPDIR/.env" + run _integration_status "forge" "github" + [ "$output" = "configured" ] +} + +@test "_integration_status: github not configured when GH_TOKEN empty" { + echo "GH_TOKEN=" > "$TEST_TMPDIR/.env" + run _integration_status "forge" "github" + [ "$output" = "not configured" ] +} + +@test "_integration_status: github not configured when GH_TOKEN absent" { + run _integration_status "forge" "github" + [ "$output" = "not configured" ] +} + +# --------------------------------------------------------------------------- +# claude +# --------------------------------------------------------------------------- + +@test "_integration_status: claude configured when CLAUDE_CODE_OAUTH_TOKEN set" { + echo "CLAUDE_CODE_OAUTH_TOKEN=oauth-token-xyz" > "$TEST_TMPDIR/.env" + run _integration_status "forge" "claude" + [ "$output" = "configured" ] +} + +@test "_integration_status: claude not configured when CLAUDE_CODE_OAUTH_TOKEN empty" { + echo "CLAUDE_CODE_OAUTH_TOKEN=" > "$TEST_TMPDIR/.env" + run _integration_status "forge" "claude" + [ "$output" = "not configured" ] +} + +@test "_integration_status: claude not configured when CLAUDE_CODE_OAUTH_TOKEN absent" { + run _integration_status "forge" "claude" + [ "$output" = "not configured" ] +} + +# --------------------------------------------------------------------------- +# gws +# --------------------------------------------------------------------------- + +@test "_integration_status: gws configured when credentials.json exists" { + mkdir -p "$TEST_TMPDIR/home/.config/gws" + touch "$TEST_TMPDIR/home/.config/gws/credentials.json" + run _integration_status "forge" "gws" + [ "$output" = "configured" ] +} + +@test "_integration_status: gws not configured when credentials.json absent" { + run _integration_status "forge" "gws" + [ "$output" = "not configured" ] +} + +@test "_integration_status: gws not configured when config dir exists but file missing" { + mkdir -p "$TEST_TMPDIR/home/.config/gws" + run _integration_status "forge" "gws" + [ "$output" = "not configured" ] +} + +# --------------------------------------------------------------------------- +# xurl +# --------------------------------------------------------------------------- + +@test "_integration_status: xurl configured when X_BEARER_TOKEN set" { + echo "X_BEARER_TOKEN=bearer-xyz" > "$TEST_TMPDIR/.env" + run _integration_status "forge" "xurl" + [ "$output" = "configured" ] +} + +@test "_integration_status: xurl not configured when X_BEARER_TOKEN empty" { + echo "X_BEARER_TOKEN=" > "$TEST_TMPDIR/.env" + run _integration_status "forge" "xurl" + [ "$output" = "not configured" ] +} + +@test "_integration_status: xurl not configured when X_BEARER_TOKEN absent" { + run _integration_status "forge" "xurl" + [ "$output" = "not configured" ] +} + +# --------------------------------------------------------------------------- +# unknown integration +# --------------------------------------------------------------------------- + +@test "_integration_status: unknown integration returns not configured" { + run _integration_status "forge" "nonexistent" + [ "$output" = "not configured" ] +} + +@test "_integration_status: unknown integration with token in env still returns not configured" { + echo "SOME_TOKEN=value" > "$TEST_TMPDIR/.env" + run _integration_status "forge" "unknown_intg" + [ "$output" = "not configured" ] +}