diff --git a/.gitignore b/.gitignore index 423ca3f..5913faa 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,91 @@ dist/ coverage/ docs/superpowers/ *.tgz + +# >>> brigade gitignore block >>> +# Managed by `brigade init`. Edit between the markers to customize. +# Re-running `brigade init` replaces only the content between markers. + +# claude: handoffs are session-local and may contain private context. +.claude/memory-handoffs/* +!.claude/memory-handoffs/TEMPLATE.md +!.claude/memory-handoffs/.gitkeep + +# Daily session logs are machine-local raw context. +memory/20[0-9][0-9]-[0-1][0-9]-[0-3][0-9].md + +# Review inbox: ambiguous handoffs awaiting human triage. +memory/handoff-inbox/ + +# brigade local state (logs, scrub cache, dogfood runs, work sessions). +.brigade/ +.brigade/backups/ +.brigade/backups.toml +.brigade/center/ +.brigade/context/ +.brigade/dogfood.toml +.brigade/handoffs/ +.brigade/handoff-sources.json +.brigade/learn/ +.brigade/projects.toml +.brigade/release/ +.brigade/repos.toml +.brigade/chat-surfaces.toml +.brigade/daily.toml +.brigade/memory-care.toml +.brigade/reviews.toml +.brigade/scanners.toml +.brigade/security.toml +.brigade/tools.toml +.brigade/logs/ +.brigade/runs/ +.brigade/scrub-cache/ +.brigade/scanners/ +.brigade/security/ +.brigade/tools/ +.brigade/chat-memory-sweeps/ +.brigade/work/ +.brigade/mcp/ +# .brigade/mcp.json is the shared canonical MCP server catalog: keep it tracked. +!.brigade/mcp.json + +# Generated tool projections are local harness state. +.claude/commands/ +.codex/skills/ +.opencode/commands/ +.opencode/superpowers/ +.antigravity/commands/ +.antigravity/superpowers/ +.pi/commands/ +.pi/superpowers/ +.cursor/rules/ +.cursor/skills/ +.aider/commands/ +.aider/skills/ +.goose/commands/ +.goose/skills/ +.continue/rules/ +.continue/skills/ +.copilot/instructions/ +.copilot/skills/ +.qwen/commands/ +.qwen/skills/ +.kimi/commands/ +.kimi/skills/ +.adal/commands/ +.adal/skills/ +.openhands/instructions/ +.openhands/skills/ +.grok/instructions/ +.grok/skills/ +.amp/instructions/ +.amp/skills/ +.crush/instructions/ +.crush/skills/ +.hermes/commands/ +.hermes/superpowers/ +.openclaw/commands/ +.openclaw/superpowers/ +.mcp/ +scripts/*.md +# <<< brigade gitignore block <<< diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..60bf498 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,76 @@ +# AGENTS.md - Working In This Repo + +This repo is Brigade-wired. These are the operating rules for any agent working here. + +## Every Session + +For substantial work, first gather context: + +1. Read this file - operating rules and the memory handoff contract. +2. Read the repo's `README` and `CONTRIBUTING` (if present) for build, test, and style expectations. +3. Skim your harness's handoff inbox (`.claude/memory-handoffs/`) for recent notes from other sessions. +4. Read `SAFETY_RULES.md` once. Hard boundaries. + +For tiny read-only commands, do the command directly and avoid loading unrelated context. Do not ask permission for normal context gathering. + +## Definition of Done + +Before you report work as complete: + +1. Run the project's checks (tests, linters, type checks, build). If you do not know them, look in `README`, `CONTRIBUTING`, `pyproject.toml` / `package.json`, or the CI config before guessing. +2. Report the actual result. Never claim success you did not observe; paste the command you ran and its output. +3. If a check could not run, say so and name the blocker instead of guessing. +4. If the session produced durable knowledge, confirm a Memory Handoff was written (see below). + +## Memory Owner + +The configured memory owner is **claude**. Side harnesses may keep local session context, but durable knowledge must be written as a Memory Handoff in your harness's own inbox (`.claude/memory-handoffs/`). The memory owner ingests those handoffs into canonical durable memory; reviewed repo-local notes land under `.learnings/`. + +If you are not the memory owner, do not edit `SAFETY_RULES.md`, `rules/`, or `.learnings/` as canonical memory unless the user explicitly asks for that file edit. Do not create a second canonical memory system. + +## Memory Handoff (Mandatory) + +If a session discovers durable knowledge - architecture decisions, workflow changes, non-obvious fixes, setup gotchas, security findings, reusable commands, durable research, or user preferences - create a handoff at the end of the task. + +Write the handoff to your harness's inbox (`.claude/memory-handoffs/`) as `-.md` using the format in that inbox's `TEMPLATE.md`. If the template is missing, run `brigade handoff-template` to print it. + +Do not wait to be reminded. Do not edit canonical memory directly unless this is the memory owner. + +## Daily Work Loop (Mandatory) + +This repo is Brigade-wired, so real work flows THROUGH Brigade and its outcome ledger and learning ratchet fill instead of sitting empty. Invoke the `brigade-work` skill and follow it: + +- **Start:** `brigade work brief --target .` (or `brigade daily status --target .`) to see pending work before deciding what to do. +- **Verify through Brigade, not raw:** when a test or check result should count, run `brigade work verify run --target . --command ""` instead of running it bare. This writes a receipt with the real exit code. +- **Capture the outcome:** immediately after, `brigade outcome capture --run-id latest` against whatever skill or card did the work. A failure is signal too. +- **Finish:** write the Memory Handoff (above). + +Running tests raw produces no signal. If `brigade outcome rank --target .` ever says "ranking: none", the loop is not being fed - this is the difference between Brigade installed and Brigade used. + +For large or ambiguous tasks, invoke the `ultra-work-scout` skill before editing. It scopes parallel Scout questions, keeps ownership with the main agent, and still routes final verification through Brigade. + +## Self-Improvement + +When the user corrects you, write a Memory Handoff capturing the correction and *why*, so the next session does not re-make the same mistake. Check your inbox's recent notes before similar tasks. + +## Safety + +- Keep private data local unless the user explicitly asks otherwise. +- Do not run destructive commands without asking. +- Prefer recoverable deletes (`trash`) over permanent recursive deletion. +- When in doubt, ask. + +Full hard rules: `SAFETY_RULES.md`. + +## Git And Repo Work + +- Do not revert or overwrite user changes. +- Do not use destructive git commands without explicit approval. +- Use conventional commits. +- Never add `Co-Authored-By` lines. +- Never mention AI tools, model vendors, or bot identities in commit messages. +- Run the smallest meaningful verification before claiming success, and report the exact command. + +## Brigade operator setup (advanced) + +Beyond the daily work loop above, an operator sets up readiness and tool sync with `brigade operator guide`, `brigade operator doctor --target .`, and `brigade operator sync-tools --target .`. Keep `.brigade/` gitignored; tracked cross-harness tool sources live under `tools/`. Brigade does not run automatically, start daemons, install hooks, send notifications, publish, push, tag, or mutate remotes. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..ba75e6a --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,41 @@ +# CLAUDE.md - Claude Code Rules + +## Project rules + +- Follow `AGENTS.md` in this repo. This file is the Claude Code-specific bridge; cross-harness behavior lives in `AGENTS.md`. + +## Memory handoff + +The canonical memory owner on this repo is **claude**. Claude Code may keep local session context, but durable knowledge must be written as a Memory Handoff in `.claude/memory-handoffs/`. Full contract in `AGENTS.md`. + +At the end of any substantial task, check whether the session produced durable knowledge. If yes, write a handoff using `.claude/memory-handoffs/TEMPLATE.md`. Do not wait to be reminded. + +## Brigade work loop (Mandatory) + +This repo is Brigade-wired. Route real work through Brigade so its outcome ledger fills. Invoke the `brigade-work` skill: `brigade work brief` at the start; run verifications via `brigade work verify run --target . --command ""` (not raw) when the result should count; then `brigade outcome capture --run-id latest`; handoff at the end. If `brigade outcome rank` says "ranking: none", work is not flowing through Brigade. Full contract in `AGENTS.md`. + +For large scoping work, invoke `ultra-work-scout` first. It is installed with the built-in Brigade skills and keeps Scout delegation tied to verified Brigade work. + +## Closeout + +- Report the exact verification command you ran. +- If verification could not run, state the blocker. +- If a Memory Handoff was warranted, confirm where it landed. + +## Tool use + +- Say it = call it. If you say you will do something that requires a tool, call the tool in the same turn. Silent intent is a lie. +- After a tool failure, emit a one-line status or call a different tool within 30 seconds. Do not silently reason for minutes. + +## Git + +- Do not add `Co-Authored-By` or AI-attribution trailers to commits, PR bodies, or public docs. +- Use conventional commits. +- Never bypass pre-push hooks (`--no-verify`) unless the user has explicitly accepted the risk. +- Never push to `main` directly on shared repos. Feature branch + PR. + +## When in doubt + +- Default to reading more before writing more. +- Ask one specific question rather than guess. +- Surface tradeoffs rather than presenting decisions as facts. diff --git a/SAFETY_RULES.md b/SAFETY_RULES.md new file mode 100644 index 0000000..8cbf262 --- /dev/null +++ b/SAFETY_RULES.md @@ -0,0 +1,164 @@ +# SAFETY_RULES.md + +Hard boundaries. These are not preferences. The content-guard pre-push hook and `brigade scrub` enforce some of these mechanically; the rest are agent-side rules. + +--- + +## Content Sanitization for Publishing + +**Never publish infrastructure details in blog posts, social media, or any public content.** + +Sanitize before publishing: + +- **IP addresses:** Replace real IPs with documented examples (e.g. `203.0.113.x`, `192.0.2.x`, `198.51.100.x` from RFC 5737). +- **Internal domain names:** Replace real domains with placeholders (e.g. `corp.local` -> `lab.local`). +- **OU names / paths:** Replace real OUs. +- **Service account names:** Replace real accounts with descriptive placeholders. +- **Hostnames:** Replace real hostnames with generic ones. +- **Credentials:** Remove entirely or use `` placeholder. +- **Combined identifiers:** Room numbers + IPs + domain + account name paint a full network map. Sanitize all of them together, not piecemeal. + +The pre-push hook runs content-guard with the `public-repo` policy. For publish-ready artifacts (blog posts, social drafts, docs), use the stricter `public-content` policy: `brigade scrub --policy public-content`. + +--- + +## External Communication + +**Never send emails, messages, or social posts on the user's behalf without explicit confirmation.** + +- Draft only. Save to file or display the draft. +- The user reviews and sends manually, or grants explicit permission. +- Exception: test messages to the user themselves are fine if explicitly requested. + +--- + +## Safe vs. ask-first + +**Safe to do freely:** + +- Reading files, research, web searches. +- Drafting content, code, documents. +- Organizing files and notes. +- Local file operations: create, edit, move. +- Checking calendars, weather, status APIs. + +**Always ask first:** + +- Sending emails, messages, or any external communication. +- Posting to social media. +- Making purchases or financial transactions. +- Deleting files or data. +- Running destructive commands (`rm`, `dd`, `git push --force`, `pct destroy`, etc.). + +--- + +## Preferred Tools + +- Use `trash` (or your platform equivalent) instead of `rm`. Recoverable beats gone forever. +- Use `git push --no-verify` only when the user has explicitly accepted the risk. Even then, log why. + +--- + +## Skill and Package Installation Safety + +**Never install any external skill, package, or dependency without explicit user approval.** + +Before installing anything (even with user approval): + +1. Search the exact package name in your registry's malware database before running any install command. +2. Check for typosquatting (similar names to popular packages). +3. Review the package source for: + - Suspicious "Prerequisites" sections asking to download external binaries. + - Reverse-shell code or outbound connections to unknown hosts. + - Any code that reads `.env`, API keys, or credential files. + - Obfuscated shell scripts or password-protected archives. +4. If a package appears in a malware database or shows red flags: **do not install** and alert the user immediately. + +**Default stance:** only use skills the user built themselves or has explicitly vetted and approved. Do not browse public skill registries autonomously. + +**Applies to:** npm, pip, cargo, go modules, gem, plugin registries, skill stores, and any package manager. + +--- + +## Git Commit Rules + +**Never add AI attribution to commits.** + +- No `Co-Authored-By` lines pointing at any AI/model/vendor. +- No `noreply@.com` (e.g. `noreply` addresses from AI vendors) or any AI-vendor email. +- No mentions of "Claude", "AI", "GPT", "Anthropic", "OpenAI", or the agent's own name in commit messages. + +**Commit style:** + +- Conventional commits: `feat:`, `fix:`, `chore:`, `docs:`, `refactor:`, `test:`, `perf:`. +- Write as a human developer would. +- Focus on **what** changed and **why**. +- Keep messages concise and professional. + +**Sensitive data in git history:** + +- If sensitive data was committed, `git rm` does **not** remove it from history. +- Use `git filter-repo` (preferred) or `git filter-branch` plus force push. +- Verify with `git log -p -- ` after cleanup. +- Force-push only after coordinating with anyone else on the branch. + +--- + +## Memory Hygiene + +- Do not write durable memory entries directly; use the handoff flow. +- Do not promote unverified reflections into canonical memory. +- Stale memory is worse than missing memory. Update or remove entries when their basis changes. +- Do not load knowledge cards in shared / group contexts that include other people. + +--- + +## Production / Remote Safety + +If you have access to remote hosts, virtualization, or shared infrastructure, treat them as production unless the user has explicitly said otherwise. + +**Never without explicit confirmation:** + +- Destroy or stop VMs / containers. +- Modify network config on running containers. +- Recursive force-deletion inside production. +- Change firewall, DNS, or routing rules. + +**Safe to do freely on shared infra:** + +- Read-only inspection: `status`, `config`, `list`, `top`-like commands. +- Resource monitoring. +- Non-destructive snapshots and backups. + +--- + +## Data Stores Worth Protecting + +If the workspace touches irreplaceable data (family photos, archives, backups, phone exports), default that mount or path to **read-only**. + +Rules: + +- No `rm`, `trash`, `mv` on the protected path without explicit confirmation. +- No bulk operations (`rsync --delete`, `find -delete`) against the protected path. +- Copy **from** the path, rarely **to** it. + +Document the protected paths and what lives there. + +--- + +## Personal Workstation Safety + +If the workspace shares a network with the user's personal daily driver (different machine, same LAN), treat that machine as **off-limits without explicit confirmation**. Do not restart, kill processes, install software, or modify settings remotely. Read-only access is fine; mutation is not. + +--- + +## NEVER + +- Racist, political, anti-religious, or whiny output. +- Posting on behalf of the user without approval. +- Bypassing the content-guard publish gate without explicit acceptance. +- Disclosing the internal AI drafting workflow for the user's public-facing content unless they explicitly approved. + +--- + +*Add new rules here as the user corrects you. The point is to stop repeating the same mistakes, not to write a manifesto.* diff --git a/hooks/pre-push b/hooks/pre-push index bda2c30..e1f8751 100755 --- a/hooks/pre-push +++ b/hooks/pre-push @@ -1,39 +1,78 @@ #!/usr/bin/env bash -# pre-push: block push if content-guard finds blocking violations. -# Scans tracked files in the repo against policies/public-repo.json. -# (Tracked-only scan — untracked working-tree files are out of scope.) -# Bypass only if you know what you're doing: git push --no-verify +# pre-push: block push if brigade guard finds blocking violations. +# +# Two scans run: +# 1. tracked tip - all tracked files in the current working tree +# 2. push history - content INTRODUCED by the commits being pushed +# (closes the forward-scrub gap: a clean tip can still +# sit on top of commits that leak in their diffs) +# +# Requires brigade-cli (the guard is embedded): pipx install brigade-cli +# +# Optional: set CONTENT_GUARD_EXTRA_POLICY (or drop a file at +# ~/.config/content-guard/internal.json) with a private identifier denylist +# (hostnames, usernames, internal subnets). That file is NEVER committed to a +# public repo - it stays local so the denylist itself does not leak. +# +# Bypass only if you know what you are doing: git push --no-verify set -euo pipefail -CONTENT_GUARD_DIR="${CONTENT_GUARD_DIR:-$HOME/repos/content-guard}" -POLICY="${CONTENT_GUARD_POLICY:-$CONTENT_GUARD_DIR/policies/public-repo.json}" - -if [[ ! -d "$CONTENT_GUARD_DIR/src/content_guard" ]]; then - echo "pre-push: content-guard not found at $CONTENT_GUARD_DIR" >&2 - echo "pre-push: clone https://github.com/solomonneas/content-guard or set CONTENT_GUARD_DIR" >&2 +if ! command -v brigade >/dev/null 2>&1; then + echo "pre-push: brigade not found on PATH" >&2 + echo "pre-push: install with: pipx install brigade-cli" >&2 exit 1 fi -if [[ ! -f "$POLICY" ]]; then - echo "pre-push: policy file not found: $POLICY" >&2 - exit 1 -fi +EXTRA_POLICY="${CONTENT_GUARD_EXTRA_POLICY:-$HOME/.config/content-guard/internal.json}" REPO_ROOT="$(git rev-parse --show-toplevel)" cd "$REPO_ROOT" -echo "pre-push: scanning tracked files against $(basename "$POLICY")" +RUN() { brigade guard git "$@"; } +ZERO="0000000000000000000000000000000000000000" +FAIL=0 +STDIN_REFS="$(cat)" + +# Apply the private policy's allow_values (known-public literals such as a +# public author email or an example port) to EVERY scan, including the public +# one. This is how a history scan of an old commit clears those literals: no +# inline `content-guard: allow` marker can exist in a past diff, and the values +# stay in the private file rather than a shipped public policy. +ALLOW_FROM=() +[[ -f "$EXTRA_POLICY" ]] && ALLOW_FROM=(--allow-values-from "$EXTRA_POLICY") + +scan_with() { + # No args = the embedded public-repo policy. Otherwise: --policy . + local -a pol=("$@") + echo "pre-push: [tip] scanning tracked files (${pol[1]:-embedded public-repo})" + RUN --all-tracked "${pol[@]+"${pol[@]}"}" "${ALLOW_FROM[@]+"${ALLOW_FROM[@]}"}" || FAIL=1 + while read -r _lref lsha _rref rsha; do + [[ -z "${lsha:-}" || "$lsha" == "$ZERO" ]] && continue # branch deletion + if [[ "$rsha" == "$ZERO" ]]; then range="$lsha"; else range="$rsha..$lsha"; fi + echo "pre-push: [history] scanning introduced content in $range" + RUN --history --range "$range" "${pol[@]+"${pol[@]}"}" "${ALLOW_FROM[@]+"${ALLOW_FROM[@]}"}" || FAIL=1 + done < <(printf '%s\n' "$STDIN_REFS") +} + +if [[ -n "${CONTENT_GUARD_POLICY:-}" ]]; then + scan_with --policy "$CONTENT_GUARD_POLICY" +else + scan_with +fi +[[ -f "$EXTRA_POLICY" ]] && scan_with --policy "$EXTRA_POLICY" -if ! PYTHONPATH="$CONTENT_GUARD_DIR/src" python3 -m content_guard.git_scan --all-tracked --policy "$POLICY"; then - echo >&2 - echo "pre-push: BLOCKED. content-guard found violations in tracked files." >&2 - echo "pre-push:" >&2 - echo "pre-push: To resolve:" >&2 - echo "pre-push: 1. Fix the leak in the offending file, OR" >&2 - echo "pre-push: 2. Add an inline allow tag on the offending line:" >&2 - echo "pre-push: " >&2 - echo "pre-push: or for the whole file (add near the top):" >&2 - echo "pre-push: " >&2 - echo "pre-push:" >&2 - echo "pre-push: Bypass only if you know what you are doing: git push --no-verify" >&2 +if [[ "$FAIL" -ne 0 ]]; then + { + echo + echo "pre-push: BLOCKED. brigade guard found violations." + echo "pre-push: A [history] hit means a leak lives in a commit diff even if the tip is clean." + echo "pre-push: Forward-scrub commits do NOT fix history - rewrite it (git filter-repo) and" + echo "pre-push: re-verify with: brigade guard git --history --all" + echo "pre-push: Inline allow on the tip (only for genuinely public example data, never real infra):" + echo "pre-push: " + echo "pre-push: For a known-public literal that trips a [history] hit (no inline marker can" + echo "pre-push: reach an old diff), add the exact string to allow_values in your private policy" + echo "pre-push: ${EXTRA_POLICY}" + echo "pre-push: Bypass only if you know what you are doing: git push --no-verify" + } >&2 exit 1 fi diff --git a/package-lock.json b/package-lock.json index 1808ae6..dfe8708 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "license": "MIT", "dependencies": { "@immich/sdk": "^2.7.5", + "@lidless-labs/effect-operator-kit": "github:lidless-labs/effect-operator-kit#1f793acd1005fcc2f04f0658befb1399b1841706", "@modelcontextprotocol/sdk": "^1.0.0", "zod": "^3.23.0" }, @@ -22,7 +23,7 @@ "@types/node": "^20.12.0", "tsup": "^8.0.0", "tsx": "^4.7.0", - "typescript": "^5.7.0", + "typescript": "^7.0.2", "vitest": "^2.0.0" }, "engines": { @@ -531,6 +532,15 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@lidless-labs/effect-operator-kit": { + "version": "0.1.0", + "resolved": "git+ssh://git@github.com/lidless-labs/effect-operator-kit.git#1f793acd1005fcc2f04f0658befb1399b1841706", + "integrity": "sha512-IbqYbxCXK+whHrjYBQcpi3Qo+CzeZyGlQWlym2o8WDUX6BNXxdl9xETbT3yBoHgr3fbiogRpBXdrSGu0OnlhmA==", + "license": "MIT", + "dependencies": { + "effect": "^3.21.4" + } + }, "node_modules/@modelcontextprotocol/sdk": { "version": "1.29.0", "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", @@ -927,6 +937,12 @@ "win32" ] }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -944,6 +960,346 @@ "undici-types": "~6.21.0" } }, + "node_modules/@typescript/typescript-aix-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", + "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz", + "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz", + "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz", + "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz", + "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz", + "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz", + "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-loong64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz", + "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-mips64el": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz", + "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz", + "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-riscv64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz", + "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-s390x": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz", + "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz", + "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz", + "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz", + "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz", + "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz", + "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-sunos-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz", + "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz", + "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz", + "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, "node_modules/@vitest/expect": { "version": "2.1.9", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz", @@ -1432,6 +1788,16 @@ "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", "license": "MIT" }, + "node_modules/effect": { + "version": "3.21.4", + "resolved": "https://registry.npmjs.org/effect/-/effect-3.21.4.tgz", + "integrity": "sha512-B89v/xSgPbl1J2Ai2u18jxq3odpFauU1rC6/eSs4FeNHi72kwKdJp12VGigvRV2lK+kRnx+OOz41XV8guZd4gQ==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "fast-check": "^3.23.1" + } + }, "node_modules/encodeurl": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", @@ -1637,6 +2003,28 @@ "express": ">= 4.11" } }, + "node_modules/fast-check": { + "version": "3.23.2", + "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-3.23.2.tgz", + "integrity": "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT", + "dependencies": { + "pure-rand": "^6.1.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -2321,6 +2709,22 @@ "node": ">= 0.10" } }, + "node_modules/pure-rand": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz", + "integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT" + }, "node_modules/qs": { "version": "6.15.2", "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", @@ -3375,17 +3779,38 @@ } }, "node_modules/typescript": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", + "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==", "dev": true, "license": "Apache-2.0", "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" + "tsc": "bin/tsc" }, "engines": { - "node": ">=14.17" + "node": ">=16.20.0" + }, + "optionalDependencies": { + "@typescript/typescript-aix-ppc64": "7.0.2", + "@typescript/typescript-darwin-arm64": "7.0.2", + "@typescript/typescript-darwin-x64": "7.0.2", + "@typescript/typescript-freebsd-arm64": "7.0.2", + "@typescript/typescript-freebsd-x64": "7.0.2", + "@typescript/typescript-linux-arm": "7.0.2", + "@typescript/typescript-linux-arm64": "7.0.2", + "@typescript/typescript-linux-loong64": "7.0.2", + "@typescript/typescript-linux-mips64el": "7.0.2", + "@typescript/typescript-linux-ppc64": "7.0.2", + "@typescript/typescript-linux-riscv64": "7.0.2", + "@typescript/typescript-linux-s390x": "7.0.2", + "@typescript/typescript-linux-x64": "7.0.2", + "@typescript/typescript-netbsd-arm64": "7.0.2", + "@typescript/typescript-netbsd-x64": "7.0.2", + "@typescript/typescript-openbsd-arm64": "7.0.2", + "@typescript/typescript-openbsd-x64": "7.0.2", + "@typescript/typescript-sunos-x64": "7.0.2", + "@typescript/typescript-win32-arm64": "7.0.2", + "@typescript/typescript-win32-x64": "7.0.2" } }, "node_modules/ufo": { diff --git a/package.json b/package.json index 7551482..9420e8e 100644 --- a/package.json +++ b/package.json @@ -45,9 +45,14 @@ "engines": { "node": ">=20.0.0" }, - "files": ["dist", "README.md", "LICENSE"], + "files": [ + "dist", + "README.md", + "LICENSE" + ], "dependencies": { "@immich/sdk": "^2.7.5", + "@lidless-labs/effect-operator-kit": "github:lidless-labs/effect-operator-kit#1f793acd1005fcc2f04f0658befb1399b1841706", "@modelcontextprotocol/sdk": "^1.0.0", "zod": "^3.23.0" }, @@ -55,7 +60,7 @@ "@types/node": "^20.12.0", "tsup": "^8.0.0", "tsx": "^4.7.0", - "typescript": "^5.7.0", + "typescript": "^7.0.2", "vitest": "^2.0.0" } } diff --git a/src/cli.ts b/src/cli.ts index 32911c3..db4fae5 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,5 +1,6 @@ import { realpathSync } from "node:fs"; import { pathToFileURL } from "node:url"; +import { operatorErrorMessage } from "@lidless-labs/effect-operator-kit"; import { getConfig } from "./config.js"; import { ImmichClient } from "./immich-client.js"; import pkg from "../package.json" with { type: "json" }; @@ -517,7 +518,7 @@ export async function run(argv: string[], deps: CliDeps): Promise { try { parsed = parseArgs(argv); } catch (error) { - deps.err(error instanceof Error ? error.message : String(error)); + deps.err(operatorErrorMessage(error)); deps.err(""); deps.err(HELP); return 2; @@ -663,7 +664,8 @@ export async function run(argv: string[], deps: CliDeps): Promise { } } } catch (error) { - deps.err(error instanceof Error ? error.message : String(error)); + // Kit cli adapter message extraction; no repo redact layer (immich has none). + deps.err(operatorErrorMessage(error)); return 1; } return 0; @@ -695,7 +697,7 @@ if (isEntrypoint) { process.exitCode = code; }) .catch((error: unknown) => { - process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); + process.stderr.write(`${operatorErrorMessage(error)}\n`); process.exitCode = 1; }); } diff --git a/src/config.ts b/src/config.ts index e104ec2..6f14dc7 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,3 +1,5 @@ +import { fromProcessEnv, type EnvReader } from "@lidless-labs/effect-operator-kit"; + export interface Config { baseUrl: string; apiKey: string; @@ -5,24 +7,58 @@ export interface Config { verifySsl: boolean; } -function bool(value: string | undefined, fallback: boolean): boolean { - if (value === undefined) return fallback; - return value.toLowerCase() === "true" || value === "1"; +/** + * Kit `requiredString` trims values and uses `${key} is required`. Immich env + * parsing keeps raw values (including trailing spaces) and repo-specific messages. + */ +function requiredEnvString( + env: EnvReader, + key: string, + message: string, +): string { + const raw = env.get(key); + if (!raw) { + throw new Error(message); + } + return raw; } -export function getConfig(): Config { - const baseUrl = process.env.IMMICH_BASE_URL; - if (!baseUrl) { - throw new Error("IMMICH_BASE_URL is required (e.g. https://photos.example.com/api)"); - } - const apiKey = process.env.IMMICH_API_KEY; - if (!apiKey) { - throw new Error("IMMICH_API_KEY is required"); +/** + * Kit `parseBooleanEnv` accepts yes/on/off, throws on invalid tokens, and treats + * blank values as fallback. Immich only treats case-insensitive "true" or exact + * "1" as true; any other defined value is false. + */ +function boolWithFallback( + env: EnvReader, + key: string, + fallback: boolean, +): boolean { + const raw = env.get(key); + if (raw === undefined) { + return fallback; } + return raw.toLowerCase() === "true" || raw === "1"; +} + +/** + * Load Immich MCP config from process env. Uses kit `fromProcessEnv` for env + * access; repo-local wrappers preserve Immich-specific messages and boolean rules. + */ +export function getConfig(): Config { + const env = fromProcessEnv(process.env); + + const baseUrl = requiredEnvString( + env, + "IMMICH_BASE_URL", + "IMMICH_BASE_URL is required (e.g. https://photos.example.com/api)", + ); + + const apiKey = requiredEnvString(env, "IMMICH_API_KEY", "IMMICH_API_KEY is required"); + return { baseUrl, apiKey, - allowWrites: bool(process.env.IMMICH_ALLOW_WRITES, false), - verifySsl: bool(process.env.IMMICH_VERIFY_SSL, true), + allowWrites: boolWithFallback(env, "IMMICH_ALLOW_WRITES", false), + verifySsl: boolWithFallback(env, "IMMICH_VERIFY_SSL", true), }; } diff --git a/src/index.ts b/src/index.ts index 3dfcb91..75ba030 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,6 @@ import { realpathSync } from "node:fs"; import { pathToFileURL } from "node:url"; +import { operatorErrorMessage } from "@lidless-labs/effect-operator-kit"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { getConfig } from "./config.js"; @@ -59,6 +60,12 @@ export async function serve(): Promise { await server.connect(transport); } +/** MCP process fatal boundary: kit message extraction, repo-owned fatal prefix. */ +export function reportMcpFatalError(error: unknown): never { + console.error(`immich-mcp fatal: ${operatorErrorMessage(error)}`); + process.exit(1); +} + // True when this module is the process entrypoint. process.argv[1] is often a // symlink (npm installs the bin as a link); resolve it before comparing so the // back-compat direct-run of index.js still starts the server. @@ -73,9 +80,5 @@ const isEntrypoint = (() => { })(); if (isEntrypoint) { - serve().catch((error: unknown) => { - const msg = error instanceof Error ? error.message : String(error); - console.error(`immich-mcp fatal: ${msg}`); - process.exit(1); - }); + serve().catch(reportMcpFatalError); } diff --git a/src/mcp-bin.ts b/src/mcp-bin.ts index d5a756e..5b39163 100644 --- a/src/mcp-bin.ts +++ b/src/mcp-bin.ts @@ -1,7 +1,3 @@ -import { serve } from "./index.js"; +import { reportMcpFatalError, serve } from "./index.js"; -serve().catch((error: unknown) => { - const msg = error instanceof Error ? error.message : String(error); - console.error(`immich-mcp fatal: ${msg}`); - process.exit(1); -}); +serve().catch(reportMcpFatalError); diff --git a/src/retry.ts b/src/retry.ts index 44d37e2..150b616 100644 --- a/src/retry.ts +++ b/src/retry.ts @@ -1,3 +1,10 @@ +import { Cause, Effect, Exit } from "effect"; +import { + exponentialRetry, + withRetry as withKitRetry, + type OperatorError, +} from "@lidless-labs/effect-operator-kit"; + const BACKOFF_MS = [1000, 2000, 4000]; function isRetryable(err: unknown): boolean { @@ -12,20 +19,40 @@ function jitter(ms: number): number { } export async function withRetry(label: string, fn: () => Promise): Promise { - let lastErr: unknown; - for (let attempt = 0; attempt < BACKOFF_MS.length + 1; attempt++) { - try { - return await fn(); - } catch (e) { - lastErr = e; - if (attempt >= BACKOFF_MS.length || !isRetryable(e)) throw e; - const msg = e instanceof Error ? e.message : String(e); - console.error( - `[immich-mcp] retry ${attempt + 1}/${BACKOFF_MS.length} for ${label}: ${msg}`, - ); - const wait = jitter(BACKOFF_MS[attempt]!); - await new Promise((resolve) => setTimeout(resolve, wait)); - } - } - throw lastErr; + let attempt = 0; + const policy = exponentialRetry({ + maxAttempts: BACKOFF_MS.length + 1, + initialDelayMs: 0, + maxDelayMs: 0, + factor: 1, + jitter: false, + shouldRetry: (err) => isRetryable(err), + }); + + const effect = Effect.tryPromise({ + try: async () => { + try { + return await fn(); + } catch (e) { + const currentAttempt = attempt++; + if (currentAttempt >= BACKOFF_MS.length || !isRetryable(e)) throw e; + + const msg = e instanceof Error ? e.message : String(e); + console.error( + `[immich-mcp] retry ${currentAttempt + 1}/${BACKOFF_MS.length} for ${label}: ${msg}`, + ); + const wait = jitter(BACKOFF_MS[currentAttempt]!); + await new Promise((resolve) => setTimeout(resolve, wait)); + throw e; + } + }, + catch: (e) => e as OperatorError, + }); + + const exit = await Effect.runPromiseExit(withKitRetry(effect, policy)); + if (Exit.isSuccess(exit)) return exit.value; + + const failure = Cause.failureOption(exit.cause); + if (failure._tag === "Some") throw failure.value; + throw Cause.squash(exit.cause); } diff --git a/src/tools/_util.ts b/src/tools/_util.ts index aade87f..367a632 100644 --- a/src/tools/_util.ts +++ b/src/tools/_util.ts @@ -1,3 +1,4 @@ +import { ok, fail, refuseUnconfirmed } from "@lidless-labs/effect-operator-kit"; import type { Config } from "../config.js"; export class WriteDisabledError extends Error { @@ -11,6 +12,7 @@ export class WriteDisabledError extends Error { export class ConfirmRequiredError extends Error { constructor(toolName: string) { + // Kit refuseUnconfirmed wording differs; keep this repo's pinned refusal text. super( `${toolName} is destructive. Pass { confirm: true } in tool args to proceed.`, ); @@ -44,15 +46,36 @@ export function surfaceError(err: unknown): string { return `Immich API ${status}: ${msg}`; } +/** + * Success MCP result. Delegates JSON text formatting to kit `ok`, then drops + * kit's `details` so the observable shape stays content-only (golden contract: + * no `details`, no `isError`). + */ export function asMcpResponse(payload: unknown) { - return { - content: [{ type: "text" as const, text: JSON.stringify(payload, null, 2) }], - }; + const kit = ok(payload); + return { content: kit.content }; } +const CONFIRM_REFUSAL_RE = + /^(.+) is destructive\. Pass \{ confirm: true \} in tool args to proceed\.$/; + +/** + * Error MCP result. Routes confirm-refusal messages through kit + * `refuseUnconfirmed` and all other messages through kit `fail` for the + * isError envelope, while pinning plain-text content. + * + * Semantic wraps: + * - kit `fail("x")` encodes text as JSON `{"error":"x"}`; this repo pins plain `x`. + * - kit `refuseUnconfirmed(op)` uses different refusal wording; this repo pins + * `${op} is destructive. Pass { confirm: true } in tool args to proceed.` + */ export function asMcpError(message: string) { + const confirm = CONFIRM_REFUSAL_RE.exec(message); + const kit = confirm ? refuseUnconfirmed(confirm[1]!) : fail(message); return { - isError: true, - content: [{ type: "text" as const, text: message }], + isError: true as const, + content: kit.content.map((part) => + part.type === "text" ? { type: "text" as const, text: message } : part, + ), }; } diff --git a/tests/_util.test.ts b/tests/_util.test.ts index 9bc7f18..ad75975 100644 --- a/tests/_util.test.ts +++ b/tests/_util.test.ts @@ -18,6 +18,14 @@ describe("requireWrites", () => { it("throws when writes disabled", () => { expect(() => requireWrites(cfg(false))).toThrow(WriteDisabledError); }); + it("pins the writes-disabled refusal text", () => { + expect(new WriteDisabledError().message).toBe( + "Writes disabled. Set IMMICH_ALLOW_WRITES=true to enable destructive and modifying tools.", + ); + expect(() => requireWrites(cfg(false))).toThrow( + "Writes disabled. Set IMMICH_ALLOW_WRITES=true to enable destructive and modifying tools.", + ); + }); it("passes when writes enabled", () => { expect(() => requireWrites(cfg(true))).not.toThrow(); }); @@ -28,6 +36,11 @@ describe("requireConfirm", () => { expect(() => requireConfirm("foo", undefined)).toThrow(ConfirmRequiredError); expect(() => requireConfirm("foo", false as unknown as boolean)).toThrow(ConfirmRequiredError); }); + it("pins the confirm-required refusal text", () => { + expect(new ConfirmRequiredError("foo").message).toBe( + "foo is destructive. Pass { confirm: true } in tool args to proceed.", + ); + }); it("passes with confirm: true", () => { expect(() => requireConfirm("foo", true)).not.toThrow(); }); diff --git a/tests/golden-cli-client-contracts.test.ts b/tests/golden-cli-client-contracts.test.ts new file mode 100644 index 0000000..0152a22 --- /dev/null +++ b/tests/golden-cli-client-contracts.test.ts @@ -0,0 +1,190 @@ +import { spawn } from "node:child_process"; +import { describe, expect, it, vi } from "vitest"; +import { run, type CliDeps } from "../src/cli.js"; + +const sdkMock = vi.hoisted(() => { + const calls: Array<{ fn: string; args: unknown[] }> = []; + const makeFn = (fn: string, value: unknown = undefined) => + vi.fn((...args: unknown[]) => { + calls.push({ fn, args }); + return Promise.resolve(value); + }); + + return { + calls, + init: vi.fn((...args: unknown[]) => { + calls.push({ fn: "init", args }); + }), + pingServer: makeFn("pingServer", { res: "pong" }), + getServerConfig: makeFn("getServerConfig", {}), + getServerStatistics: makeFn("getServerStatistics", {}), + getServerFeatures: makeFn("getServerFeatures", {}), + getStorage: makeFn("getStorage", {}), + getAboutInfo: makeFn("getAboutInfo", {}), + getServerVersion: makeFn("getServerVersion", {}), + getAllAlbums: makeFn("getAllAlbums", []), + getAlbumInfo: makeFn("getAlbumInfo", {}), + getAlbumStatistics: makeFn("getAlbumStatistics", {}), + searchAssets: makeFn("searchAssets", { assets: { items: [] } }), + getAssetInfo: makeFn("getAssetInfo", {}), + getAssetStatistics: makeFn("getAssetStatistics", {}), + getAllPeople: makeFn("getAllPeople", { people: [] }), + getAllTags: makeFn("getAllTags", []), + getAssetDuplicates: makeFn("getAssetDuplicates", []), + getQueuesLegacy: makeFn("getQueuesLegacy", {}), + searchMemories: makeFn("searchMemories", []), + searchSmart: makeFn("searchSmart", { assets: { items: [] } }), + }; +}); + +vi.mock("@immich/sdk", () => ({ + init: sdkMock.init, + pingServer: sdkMock.pingServer, + getServerConfig: sdkMock.getServerConfig, + getServerStatistics: sdkMock.getServerStatistics, + getServerFeatures: sdkMock.getServerFeatures, + getStorage: sdkMock.getStorage, + getAboutInfo: sdkMock.getAboutInfo, + getServerVersion: sdkMock.getServerVersion, + getAllAlbums: sdkMock.getAllAlbums, + getAlbumInfo: sdkMock.getAlbumInfo, + getAlbumStatistics: sdkMock.getAlbumStatistics, + searchAssets: sdkMock.searchAssets, + getAssetInfo: sdkMock.getAssetInfo, + getAssetStatistics: sdkMock.getAssetStatistics, + getAllPeople: sdkMock.getAllPeople, + getAllTags: sdkMock.getAllTags, + getAssetDuplicates: sdkMock.getAssetDuplicates, + getQueuesLegacy: sdkMock.getQueuesLegacy, + searchMemories: sdkMock.searchMemories, + searchSmart: sdkMock.searchSmart, +})); + +import { ImmichClient } from "../src/immich-client.js"; + +function deps(overrides: Partial = {}) { + const out: string[] = []; + const err: string[] = []; + const base: CliDeps = { + out: (s) => out.push(s), + err: (s) => err.push(s), + makeClient: () => + ({ + ping: vi.fn().mockResolvedValue({ res: "pong" }), + serverStatistics: vi.fn().mockResolvedValue({ photos: 0, videos: 0 }), + }) as unknown as ImmichClient, + serve: vi.fn().mockResolvedValue(undefined), + }; + return { out, err, deps: { ...base, ...overrides } }; +} + +function runEntrypoint(argv: string[], envOverrides: Record = {}) { + const env = { ...process.env }; + for (const [key, value] of Object.entries(envOverrides)) { + if (value === undefined) delete env[key]; + else env[key] = value; + } + + return new Promise<{ code: number | null; stdout: string; stderr: string }>((resolve, reject) => { + const child = spawn(process.execPath, ["--import", "tsx", "src/cli.ts", ...argv], { + cwd: process.cwd(), + env, + stdio: ["ignore", "pipe", "pipe"], + }); + let stdout = ""; + let stderr = ""; + child.stdout.setEncoding("utf8"); + child.stderr.setEncoding("utf8"); + child.stdout.on("data", (chunk: string) => { + stdout += chunk; + }); + child.stderr.on("data", (chunk: string) => { + stderr += chunk; + }); + child.on("error", reject); + child.on("close", (code) => resolve({ code, stdout, stderr })); + }); +} + +describe("golden programmatic CLI contracts", () => { + it("rejects with the original construction error when makeClient fails", async () => { + const constructionError = new Error("IMMICH_BASE_URL is required (e.g. https://photos.example.com/api)"); + const captured = deps({ + makeClient: () => { + throw constructionError; + }, + }); + + await expect(run(["ping"], captured.deps)).rejects.toBe(constructionError); + expect(captured.err).toEqual([]); + }); + + it("preserves the startup rejection object identity on the mcp path", async () => { + const startupError = new Error("stdio refused"); + const captured = deps({ + serve: vi.fn().mockRejectedValue(startupError), + }); + + await expect(run(["mcp"], captured.deps)).rejects.toBe(startupError); + }); +}); + +describe("golden CLI exit and stderr contracts", () => { + it("returns exit 2 and prints the current stderr for an unknown command", async () => { + const captured = deps(); + + await expect(run(["bogus"], captured.deps)).resolves.toBe(2); + + expect(captured.err[0]).toBe("Unknown command: bogus"); + expect(captured.err[1]).toBe(""); + expect(captured.err.join("\n")).toContain("Usage:"); + }); + + it("returns exit 1 and prints the current stderr for a failed API call", async () => { + const apiError = new Error("Immich server error 503: upstream unavailable"); + const client = { + serverStatistics: vi.fn().mockRejectedValue(apiError), + } as unknown as ImmichClient; + const captured = deps({ makeClient: () => client }); + + await expect(run(["server", "stats"], captured.deps)).resolves.toBe(1); + + expect(captured.err).toEqual(["Immich server error 503: upstream unavailable"]); + }); + + it("process entrypoint exits 1 and prints only the missing config message", async () => { + const result = await runEntrypoint(["ping"], { + IMMICH_BASE_URL: undefined, + IMMICH_API_KEY: undefined, + }); + + expect(result.code).toBe(1); + expect(result.stdout).toBe(""); + expect(result.stderr).toBe("IMMICH_BASE_URL is required (e.g. https://photos.example.com/api)\n"); + }); +}); + +describe("golden Immich SDK-owned auth and initialization contract", () => { + it("initializes the Immich SDK with baseUrl and apiKey without raw fetch headers", async () => { + sdkMock.calls.length = 0; + const fetchSpy = vi.fn(); + vi.stubGlobal("fetch", fetchSpy); + + const client = new ImmichClient({ + baseUrl: "https://photos.example.com/api", + apiKey: "secret-api-key", + allowWrites: false, + verifySsl: true, + }); + await client.ping(); + + expect(sdkMock.calls[0]).toEqual({ + fn: "init", + args: [{ baseUrl: "https://photos.example.com/api", apiKey: "secret-api-key" }], + }); + expect(sdkMock.calls[1]).toEqual({ fn: "pingServer", args: [] }); + expect(fetchSpy).not.toHaveBeenCalled(); + + vi.unstubAllGlobals(); + }); +}); diff --git a/tests/golden-retry-contracts.test.ts b/tests/golden-retry-contracts.test.ts new file mode 100644 index 0000000..e076d8d --- /dev/null +++ b/tests/golden-retry-contracts.test.ts @@ -0,0 +1,107 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { withRetry } from "../src/retry.js"; + +function makeStatusError(status: number, message = `status ${status}`): Error { + const e = new Error(message) as Error & { status: number }; + e.status = status; + return e; +} + +describe("golden retry contracts", () => { + let errSpy: ReturnType; + let randomSpy: ReturnType; + + beforeEach(() => { + vi.useFakeTimers(); + errSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + randomSpy = vi.spyOn(Math, "random").mockReturnValue(0); + }); + + afterEach(() => { + randomSpy.mockRestore(); + errSpy.mockRestore(); + vi.useRealTimers(); + }); + + it("uses exactly 1 initial attempt plus 3 retries with 1000, 2000, and 4000 ms backoff before throwing the last error", async () => { + const errors = [ + makeStatusError(503, "first"), + makeStatusError(503, "second"), + makeStatusError(503, "third"), + makeStatusError(503, "fourth"), + ]; + const fn = vi + .fn() + .mockRejectedValueOnce(errors[0]) + .mockRejectedValueOnce(errors[1]) + .mockRejectedValueOnce(errors[2]) + .mockRejectedValueOnce(errors[3]); + + const promise = withRetry("golden", fn); + const settled = expect(promise).rejects.toBe(errors[3]); + + await vi.advanceTimersByTimeAsync(999); + expect(fn).toHaveBeenCalledTimes(1); + await vi.advanceTimersByTimeAsync(1); + expect(fn).toHaveBeenCalledTimes(2); + + await vi.advanceTimersByTimeAsync(1999); + expect(fn).toHaveBeenCalledTimes(2); + await vi.advanceTimersByTimeAsync(1); + expect(fn).toHaveBeenCalledTimes(3); + + await vi.advanceTimersByTimeAsync(3999); + expect(fn).toHaveBeenCalledTimes(3); + await vi.advanceTimersByTimeAsync(1); + expect(fn).toHaveBeenCalledTimes(4); + + await settled; + expect(errSpy).toHaveBeenCalledTimes(3); + expect(errSpy).toHaveBeenNthCalledWith(1, "[immich-mcp] retry 1/3 for golden: first"); + expect(errSpy).toHaveBeenNthCalledWith(2, "[immich-mcp] retry 2/3 for golden: second"); + expect(errSpy).toHaveBeenNthCalledWith(3, "[immich-mcp] retry 3/3 for golden: third"); + }); + + it.each([429, 500, 503, 599])("retries status %i", async (status) => { + const fn = vi.fn().mockRejectedValueOnce(makeStatusError(status)).mockResolvedValueOnce("ok"); + + const promise = withRetry("status", fn); + await vi.runAllTimersAsync(); + + await expect(promise).resolves.toBe("ok"); + expect(fn).toHaveBeenCalledTimes(2); + }); + + it.each([400, 401, 403, 404, 499, 600])("does not retry status %i", async (status) => { + const error = makeStatusError(status); + const fn = vi.fn().mockRejectedValue(error); + + const promise = withRetry("status", fn); + const settled = expect(promise).rejects.toBe(error); + await vi.runAllTimersAsync(); + + await settled; + expect(fn).toHaveBeenCalledTimes(1); + expect(errSpy).not.toHaveBeenCalled(); + }); + + it("does not retry non-Error throws or Error objects without status", async () => { + const plain = "plain failure"; + const noStatus = new Error("no status"); + + const plainFn = vi.fn().mockRejectedValue(plain); + const noStatusFn = vi.fn().mockRejectedValue(noStatus); + + const plainPromise = withRetry("plain", plainFn); + const noStatusPromise = withRetry("no-status", noStatusFn); + const plainSettled = expect(plainPromise).rejects.toBe(plain); + const noStatusSettled = expect(noStatusPromise).rejects.toBe(noStatus); + await vi.runAllTimersAsync(); + + await plainSettled; + await noStatusSettled; + expect(plainFn).toHaveBeenCalledTimes(1); + expect(noStatusFn).toHaveBeenCalledTimes(1); + expect(errSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/golden-tool-contracts.test.ts b/tests/golden-tool-contracts.test.ts new file mode 100644 index 0000000..0a8a0b9 --- /dev/null +++ b/tests/golden-tool-contracts.test.ts @@ -0,0 +1,207 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { installFakeSdk, mockSdkResponse, resetFakeSdk, sdkCalls } from "./_fake-sdk.js"; + +installFakeSdk(); + +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { registerAssetTools } from "../src/tools/assets.js"; +import { registerDuplicateTools } from "../src/tools/duplicates.js"; +import { registerPeopleTools } from "../src/tools/people.js"; +import { registerSystemTools } from "../src/tools/system.js"; +import { registerTagTools } from "../src/tools/tags.js"; +import { registerTrashTools } from "../src/tools/trash.js"; + +const UUID_A = "00000000-0000-0000-0000-000000000001"; +const UUID_B = "00000000-0000-0000-0000-000000000002"; +const cfgRead = { baseUrl: "https://photos.example.com/api", apiKey: "k", allowWrites: false, verifySsl: true }; +const cfgWrite = { ...cfgRead, allowWrites: true }; + +type ToolResult = { + isError?: boolean; + details?: unknown; + content: Array<{ type: "text"; text: string }>; +}; + +async function callTool(server: McpServer, name: string, args: Record = {}): Promise { + const reg = (server as unknown as { + _registeredTools: Record Promise }>; + })._registeredTools; + const tool = reg[name]; + if (!tool) throw new Error(`tool ${name} not registered`); + return (await tool.handler(args, {})) as ToolResult; +} + +function makeServer(config = cfgWrite): McpServer { + const server = new McpServer({ name: "immich-mcp", version: "0.0.0-test" }); + registerAssetTools(server, config); + registerDuplicateTools(server, config); + registerPeopleTools(server, config); + registerSystemTools(server, config); + registerTagTools(server, config); + registerTrashTools(server, config); + return server; +} + +function parsedPayload(result: ToolResult): unknown { + expect(result.isError).toBeUndefined(); + expect(result.details).toBeUndefined(); + expect(result.content).toHaveLength(1); + expect(result.content[0]).toMatchObject({ type: "text" }); + return JSON.parse(result.content[0]!.text); +} + +function rawText(result: ToolResult): string { + expect(result.isError).toBeUndefined(); + expect(result.details).toBeUndefined(); + expect(result.content).toHaveLength(1); + expect(result.content[0]).toMatchObject({ type: "text" }); + return result.content[0]!.text; +} + +describe("golden confirm-gated destructive refusal contracts", () => { + let server: McpServer; + + beforeEach(() => { + resetFakeSdk(); + server = makeServer(cfgWrite); + }); + + it.each([ + { + name: "immich_bulk_update_assets", + args: { ids: [UUID_A], isFavorite: true }, + refusal: "immich_bulk_update_assets is destructive. Pass { confirm: true } in tool args to proceed.", + }, + { + name: "immich_delete_asset", + args: { ids: [UUID_A], permanent: true }, + refusal: "immich_delete_asset is destructive. Pass { confirm: true } in tool args to proceed.", + }, + { + name: "immich_resolve_duplicates", + args: { keep: [UUID_A], discard: [UUID_B], delete: true }, + refusal: "immich_resolve_duplicates is destructive. Pass { confirm: true } in tool args to proceed.", + }, + { + name: "immich_merge_people", + args: { id: UUID_A, ids: [UUID_B] }, + refusal: "immich_merge_people is destructive. Pass { confirm: true } in tool args to proceed.", + }, + { + name: "immich_delete_tag", + args: { id: UUID_A }, + refusal: "immich_delete_tag is destructive. Pass { confirm: true } in tool args to proceed.", + }, + { + name: "immich_empty_trash", + args: {}, + refusal: "immich_empty_trash is destructive. Pass { confirm: true } in tool args to proceed.", + }, + ])("$name returns current refusal shape before any SDK call", async ({ name, args, refusal }) => { + const result = await callTool(server, name, args); + + expect(result).toEqual({ + isError: true, + content: [{ type: "text", text: refusal }], + }); + expect(sdkCalls).toEqual([]); + }); + + it("immich_restore_by_query with no filters returns its custom refusal before any SDK call", async () => { + const result = await callTool(server, "immich_restore_by_query", {}); + + expect(result).toEqual({ + isError: true, + content: [ + { + type: "text", + text: "immich_restore_by_query with no filter would restore all trashed assets. Pass { confirm: true } to proceed, or add a takenAfter/takenBefore/type filter.", + }, + ], + }); + expect(sdkCalls).toEqual([]); + }); + + it.each([ + ["immich_bulk_update_assets", { ids: [UUID_A], isFavorite: true }], + ["immich_delete_asset", { ids: [UUID_A], permanent: true }], + ["immich_resolve_duplicates", { keep: [UUID_A], discard: [UUID_B], delete: true }], + ])("%s returns writes-disabled before confirm-required when both gates apply", async (name, args) => { + server = makeServer(cfgRead); + + const result = await callTool(server, name, args); + + expect(result).toEqual({ + isError: true, + content: [ + { + type: "text", + text: "Writes disabled. Set IMMICH_ALLOW_WRITES=true to enable destructive and modifying tools.", + }, + ], + }); + expect(sdkCalls).toEqual([]); + }); +}); + +describe("golden result and payload shape contracts", () => { + let server: McpServer; + + beforeEach(() => { + resetFakeSdk(); + server = makeServer(cfgRead); + }); + + it("pins current success payload shape for representative tools", async () => { + mockSdkResponse("pingServer", { res: "pong" }); + mockSdkResponse("getServerStatistics", { photos: 12, videos: 3 }); + mockSdkResponse("searchAssets", { assets: { items: [{ id: UUID_A, type: "IMAGE" }], total: 1 } }); + mockSdkResponse("getAllTags", [{ id: UUID_A, value: "family" }]); + mockSdkResponse("getAssetDuplicates", [{ duplicateId: "dup-1", assets: [{ id: UUID_A }, { id: UUID_B }] }]); + + const ping = await callTool(server, "immich_ping"); + expect(rawText(ping)).toBe(`{ + "res": "pong" +}`); + expect(parsedPayload(ping)).toEqual({ res: "pong" }); + + const statistics = await callTool(server, "immich_get_server_statistics"); + expect(rawText(statistics)).toBe(`{ + "photos": 12, + "videos": 3 +}`); + expect(parsedPayload(statistics)).toEqual({ photos: 12, videos: 3 }); + + expect(parsedPayload(await callTool(server, "immich_list_assets", { size: 1 }))).toEqual({ + assets: { items: [{ id: UUID_A, type: "IMAGE" }], total: 1 }, + }); + + const tags = await callTool(server, "immich_list_tags"); + expect(rawText(tags)).toBe(`[ + { + "id": "00000000-0000-0000-0000-000000000001", + "value": "family" + } +]`); + expect(parsedPayload(tags)).toEqual([{ id: UUID_A, value: "family" }]); + + expect(parsedPayload(await callTool(server, "immich_list_duplicates"))).toEqual([ + { duplicateId: "dup-1", assets: [{ id: UUID_A }, { id: UUID_B }] }, + ]); + }); + + it("pins current dry-run payload shape with no SDK calls for duplicate resolution", async () => { + const result = await callTool(server, "immich_resolve_duplicates", { + keep: [UUID_A], + discard: [UUID_B], + }); + + expect(parsedPayload(result)).toEqual({ + dryRun: true, + keep: [UUID_A], + discard: [UUID_B], + deleted: 0, + }); + expect(sdkCalls).toEqual([]); + }); +});