diff --git a/.codex/apex/task.json b/.codex/apex/task.json index 3733fd2..e3f215d 100644 --- a/.codex/apex/task.json +++ b/.codex/apex/task.json @@ -1,5 +1,5 @@ { - "current_task": "task-1788259000", + "current_task": "readme-remote-installation", "created_at": "2026-08-29T15:41:42Z", "tasks": { "task-1788018121": { @@ -33,6 +33,18 @@ "cursor-loader": "docs/reference/project-loader.md", "cursor-compatibility": "docs/reference/migration-compatibility.md" } + }, + "readme-remote-installation": { + "subject": "Replace clone-local README installation with a verified remote Cursor installation flow", + "status": "in_progress", + "phase": "validation", + "started_at": "2026-09-01T10:26:23Z", + "doc_consulted": { + "cursor-cli-changelog": "https://cursor.com/docs/cli/changelog", + "cursor-cli-slash-commands": "https://cursor.com/docs/cli/reference/slash-commands", + "cursor-plugins": "https://cursor.com/docs/plugins", + "codex-reference-readme": "Adjacent Codex plugins repository: root README.md" + } } } } diff --git a/.cursor-plugin/harness/package.json b/.cursor-plugin/harness/package.json new file mode 100644 index 0000000..425bc20 --- /dev/null +++ b/.cursor-plugin/harness/package.json @@ -0,0 +1,9 @@ +{ + "name": "@fusengine/cursor-harness", + "version": "1.0.0", + "private": true, + "description": "Fusengine Cursor global harness vendor manifest - single shared install of @fusengine/harness under ~/.cursor/.fusengine-global/node_modules, always tracking the latest published release.", + "dependencies": { + "@fusengine/harness": "latest" + } +} diff --git a/.cursor-plugin/marketplace.json b/.cursor-plugin/marketplace.json index d5dfe0f..99d3816 100644 --- a/.cursor-plugin/marketplace.json +++ b/.cursor-plugin/marketplace.json @@ -6,7 +6,7 @@ }, "metadata": { "description": "Professional Cursor plugins with APEX workflow, SOLID principles, and expert development agents for Next.js, Laravel, Swift, Tailwind CSS, and more", - "version": "1.39.26" + "version": "0.1.1" }, "plugins": [ { diff --git a/.cursor-plugin/mcp/catalog.json b/.cursor-plugin/mcp/catalog.json new file mode 100644 index 0000000..6bcd039 --- /dev/null +++ b/.cursor-plugin/mcp/catalog.json @@ -0,0 +1,237 @@ +{ + "mcpServers": { + "_comment": "MCP Servers catalog for Fusengine Plugins installer - Verified packages only", + + "sequential-thinking": { + "_description": "Dynamic problem-solving with step-by-step reasoning", + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-sequential-thinking"], + "type": "stdio", + "requiresApiKey": false + }, + "memory": { + "_description": "Knowledge graph-based persistent memory system", + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-memory"], + "type": "stdio", + "requiresApiKey": false + }, + "filesystem": { + "_description": "Secure local file operations with configurable access", + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-filesystem", "${HOME}"], + "type": "stdio", + "requiresApiKey": false, + "default": false + }, + "fuse-browser": { + "_description": "Stealth browser automation: snapshots, multi-step plans, themed (light/dark) screenshots", + "command": "npx", + "args": ["-y", "@fusengine/browser-mcp@latest"], + "type": "stdio", + "requiresApiKey": false + }, + "postgres": { + "_description": "PostgreSQL database queries via natural language", + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-postgres"], + "type": "stdio", + "requiresApiKey": false, + "default": false, + "env": { + "DATABASE_URL": "${DATABASE_URL}" + } + }, + "shadcn": { + "_description": "shadcn/ui component registry and installation", + "command": "npx", + "args": ["shadcn@latest", "mcp"], + "type": "stdio", + "requiresApiKey": false + }, + "next-devtools": { + "_description": "Next.js development tools and debugging", + "command": "npx", + "args": ["-y", "next-devtools-mcp@latest"], + "type": "stdio", + "requiresApiKey": false + }, + "XcodeBuildMCP": { + "_description": "Xcode build, test and simulator automation for iOS/macOS", + "command": "npx", + "args": ["-y", "xcodebuildmcp@latest", "mcp"], + "type": "stdio", + "requiresApiKey": false + }, + "apple-docs": { + "_description": "Apple developer documentation and WWDC content", + "command": "npx", + "args": ["-y", "@kimsungwhee/apple-docs-mcp"], + "type": "stdio", + "requiresApiKey": false + }, + "astro-docs": { + "_description": "Search official Astro framework documentation", + "type": "http", + "url": "https://mcp.docs.astro.build/mcp", + "requiresApiKey": false + }, + + "context7": { + "_description": "Up-to-date documentation lookup to reduce AI hallucinations", + "command": "npx", + "args": [ + "-y", + "@upstash/context7-mcp", + "--api-key", + "${CONTEXT7_API_KEY}" + ], + "type": "stdio", + "default": true, + "requiresApiKey": true, + "apiKeyEnv": "CONTEXT7_API_KEY", + "apiKeyUrl": "https://context7.com" + }, + "exa": { + "_description": "AI-powered web search, code context and deep research", + "default": true, + "type": "http", + "url": "https://mcp.exa.ai/mcp?tools=web_search_exa,get_code_context_exa,crawling_exa,company_research_exa,linkedin_search_exa,deep_researcher_start,deep_researcher_check&exaApiKey=${EXA_API_KEY}", + "headers": {}, + "requiresApiKey": true, + "apiKeyEnv": "EXA_API_KEY", + "apiKeyUrl": "https://exa.ai" + }, + "magic": { + "_description": "AI-powered UI component generation from 21st.dev", + "command": "npx", + "args": ["-y", "@21st-dev/magic@latest"], + "env": { + "MAGIC_API_KEY": "${MAGIC_API_KEY}" + }, + "type": "stdio", + "default": true, + "requiresApiKey": true, + "apiKeyEnv": "MAGIC_API_KEY", + "apiKeyUrl": "https://21st.dev" + }, + "gemini-design": { + "_description": "AI frontend generation with Tailwind CSS and shadcn/ui", + "command": "npx", + "args": ["-y", "gemini-design-mcp@latest"], + "env": { + "API_KEY": "${GEMINI_DESIGN_API_KEY}" + }, + "type": "stdio", + "default": true, + "requiresApiKey": true, + "apiKeyEnv": "GEMINI_DESIGN_API_KEY", + "apiKeyUrl": "https://gemini-design-mcp.com" + }, + "github": { + "_description": "GitHub repos, PRs, issues, commits and code review", + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-github"], + "env": { + "GITHUB_TOKEN": "${GITHUB_TOKEN}" + }, + "type": "stdio", + "requiresApiKey": true, + "apiKeyEnv": "GITHUB_TOKEN", + "apiKeyUrl": "https://github.com/settings/tokens" + }, + "supabase": { + "_description": "Supabase database, auth, storage and migrations", + "command": "npx", + "args": ["-y", "supabase", "mcp"], + "env": { + "SUPABASE_ACCESS_TOKEN": "${SUPABASE_ACCESS_TOKEN}" + }, + "type": "stdio", + "requiresApiKey": true, + "apiKeyEnv": "SUPABASE_ACCESS_TOKEN", + "apiKeyUrl": "https://supabase.com/dashboard/account/tokens" + }, + "slack": { + "_description": "Slack messages, channels and workspace management", + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-slack"], + "env": { + "SLACK_TOKEN": "${SLACK_TOKEN}" + }, + "type": "stdio", + "requiresApiKey": true, + "apiKeyEnv": "SLACK_TOKEN", + "apiKeyUrl": "https://api.slack.com/apps" + }, + "sentry": { + "_description": "Sentry error tracking, issues and debugging", + "command": "npx", + "args": ["-y", "@sentry/mcp-server"], + "env": { + "SENTRY_AUTH_TOKEN": "${SENTRY_AUTH_TOKEN}" + }, + "type": "stdio", + "requiresApiKey": true, + "apiKeyEnv": "SENTRY_AUTH_TOKEN", + "apiKeyUrl": "https://sentry.io/settings/auth-tokens" + }, + "brave-search": { + "_description": "Private web search via Brave Search API", + "command": "npx", + "args": ["-y", "@brave/brave-search-mcp-server"], + "env": { + "BRAVE_API_KEY": "${BRAVE_API_KEY}" + }, + "type": "stdio", + "requiresApiKey": true, + "apiKeyEnv": "BRAVE_API_KEY", + "apiKeyUrl": "https://brave.com/search/api" + }, + "serper": { + "_description": "Google search (SERP) and webpage scraping via Serper API", + "command": "npx", + "args": ["-y", "serper-search-scrape-mcp-server"], + "env": { + "SERPER_API_KEY": "${SERPER_API_KEY}" + }, + "type": "stdio", + "requiresApiKey": true, + "apiKeyEnv": "SERPER_API_KEY", + "apiKeyUrl": "https://serper.dev/api-key" + }, + "stripe": { + "_description": "Stripe payments, invoices, customers and subscriptions", + "command": "npx", + "args": ["-y", "@stripe/mcp"], + "env": { + "STRIPE_SECRET_KEY": "${STRIPE_SECRET_KEY}" + }, + "type": "stdio", + "requiresApiKey": true, + "apiKeyEnv": "STRIPE_SECRET_KEY", + "apiKeyUrl": "https://dashboard.stripe.com/apikeys" + }, + "notion": { + "_description": "Notion pages, databases and knowledge base access", + "command": "npx", + "args": ["-y", "@notionhq/notion-mcp-server"], + "env": { + "NOTION_TOKEN": "${NOTION_TOKEN}" + }, + "type": "stdio", + "requiresApiKey": true, + "apiKeyEnv": "NOTION_TOKEN", + "apiKeyUrl": "https://www.notion.so/my-integrations" + }, + "replicate": { + "_description": "Run 1000+ AI models (Stable Diffusion, Whisper, etc.)", + "type": "http", + "url": "https://replicate.com/mcp?token=${REPLICATE_API_TOKEN}", + "headers": {}, + "requiresApiKey": true, + "apiKeyEnv": "REPLICATE_API_TOKEN", + "apiKeyUrl": "https://replicate.com/account/api-tokens" + } + } +} diff --git a/.cursor-plugin/plugin-runtime/hook.sh b/.cursor-plugin/plugin-runtime/hook.sh new file mode 100644 index 0000000..cff4a47 --- /dev/null +++ b/.cursor-plugin/plugin-runtime/hook.sh @@ -0,0 +1,45 @@ +#!/bin/sh +# Fusengine hook entrypoint — shipped inside every plugin as scripts/hook.sh. +# +# Resolves the shared harness itself so the plugin works from the Cursor +# marketplace, where no installer ever runs. Order: +# 1. the vendored install under $HOME/.cursor/.fusengine-global (fast path) +# 2. a self-heal `bun install` of that same shared directory, once +# 3. `npx` on latest (last resort, network) +# +# Always tracks the LATEST published harness: the vendored manifest uses the +# `latest` dist-tag and the npx fallback resolves it too, so a new release is +# picked up without editing 21 wrappers. +# +# Resolution is relative to THIS file, never to the caller's cwd. Always exits 0 +# on its own failures: Cursor fails hooks open, and a broken guard must never +# block the user's action. +# +# Usage (from hooks/hooks.json): ./scripts/hook.sh [scope...] + +HARNESS_SPEC="latest" +CONTROL_ROOT="${HOME}/.cursor/.fusengine-global" +BIN="${CONTROL_ROOT}/node_modules/@fusengine/harness/dist/cli/bin.mjs" +STAMP="${CONTROL_ROOT}/.selfheal-attempted" + +run_bin() { + if command -v bun >/dev/null 2>&1; then exec bun "$BIN" hook cursor "$@"; fi + exec node "$BIN" hook cursor "$@" +} + +# 1. Fast path: already vendored. +[ -f "$BIN" ] && run_bin "$@" + +# 2. Self-heal once. The stamp keeps 62 hooks from stampeding the registry when +# the install is genuinely impossible (offline, no bun, no network). +if [ ! -e "$STAMP" ] && command -v bun >/dev/null 2>&1; then + mkdir -p "$CONTROL_ROOT" 2>/dev/null && : > "$STAMP" 2>/dev/null + printf '{"name":"@fusengine/cursor-harness","private":true,"dependencies":{"@fusengine/harness":"%s"}}\n' \ + "$HARNESS_SPEC" > "${CONTROL_ROOT}/package.json" 2>/dev/null + (cd "$CONTROL_ROOT" && bun install --silent) >/dev/null 2>&1 + [ -f "$BIN" ] && run_bin "$@" +fi + +# 3. Last resort: npx on the latest published release. +command -v npx >/dev/null 2>&1 || exit 0 +exec npx -y "@fusengine/harness@${HARNESS_SPEC}" hook cursor "$@" diff --git a/.cursor-plugin/scripts/load-project-plugins.mjs b/.cursor-plugin/plugin-runtime/load-project-plugins.mjs old mode 100755 new mode 100644 similarity index 100% rename from .cursor-plugin/scripts/load-project-plugins.mjs rename to .cursor-plugin/plugin-runtime/load-project-plugins.mjs diff --git a/.cursor-plugin/scripts/marketplace-sources.mjs b/.cursor-plugin/plugin-runtime/marketplace-sources.mjs similarity index 100% rename from .cursor-plugin/scripts/marketplace-sources.mjs rename to .cursor-plugin/plugin-runtime/marketplace-sources.mjs diff --git a/.cursor-plugin/scripts/project-plugin-inventory.mjs b/.cursor-plugin/plugin-runtime/project-plugin-inventory.mjs similarity index 100% rename from .cursor-plugin/scripts/project-plugin-inventory.mjs rename to .cursor-plugin/plugin-runtime/project-plugin-inventory.mjs diff --git a/.cursor-plugin/plugins/core-guards/hooks/hooks.json b/.cursor-plugin/plugins/core-guards/hooks/hooks.json index f7d3a16..ef10df7 100644 --- a/.cursor-plugin/plugins/core-guards/hooks/hooks.json +++ b/.cursor-plugin/plugins/core-guards/hooks/hooks.json @@ -3,49 +3,49 @@ "hooks": { "preToolUse": [ { - "command": "npx -y @fusengine/harness hook cursor", + "command": "./scripts/hook.sh", "matcher": "Write|Shell|WebFetch" } ], "beforeMCPExecution": [ { - "command": "npx -y @fusengine/harness hook cursor", + "command": "./scripts/hook.sh", "matcher": "MCP:(query-docs|web_search_exa|get_code_context_exa)" } ], "postToolUse": [ { - "command": "npx -y @fusengine/harness hook cursor" + "command": "./scripts/hook.sh" } ], "sessionStart": [ { - "command": "npx -y @fusengine/harness hook cursor" + "command": "./scripts/hook.sh" } ], "beforeSubmitPrompt": [ { - "command": "npx -y @fusengine/harness hook cursor" + "command": "./scripts/hook.sh" } ], "subagentStart": [ { - "command": "npx -y @fusengine/harness hook cursor" + "command": "./scripts/hook.sh" } ], "subagentStop": [ { - "command": "npx -y @fusengine/harness hook cursor" + "command": "./scripts/hook.sh" } ], "postToolUseFailure": [ { - "command": "npx -y @fusengine/harness hook cursor" + "command": "./scripts/hook.sh" } ], "stop": [ { - "command": "npx -y @fusengine/harness hook cursor core --sound stop" + "command": "./scripts/hook.sh core --sound stop" }, { "type": "prompt", @@ -55,12 +55,12 @@ ], "preCompact": [ { - "command": "npx -y @fusengine/harness hook cursor" + "command": "./scripts/hook.sh" } ], "sessionEnd": [ { - "command": "npx -y @fusengine/harness hook cursor" + "command": "./scripts/hook.sh" } ] } diff --git a/.cursor-plugin/plugins/core-guards/scripts/hook.sh b/.cursor-plugin/plugins/core-guards/scripts/hook.sh new file mode 100755 index 0000000..cff4a47 --- /dev/null +++ b/.cursor-plugin/plugins/core-guards/scripts/hook.sh @@ -0,0 +1,45 @@ +#!/bin/sh +# Fusengine hook entrypoint — shipped inside every plugin as scripts/hook.sh. +# +# Resolves the shared harness itself so the plugin works from the Cursor +# marketplace, where no installer ever runs. Order: +# 1. the vendored install under $HOME/.cursor/.fusengine-global (fast path) +# 2. a self-heal `bun install` of that same shared directory, once +# 3. `npx` on latest (last resort, network) +# +# Always tracks the LATEST published harness: the vendored manifest uses the +# `latest` dist-tag and the npx fallback resolves it too, so a new release is +# picked up without editing 21 wrappers. +# +# Resolution is relative to THIS file, never to the caller's cwd. Always exits 0 +# on its own failures: Cursor fails hooks open, and a broken guard must never +# block the user's action. +# +# Usage (from hooks/hooks.json): ./scripts/hook.sh [scope...] + +HARNESS_SPEC="latest" +CONTROL_ROOT="${HOME}/.cursor/.fusengine-global" +BIN="${CONTROL_ROOT}/node_modules/@fusengine/harness/dist/cli/bin.mjs" +STAMP="${CONTROL_ROOT}/.selfheal-attempted" + +run_bin() { + if command -v bun >/dev/null 2>&1; then exec bun "$BIN" hook cursor "$@"; fi + exec node "$BIN" hook cursor "$@" +} + +# 1. Fast path: already vendored. +[ -f "$BIN" ] && run_bin "$@" + +# 2. Self-heal once. The stamp keeps 62 hooks from stampeding the registry when +# the install is genuinely impossible (offline, no bun, no network). +if [ ! -e "$STAMP" ] && command -v bun >/dev/null 2>&1; then + mkdir -p "$CONTROL_ROOT" 2>/dev/null && : > "$STAMP" 2>/dev/null + printf '{"name":"@fusengine/cursor-harness","private":true,"dependencies":{"@fusengine/harness":"%s"}}\n' \ + "$HARNESS_SPEC" > "${CONTROL_ROOT}/package.json" 2>/dev/null + (cd "$CONTROL_ROOT" && bun install --silent) >/dev/null 2>&1 + [ -f "$BIN" ] && run_bin "$@" +fi + +# 3. Last resort: npx on the latest published release. +command -v npx >/dev/null 2>&1 || exit 0 +exec npx -y "@fusengine/harness@${HARNESS_SPEC}" hook cursor "$@" diff --git a/.cursor-plugin/plugins/fuse-ai-pilot/hooks/hooks.json b/.cursor-plugin/plugins/fuse-ai-pilot/hooks/hooks.json index 8cea9b3..74bf14b 100644 --- a/.cursor-plugin/plugins/fuse-ai-pilot/hooks/hooks.json +++ b/.cursor-plugin/plugins/fuse-ai-pilot/hooks/hooks.json @@ -3,28 +3,28 @@ "hooks": { "subagentStart": [ { - "command": "npx -y @fusengine/harness hook cursor aipilot" + "command": "./scripts/hook.sh aipilot" } ], "subagentStop": [ { - "command": "npx -y @fusengine/harness hook cursor aipilot" + "command": "./scripts/hook.sh aipilot" } ], "sessionEnd": [ { - "command": "npx -y @fusengine/harness hook cursor aipilot" + "command": "./scripts/hook.sh aipilot" } ], "preToolUse": [ { - "command": "npx -y @fusengine/harness hook cursor", + "command": "./scripts/hook.sh", "matcher": "Task" } ], "postToolUse": [ { - "command": "npx -y @fusengine/harness hook cursor aipilot", + "command": "./scripts/hook.sh aipilot", "matcher": "Write" } ] diff --git a/.cursor-plugin/plugins/fuse-ai-pilot/scripts/hook.sh b/.cursor-plugin/plugins/fuse-ai-pilot/scripts/hook.sh new file mode 100755 index 0000000..cff4a47 --- /dev/null +++ b/.cursor-plugin/plugins/fuse-ai-pilot/scripts/hook.sh @@ -0,0 +1,45 @@ +#!/bin/sh +# Fusengine hook entrypoint — shipped inside every plugin as scripts/hook.sh. +# +# Resolves the shared harness itself so the plugin works from the Cursor +# marketplace, where no installer ever runs. Order: +# 1. the vendored install under $HOME/.cursor/.fusengine-global (fast path) +# 2. a self-heal `bun install` of that same shared directory, once +# 3. `npx` on latest (last resort, network) +# +# Always tracks the LATEST published harness: the vendored manifest uses the +# `latest` dist-tag and the npx fallback resolves it too, so a new release is +# picked up without editing 21 wrappers. +# +# Resolution is relative to THIS file, never to the caller's cwd. Always exits 0 +# on its own failures: Cursor fails hooks open, and a broken guard must never +# block the user's action. +# +# Usage (from hooks/hooks.json): ./scripts/hook.sh [scope...] + +HARNESS_SPEC="latest" +CONTROL_ROOT="${HOME}/.cursor/.fusengine-global" +BIN="${CONTROL_ROOT}/node_modules/@fusengine/harness/dist/cli/bin.mjs" +STAMP="${CONTROL_ROOT}/.selfheal-attempted" + +run_bin() { + if command -v bun >/dev/null 2>&1; then exec bun "$BIN" hook cursor "$@"; fi + exec node "$BIN" hook cursor "$@" +} + +# 1. Fast path: already vendored. +[ -f "$BIN" ] && run_bin "$@" + +# 2. Self-heal once. The stamp keeps 62 hooks from stampeding the registry when +# the install is genuinely impossible (offline, no bun, no network). +if [ ! -e "$STAMP" ] && command -v bun >/dev/null 2>&1; then + mkdir -p "$CONTROL_ROOT" 2>/dev/null && : > "$STAMP" 2>/dev/null + printf '{"name":"@fusengine/cursor-harness","private":true,"dependencies":{"@fusengine/harness":"%s"}}\n' \ + "$HARNESS_SPEC" > "${CONTROL_ROOT}/package.json" 2>/dev/null + (cd "$CONTROL_ROOT" && bun install --silent) >/dev/null 2>&1 + [ -f "$BIN" ] && run_bin "$@" +fi + +# 3. Last resort: npx on the latest published release. +command -v npx >/dev/null 2>&1 || exit 0 +exec npx -y "@fusengine/harness@${HARNESS_SPEC}" hook cursor "$@" diff --git a/.cursor-plugin/plugins/fuse-cartographer/agents/cartographer.md b/.cursor-plugin/plugins/fuse-cartographer/agents/cartographer.md index 33c37c7..e472c65 100644 --- a/.cursor-plugin/plugins/fuse-cartographer/agents/cartographer.md +++ b/.cursor-plugin/plugins/fuse-cartographer/agents/cartographer.md @@ -1,10 +1,9 @@ --- name: cartographer description: "Use when: /map command, finding skills/agents, understanding ecosystem layout. Do NOT use for: code generation, debugging, file editing." -model: grok-4.6 +model: grok-4.6[effort=low] color: green tools: Read, Write, Glob, Grep -effort: low --- ## Allowed tools diff --git a/.cursor-plugin/plugins/fuse-cartographer/hooks/hooks.json b/.cursor-plugin/plugins/fuse-cartographer/hooks/hooks.json index 9b0e448..3ac6c18 100644 --- a/.cursor-plugin/plugins/fuse-cartographer/hooks/hooks.json +++ b/.cursor-plugin/plugins/fuse-cartographer/hooks/hooks.json @@ -3,12 +3,12 @@ "hooks": { "sessionStart": [ { - "command": "npx -y @fusengine/harness hook cursor carto" + "command": "./scripts/hook.sh carto" } ], "postToolUse": [ { - "command": "npx -y @fusengine/harness hook cursor carto", + "command": "./scripts/hook.sh carto", "matcher": "Write" } ] diff --git a/.cursor-plugin/plugins/fuse-cartographer/scripts/hook.sh b/.cursor-plugin/plugins/fuse-cartographer/scripts/hook.sh new file mode 100755 index 0000000..cff4a47 --- /dev/null +++ b/.cursor-plugin/plugins/fuse-cartographer/scripts/hook.sh @@ -0,0 +1,45 @@ +#!/bin/sh +# Fusengine hook entrypoint — shipped inside every plugin as scripts/hook.sh. +# +# Resolves the shared harness itself so the plugin works from the Cursor +# marketplace, where no installer ever runs. Order: +# 1. the vendored install under $HOME/.cursor/.fusengine-global (fast path) +# 2. a self-heal `bun install` of that same shared directory, once +# 3. `npx` on latest (last resort, network) +# +# Always tracks the LATEST published harness: the vendored manifest uses the +# `latest` dist-tag and the npx fallback resolves it too, so a new release is +# picked up without editing 21 wrappers. +# +# Resolution is relative to THIS file, never to the caller's cwd. Always exits 0 +# on its own failures: Cursor fails hooks open, and a broken guard must never +# block the user's action. +# +# Usage (from hooks/hooks.json): ./scripts/hook.sh [scope...] + +HARNESS_SPEC="latest" +CONTROL_ROOT="${HOME}/.cursor/.fusengine-global" +BIN="${CONTROL_ROOT}/node_modules/@fusengine/harness/dist/cli/bin.mjs" +STAMP="${CONTROL_ROOT}/.selfheal-attempted" + +run_bin() { + if command -v bun >/dev/null 2>&1; then exec bun "$BIN" hook cursor "$@"; fi + exec node "$BIN" hook cursor "$@" +} + +# 1. Fast path: already vendored. +[ -f "$BIN" ] && run_bin "$@" + +# 2. Self-heal once. The stamp keeps 62 hooks from stampeding the registry when +# the install is genuinely impossible (offline, no bun, no network). +if [ ! -e "$STAMP" ] && command -v bun >/dev/null 2>&1; then + mkdir -p "$CONTROL_ROOT" 2>/dev/null && : > "$STAMP" 2>/dev/null + printf '{"name":"@fusengine/cursor-harness","private":true,"dependencies":{"@fusengine/harness":"%s"}}\n' \ + "$HARNESS_SPEC" > "${CONTROL_ROOT}/package.json" 2>/dev/null + (cd "$CONTROL_ROOT" && bun install --silent) >/dev/null 2>&1 + [ -f "$BIN" ] && run_bin "$@" +fi + +# 3. Last resort: npx on the latest published release. +command -v npx >/dev/null 2>&1 || exit 0 +exec npx -y "@fusengine/harness@${HARNESS_SPEC}" hook cursor "$@" diff --git a/.cursor-plugin/plugins/fuse-changelog/hooks/hooks.json b/.cursor-plugin/plugins/fuse-changelog/hooks/hooks.json index b56e97c..82d447a 100644 --- a/.cursor-plugin/plugins/fuse-changelog/hooks/hooks.json +++ b/.cursor-plugin/plugins/fuse-changelog/hooks/hooks.json @@ -3,13 +3,13 @@ "hooks": { "postToolUse": [ { - "command": "npx -y @fusengine/harness hook cursor changelog", + "command": "./scripts/hook.sh changelog", "matcher": "WebFetch|WebSearch" } ], "afterMCPExecution": [ { - "command": "npx -y @fusengine/harness hook cursor changelog", + "command": "./scripts/hook.sh changelog", "matcher": "MCP:(web_search_exa|get_code_context_exa|deep_researcher_start|deep_researcher_check)" } ] diff --git a/.cursor-plugin/plugins/fuse-changelog/scripts/hook.sh b/.cursor-plugin/plugins/fuse-changelog/scripts/hook.sh new file mode 100755 index 0000000..cff4a47 --- /dev/null +++ b/.cursor-plugin/plugins/fuse-changelog/scripts/hook.sh @@ -0,0 +1,45 @@ +#!/bin/sh +# Fusengine hook entrypoint — shipped inside every plugin as scripts/hook.sh. +# +# Resolves the shared harness itself so the plugin works from the Cursor +# marketplace, where no installer ever runs. Order: +# 1. the vendored install under $HOME/.cursor/.fusengine-global (fast path) +# 2. a self-heal `bun install` of that same shared directory, once +# 3. `npx` on latest (last resort, network) +# +# Always tracks the LATEST published harness: the vendored manifest uses the +# `latest` dist-tag and the npx fallback resolves it too, so a new release is +# picked up without editing 21 wrappers. +# +# Resolution is relative to THIS file, never to the caller's cwd. Always exits 0 +# on its own failures: Cursor fails hooks open, and a broken guard must never +# block the user's action. +# +# Usage (from hooks/hooks.json): ./scripts/hook.sh [scope...] + +HARNESS_SPEC="latest" +CONTROL_ROOT="${HOME}/.cursor/.fusengine-global" +BIN="${CONTROL_ROOT}/node_modules/@fusengine/harness/dist/cli/bin.mjs" +STAMP="${CONTROL_ROOT}/.selfheal-attempted" + +run_bin() { + if command -v bun >/dev/null 2>&1; then exec bun "$BIN" hook cursor "$@"; fi + exec node "$BIN" hook cursor "$@" +} + +# 1. Fast path: already vendored. +[ -f "$BIN" ] && run_bin "$@" + +# 2. Self-heal once. The stamp keeps 62 hooks from stampeding the registry when +# the install is genuinely impossible (offline, no bun, no network). +if [ ! -e "$STAMP" ] && command -v bun >/dev/null 2>&1; then + mkdir -p "$CONTROL_ROOT" 2>/dev/null && : > "$STAMP" 2>/dev/null + printf '{"name":"@fusengine/cursor-harness","private":true,"dependencies":{"@fusengine/harness":"%s"}}\n' \ + "$HARNESS_SPEC" > "${CONTROL_ROOT}/package.json" 2>/dev/null + (cd "$CONTROL_ROOT" && bun install --silent) >/dev/null 2>&1 + [ -f "$BIN" ] && run_bin "$@" +fi + +# 3. Last resort: npx on the latest published release. +command -v npx >/dev/null 2>&1 || exit 0 +exec npx -y "@fusengine/harness@${HARNESS_SPEC}" hook cursor "$@" diff --git a/.cursor-plugin/plugins/fuse-commit-pro/agents/commit-detector.md b/.cursor-plugin/plugins/fuse-commit-pro/agents/commit-detector.md index 0c6c69e..51edc91 100644 --- a/.cursor-plugin/plugins/fuse-commit-pro/agents/commit-detector.md +++ b/.cursor-plugin/plugins/fuse-commit-pro/agents/commit-detector.md @@ -1,12 +1,11 @@ --- name: commit-detector description: "Use PROACTIVELY when: user says commit/save/git, mentions wip/feat/fix/chore. Do NOT use for: code review, non-commit git ops (log/diff/status)." -model: grok-4.6 +model: grok-4.6[effort=low] readonly: true color: cyan tools: Bash, Read, Grep, Glob skills: commit-detection -effort: low --- ## Allowed tools diff --git a/.cursor-plugin/plugins/fuse-design/hooks/hooks.json b/.cursor-plugin/plugins/fuse-design/hooks/hooks.json index ac8e0c4..95148d3 100644 --- a/.cursor-plugin/plugins/fuse-design/hooks/hooks.json +++ b/.cursor-plugin/plugins/fuse-design/hooks/hooks.json @@ -3,29 +3,29 @@ "hooks": { "preToolUse": [ { - "command": "npx -y @fusengine/harness hook cursor", + "command": "./scripts/hook.sh", "matcher": "Write" } ], "beforeMCPExecution": [ { - "command": "npx -y @fusengine/harness hook cursor", + "command": "./scripts/hook.sh", "matcher": "MCP:(browser_navigate|browser_screenshot|create_frontend)" } ], "postToolUse": [ { - "command": "npx -y @fusengine/harness hook cursor" + "command": "./scripts/hook.sh" } ], "subagentStart": [ { - "command": "npx -y @fusengine/harness hook cursor" + "command": "./scripts/hook.sh" } ], "subagentStop": [ { - "command": "npx -y @fusengine/harness hook cursor" + "command": "./scripts/hook.sh" } ] } diff --git a/.cursor-plugin/plugins/fuse-design/rules/design-rules.md b/.cursor-plugin/plugins/fuse-design/rules/design-rules.md index be51eff..e6d4c69 100644 --- a/.cursor-plugin/plugins/fuse-design/rules/design-rules.md +++ b/.cursor-plugin/plugins/fuse-design/rules/design-rules.md @@ -1,3 +1,8 @@ +--- +description: Reusable component-pattern snippets for stacks that render JSX/Tailwind (React/Next.js) on top of generated HTML/CSS +alwaysApply: true +--- + # Design Rules (STRICT — NO EXCEPTIONS) > Forbidden fonts, contrast thresholds, and the screenshot/review procedure are defined diff --git a/.cursor-plugin/plugins/fuse-design/rules/framework-integration.md b/.cursor-plugin/plugins/fuse-design/rules/framework-integration.md index 0d4a308..aa4381c 100644 --- a/.cursor-plugin/plugins/fuse-design/rules/framework-integration.md +++ b/.cursor-plugin/plugins/fuse-design/rules/framework-integration.md @@ -1,3 +1,8 @@ +--- +description: Mandatory framework-expert handoff after generating UI, for wiring components into the codebase +alwaysApply: true +--- + # Framework Integration Rules (MANDATORY) After generating UI (direct HTML/CSS by default, or via the optional Gemini Design MCP diff --git a/.cursor-plugin/plugins/fuse-design/rules/gemini-design.md b/.cursor-plugin/plugins/fuse-design/rules/gemini-design.md index 79b0231..99903ac 100644 --- a/.cursor-plugin/plugins/fuse-design/rules/gemini-design.md +++ b/.cursor-plugin/plugins/fuse-design/rules/gemini-design.md @@ -1,3 +1,8 @@ +--- +description: Gemini Design MCP optional path quick reference — direct HTML/CSS generation remains the default +alwaysApply: false +--- + # Gemini Design MCP — Optional Path Quick Reference **Direct HTML/CSS generation is the default and the primary path** (see diff --git a/.cursor-plugin/plugins/fuse-design/scripts/hook.sh b/.cursor-plugin/plugins/fuse-design/scripts/hook.sh new file mode 100755 index 0000000..cff4a47 --- /dev/null +++ b/.cursor-plugin/plugins/fuse-design/scripts/hook.sh @@ -0,0 +1,45 @@ +#!/bin/sh +# Fusengine hook entrypoint — shipped inside every plugin as scripts/hook.sh. +# +# Resolves the shared harness itself so the plugin works from the Cursor +# marketplace, where no installer ever runs. Order: +# 1. the vendored install under $HOME/.cursor/.fusengine-global (fast path) +# 2. a self-heal `bun install` of that same shared directory, once +# 3. `npx` on latest (last resort, network) +# +# Always tracks the LATEST published harness: the vendored manifest uses the +# `latest` dist-tag and the npx fallback resolves it too, so a new release is +# picked up without editing 21 wrappers. +# +# Resolution is relative to THIS file, never to the caller's cwd. Always exits 0 +# on its own failures: Cursor fails hooks open, and a broken guard must never +# block the user's action. +# +# Usage (from hooks/hooks.json): ./scripts/hook.sh [scope...] + +HARNESS_SPEC="latest" +CONTROL_ROOT="${HOME}/.cursor/.fusengine-global" +BIN="${CONTROL_ROOT}/node_modules/@fusengine/harness/dist/cli/bin.mjs" +STAMP="${CONTROL_ROOT}/.selfheal-attempted" + +run_bin() { + if command -v bun >/dev/null 2>&1; then exec bun "$BIN" hook cursor "$@"; fi + exec node "$BIN" hook cursor "$@" +} + +# 1. Fast path: already vendored. +[ -f "$BIN" ] && run_bin "$@" + +# 2. Self-heal once. The stamp keeps 62 hooks from stampeding the registry when +# the install is genuinely impossible (offline, no bun, no network). +if [ ! -e "$STAMP" ] && command -v bun >/dev/null 2>&1; then + mkdir -p "$CONTROL_ROOT" 2>/dev/null && : > "$STAMP" 2>/dev/null + printf '{"name":"@fusengine/cursor-harness","private":true,"dependencies":{"@fusengine/harness":"%s"}}\n' \ + "$HARNESS_SPEC" > "${CONTROL_ROOT}/package.json" 2>/dev/null + (cd "$CONTROL_ROOT" && bun install --silent) >/dev/null 2>&1 + [ -f "$BIN" ] && run_bin "$@" +fi + +# 3. Last resort: npx on the latest published release. +command -v npx >/dev/null 2>&1 || exit 0 +exec npx -y "@fusengine/harness@${HARNESS_SPEC}" hook cursor "$@" diff --git a/.cursor-plugin/plugins/fuse-go/hooks/hooks.json b/.cursor-plugin/plugins/fuse-go/hooks/hooks.json index 960e856..c8dde2f 100644 --- a/.cursor-plugin/plugins/fuse-go/hooks/hooks.json +++ b/.cursor-plugin/plugins/fuse-go/hooks/hooks.json @@ -3,13 +3,13 @@ "hooks": { "preToolUse": [ { - "command": "npx -y @fusengine/harness hook cursor", + "command": "./scripts/hook.sh", "matcher": "Write" } ], "postToolUse": [ { - "command": "npx -y @fusengine/harness hook cursor" + "command": "./scripts/hook.sh" } ] } diff --git a/.cursor-plugin/plugins/fuse-go/scripts/hook.sh b/.cursor-plugin/plugins/fuse-go/scripts/hook.sh new file mode 100755 index 0000000..cff4a47 --- /dev/null +++ b/.cursor-plugin/plugins/fuse-go/scripts/hook.sh @@ -0,0 +1,45 @@ +#!/bin/sh +# Fusengine hook entrypoint — shipped inside every plugin as scripts/hook.sh. +# +# Resolves the shared harness itself so the plugin works from the Cursor +# marketplace, where no installer ever runs. Order: +# 1. the vendored install under $HOME/.cursor/.fusengine-global (fast path) +# 2. a self-heal `bun install` of that same shared directory, once +# 3. `npx` on latest (last resort, network) +# +# Always tracks the LATEST published harness: the vendored manifest uses the +# `latest` dist-tag and the npx fallback resolves it too, so a new release is +# picked up without editing 21 wrappers. +# +# Resolution is relative to THIS file, never to the caller's cwd. Always exits 0 +# on its own failures: Cursor fails hooks open, and a broken guard must never +# block the user's action. +# +# Usage (from hooks/hooks.json): ./scripts/hook.sh [scope...] + +HARNESS_SPEC="latest" +CONTROL_ROOT="${HOME}/.cursor/.fusengine-global" +BIN="${CONTROL_ROOT}/node_modules/@fusengine/harness/dist/cli/bin.mjs" +STAMP="${CONTROL_ROOT}/.selfheal-attempted" + +run_bin() { + if command -v bun >/dev/null 2>&1; then exec bun "$BIN" hook cursor "$@"; fi + exec node "$BIN" hook cursor "$@" +} + +# 1. Fast path: already vendored. +[ -f "$BIN" ] && run_bin "$@" + +# 2. Self-heal once. The stamp keeps 62 hooks from stampeding the registry when +# the install is genuinely impossible (offline, no bun, no network). +if [ ! -e "$STAMP" ] && command -v bun >/dev/null 2>&1; then + mkdir -p "$CONTROL_ROOT" 2>/dev/null && : > "$STAMP" 2>/dev/null + printf '{"name":"@fusengine/cursor-harness","private":true,"dependencies":{"@fusengine/harness":"%s"}}\n' \ + "$HARNESS_SPEC" > "${CONTROL_ROOT}/package.json" 2>/dev/null + (cd "$CONTROL_ROOT" && bun install --silent) >/dev/null 2>&1 + [ -f "$BIN" ] && run_bin "$@" +fi + +# 3. Last resort: npx on the latest published release. +command -v npx >/dev/null 2>&1 || exit 0 +exec npx -y "@fusengine/harness@${HARNESS_SPEC}" hook cursor "$@" diff --git a/.cursor-plugin/plugins/fuse-laravel/hooks/hooks.json b/.cursor-plugin/plugins/fuse-laravel/hooks/hooks.json index 960e856..c8dde2f 100644 --- a/.cursor-plugin/plugins/fuse-laravel/hooks/hooks.json +++ b/.cursor-plugin/plugins/fuse-laravel/hooks/hooks.json @@ -3,13 +3,13 @@ "hooks": { "preToolUse": [ { - "command": "npx -y @fusengine/harness hook cursor", + "command": "./scripts/hook.sh", "matcher": "Write" } ], "postToolUse": [ { - "command": "npx -y @fusengine/harness hook cursor" + "command": "./scripts/hook.sh" } ] } diff --git a/.cursor-plugin/plugins/fuse-laravel/scripts/hook.sh b/.cursor-plugin/plugins/fuse-laravel/scripts/hook.sh new file mode 100755 index 0000000..cff4a47 --- /dev/null +++ b/.cursor-plugin/plugins/fuse-laravel/scripts/hook.sh @@ -0,0 +1,45 @@ +#!/bin/sh +# Fusengine hook entrypoint — shipped inside every plugin as scripts/hook.sh. +# +# Resolves the shared harness itself so the plugin works from the Cursor +# marketplace, where no installer ever runs. Order: +# 1. the vendored install under $HOME/.cursor/.fusengine-global (fast path) +# 2. a self-heal `bun install` of that same shared directory, once +# 3. `npx` on latest (last resort, network) +# +# Always tracks the LATEST published harness: the vendored manifest uses the +# `latest` dist-tag and the npx fallback resolves it too, so a new release is +# picked up without editing 21 wrappers. +# +# Resolution is relative to THIS file, never to the caller's cwd. Always exits 0 +# on its own failures: Cursor fails hooks open, and a broken guard must never +# block the user's action. +# +# Usage (from hooks/hooks.json): ./scripts/hook.sh [scope...] + +HARNESS_SPEC="latest" +CONTROL_ROOT="${HOME}/.cursor/.fusengine-global" +BIN="${CONTROL_ROOT}/node_modules/@fusengine/harness/dist/cli/bin.mjs" +STAMP="${CONTROL_ROOT}/.selfheal-attempted" + +run_bin() { + if command -v bun >/dev/null 2>&1; then exec bun "$BIN" hook cursor "$@"; fi + exec node "$BIN" hook cursor "$@" +} + +# 1. Fast path: already vendored. +[ -f "$BIN" ] && run_bin "$@" + +# 2. Self-heal once. The stamp keeps 62 hooks from stampeding the registry when +# the install is genuinely impossible (offline, no bun, no network). +if [ ! -e "$STAMP" ] && command -v bun >/dev/null 2>&1; then + mkdir -p "$CONTROL_ROOT" 2>/dev/null && : > "$STAMP" 2>/dev/null + printf '{"name":"@fusengine/cursor-harness","private":true,"dependencies":{"@fusengine/harness":"%s"}}\n' \ + "$HARNESS_SPEC" > "${CONTROL_ROOT}/package.json" 2>/dev/null + (cd "$CONTROL_ROOT" && bun install --silent) >/dev/null 2>&1 + [ -f "$BIN" ] && run_bin "$@" +fi + +# 3. Last resort: npx on the latest published release. +command -v npx >/dev/null 2>&1 || exit 0 +exec npx -y "@fusengine/harness@${HARNESS_SPEC}" hook cursor "$@" diff --git a/.cursor-plugin/plugins/fuse-lessons/.cursor-plugin/plugin.json b/.cursor-plugin/plugins/fuse-lessons/.cursor-plugin/plugin.json index 9a4e725..f922af6 100644 --- a/.cursor-plugin/plugins/fuse-lessons/.cursor-plugin/plugin.json +++ b/.cursor-plugin/plugins/fuse-lessons/.cursor-plugin/plugin.json @@ -12,6 +12,5 @@ "lessons", "hooks", "postmortem" - ], - "category": "development" + ] } diff --git a/.cursor-plugin/plugins/fuse-lessons/hooks/hooks.json b/.cursor-plugin/plugins/fuse-lessons/hooks/hooks.json index fe79107..79dffdd 100644 --- a/.cursor-plugin/plugins/fuse-lessons/hooks/hooks.json +++ b/.cursor-plugin/plugins/fuse-lessons/hooks/hooks.json @@ -3,27 +3,27 @@ "hooks": { "beforeSubmitPrompt": [ { - "command": "npx -y @fusengine/harness hook cursor lessons" + "command": "./scripts/hook.sh lessons" } ], "sessionStart": [ { - "command": "npx -y @fusengine/harness hook cursor lessons" + "command": "./scripts/hook.sh lessons" } ], "subagentStart": [ { - "command": "npx -y @fusengine/harness hook cursor lessons" + "command": "./scripts/hook.sh lessons" } ], "stop": [ { - "command": "npx -y @fusengine/harness hook cursor lessons" + "command": "./scripts/hook.sh lessons" } ], "postToolUse": [ { - "command": "npx -y @fusengine/harness hook cursor lessons", + "command": "./scripts/hook.sh lessons", "matcher": "Write" } ] diff --git a/.cursor-plugin/plugins/fuse-lessons/scripts/hook.sh b/.cursor-plugin/plugins/fuse-lessons/scripts/hook.sh new file mode 100755 index 0000000..cff4a47 --- /dev/null +++ b/.cursor-plugin/plugins/fuse-lessons/scripts/hook.sh @@ -0,0 +1,45 @@ +#!/bin/sh +# Fusengine hook entrypoint — shipped inside every plugin as scripts/hook.sh. +# +# Resolves the shared harness itself so the plugin works from the Cursor +# marketplace, where no installer ever runs. Order: +# 1. the vendored install under $HOME/.cursor/.fusengine-global (fast path) +# 2. a self-heal `bun install` of that same shared directory, once +# 3. `npx` on latest (last resort, network) +# +# Always tracks the LATEST published harness: the vendored manifest uses the +# `latest` dist-tag and the npx fallback resolves it too, so a new release is +# picked up without editing 21 wrappers. +# +# Resolution is relative to THIS file, never to the caller's cwd. Always exits 0 +# on its own failures: Cursor fails hooks open, and a broken guard must never +# block the user's action. +# +# Usage (from hooks/hooks.json): ./scripts/hook.sh [scope...] + +HARNESS_SPEC="latest" +CONTROL_ROOT="${HOME}/.cursor/.fusengine-global" +BIN="${CONTROL_ROOT}/node_modules/@fusengine/harness/dist/cli/bin.mjs" +STAMP="${CONTROL_ROOT}/.selfheal-attempted" + +run_bin() { + if command -v bun >/dev/null 2>&1; then exec bun "$BIN" hook cursor "$@"; fi + exec node "$BIN" hook cursor "$@" +} + +# 1. Fast path: already vendored. +[ -f "$BIN" ] && run_bin "$@" + +# 2. Self-heal once. The stamp keeps 62 hooks from stampeding the registry when +# the install is genuinely impossible (offline, no bun, no network). +if [ ! -e "$STAMP" ] && command -v bun >/dev/null 2>&1; then + mkdir -p "$CONTROL_ROOT" 2>/dev/null && : > "$STAMP" 2>/dev/null + printf '{"name":"@fusengine/cursor-harness","private":true,"dependencies":{"@fusengine/harness":"%s"}}\n' \ + "$HARNESS_SPEC" > "${CONTROL_ROOT}/package.json" 2>/dev/null + (cd "$CONTROL_ROOT" && bun install --silent) >/dev/null 2>&1 + [ -f "$BIN" ] && run_bin "$@" +fi + +# 3. Last resort: npx on the latest published release. +command -v npx >/dev/null 2>&1 || exit 0 +exec npx -y "@fusengine/harness@${HARNESS_SPEC}" hook cursor "$@" diff --git a/.cursor-plugin/plugins/fuse-nextjs/hooks/hooks.json b/.cursor-plugin/plugins/fuse-nextjs/hooks/hooks.json index 960e856..c8dde2f 100644 --- a/.cursor-plugin/plugins/fuse-nextjs/hooks/hooks.json +++ b/.cursor-plugin/plugins/fuse-nextjs/hooks/hooks.json @@ -3,13 +3,13 @@ "hooks": { "preToolUse": [ { - "command": "npx -y @fusengine/harness hook cursor", + "command": "./scripts/hook.sh", "matcher": "Write" } ], "postToolUse": [ { - "command": "npx -y @fusengine/harness hook cursor" + "command": "./scripts/hook.sh" } ] } diff --git a/.cursor-plugin/plugins/fuse-nextjs/scripts/hook.sh b/.cursor-plugin/plugins/fuse-nextjs/scripts/hook.sh new file mode 100755 index 0000000..cff4a47 --- /dev/null +++ b/.cursor-plugin/plugins/fuse-nextjs/scripts/hook.sh @@ -0,0 +1,45 @@ +#!/bin/sh +# Fusengine hook entrypoint — shipped inside every plugin as scripts/hook.sh. +# +# Resolves the shared harness itself so the plugin works from the Cursor +# marketplace, where no installer ever runs. Order: +# 1. the vendored install under $HOME/.cursor/.fusengine-global (fast path) +# 2. a self-heal `bun install` of that same shared directory, once +# 3. `npx` on latest (last resort, network) +# +# Always tracks the LATEST published harness: the vendored manifest uses the +# `latest` dist-tag and the npx fallback resolves it too, so a new release is +# picked up without editing 21 wrappers. +# +# Resolution is relative to THIS file, never to the caller's cwd. Always exits 0 +# on its own failures: Cursor fails hooks open, and a broken guard must never +# block the user's action. +# +# Usage (from hooks/hooks.json): ./scripts/hook.sh [scope...] + +HARNESS_SPEC="latest" +CONTROL_ROOT="${HOME}/.cursor/.fusengine-global" +BIN="${CONTROL_ROOT}/node_modules/@fusengine/harness/dist/cli/bin.mjs" +STAMP="${CONTROL_ROOT}/.selfheal-attempted" + +run_bin() { + if command -v bun >/dev/null 2>&1; then exec bun "$BIN" hook cursor "$@"; fi + exec node "$BIN" hook cursor "$@" +} + +# 1. Fast path: already vendored. +[ -f "$BIN" ] && run_bin "$@" + +# 2. Self-heal once. The stamp keeps 62 hooks from stampeding the registry when +# the install is genuinely impossible (offline, no bun, no network). +if [ ! -e "$STAMP" ] && command -v bun >/dev/null 2>&1; then + mkdir -p "$CONTROL_ROOT" 2>/dev/null && : > "$STAMP" 2>/dev/null + printf '{"name":"@fusengine/cursor-harness","private":true,"dependencies":{"@fusengine/harness":"%s"}}\n' \ + "$HARNESS_SPEC" > "${CONTROL_ROOT}/package.json" 2>/dev/null + (cd "$CONTROL_ROOT" && bun install --silent) >/dev/null 2>&1 + [ -f "$BIN" ] && run_bin "$@" +fi + +# 3. Last resort: npx on the latest published release. +command -v npx >/dev/null 2>&1 || exit 0 +exec npx -y "@fusengine/harness@${HARNESS_SPEC}" hook cursor "$@" diff --git a/.cursor-plugin/plugins/fuse-php/hooks/hooks.json b/.cursor-plugin/plugins/fuse-php/hooks/hooks.json index 960e856..c8dde2f 100644 --- a/.cursor-plugin/plugins/fuse-php/hooks/hooks.json +++ b/.cursor-plugin/plugins/fuse-php/hooks/hooks.json @@ -3,13 +3,13 @@ "hooks": { "preToolUse": [ { - "command": "npx -y @fusengine/harness hook cursor", + "command": "./scripts/hook.sh", "matcher": "Write" } ], "postToolUse": [ { - "command": "npx -y @fusengine/harness hook cursor" + "command": "./scripts/hook.sh" } ] } diff --git a/.cursor-plugin/plugins/fuse-php/scripts/hook.sh b/.cursor-plugin/plugins/fuse-php/scripts/hook.sh new file mode 100755 index 0000000..cff4a47 --- /dev/null +++ b/.cursor-plugin/plugins/fuse-php/scripts/hook.sh @@ -0,0 +1,45 @@ +#!/bin/sh +# Fusengine hook entrypoint — shipped inside every plugin as scripts/hook.sh. +# +# Resolves the shared harness itself so the plugin works from the Cursor +# marketplace, where no installer ever runs. Order: +# 1. the vendored install under $HOME/.cursor/.fusengine-global (fast path) +# 2. a self-heal `bun install` of that same shared directory, once +# 3. `npx` on latest (last resort, network) +# +# Always tracks the LATEST published harness: the vendored manifest uses the +# `latest` dist-tag and the npx fallback resolves it too, so a new release is +# picked up without editing 21 wrappers. +# +# Resolution is relative to THIS file, never to the caller's cwd. Always exits 0 +# on its own failures: Cursor fails hooks open, and a broken guard must never +# block the user's action. +# +# Usage (from hooks/hooks.json): ./scripts/hook.sh [scope...] + +HARNESS_SPEC="latest" +CONTROL_ROOT="${HOME}/.cursor/.fusengine-global" +BIN="${CONTROL_ROOT}/node_modules/@fusengine/harness/dist/cli/bin.mjs" +STAMP="${CONTROL_ROOT}/.selfheal-attempted" + +run_bin() { + if command -v bun >/dev/null 2>&1; then exec bun "$BIN" hook cursor "$@"; fi + exec node "$BIN" hook cursor "$@" +} + +# 1. Fast path: already vendored. +[ -f "$BIN" ] && run_bin "$@" + +# 2. Self-heal once. The stamp keeps 62 hooks from stampeding the registry when +# the install is genuinely impossible (offline, no bun, no network). +if [ ! -e "$STAMP" ] && command -v bun >/dev/null 2>&1; then + mkdir -p "$CONTROL_ROOT" 2>/dev/null && : > "$STAMP" 2>/dev/null + printf '{"name":"@fusengine/cursor-harness","private":true,"dependencies":{"@fusengine/harness":"%s"}}\n' \ + "$HARNESS_SPEC" > "${CONTROL_ROOT}/package.json" 2>/dev/null + (cd "$CONTROL_ROOT" && bun install --silent) >/dev/null 2>&1 + [ -f "$BIN" ] && run_bin "$@" +fi + +# 3. Last resort: npx on the latest published release. +command -v npx >/dev/null 2>&1 || exit 0 +exec npx -y "@fusengine/harness@${HARNESS_SPEC}" hook cursor "$@" diff --git a/.cursor-plugin/plugins/fuse-react/hooks/hooks.json b/.cursor-plugin/plugins/fuse-react/hooks/hooks.json index 960e856..c8dde2f 100644 --- a/.cursor-plugin/plugins/fuse-react/hooks/hooks.json +++ b/.cursor-plugin/plugins/fuse-react/hooks/hooks.json @@ -3,13 +3,13 @@ "hooks": { "preToolUse": [ { - "command": "npx -y @fusengine/harness hook cursor", + "command": "./scripts/hook.sh", "matcher": "Write" } ], "postToolUse": [ { - "command": "npx -y @fusengine/harness hook cursor" + "command": "./scripts/hook.sh" } ] } diff --git a/.cursor-plugin/plugins/fuse-react/scripts/hook.sh b/.cursor-plugin/plugins/fuse-react/scripts/hook.sh new file mode 100755 index 0000000..cff4a47 --- /dev/null +++ b/.cursor-plugin/plugins/fuse-react/scripts/hook.sh @@ -0,0 +1,45 @@ +#!/bin/sh +# Fusengine hook entrypoint — shipped inside every plugin as scripts/hook.sh. +# +# Resolves the shared harness itself so the plugin works from the Cursor +# marketplace, where no installer ever runs. Order: +# 1. the vendored install under $HOME/.cursor/.fusengine-global (fast path) +# 2. a self-heal `bun install` of that same shared directory, once +# 3. `npx` on latest (last resort, network) +# +# Always tracks the LATEST published harness: the vendored manifest uses the +# `latest` dist-tag and the npx fallback resolves it too, so a new release is +# picked up without editing 21 wrappers. +# +# Resolution is relative to THIS file, never to the caller's cwd. Always exits 0 +# on its own failures: Cursor fails hooks open, and a broken guard must never +# block the user's action. +# +# Usage (from hooks/hooks.json): ./scripts/hook.sh [scope...] + +HARNESS_SPEC="latest" +CONTROL_ROOT="${HOME}/.cursor/.fusengine-global" +BIN="${CONTROL_ROOT}/node_modules/@fusengine/harness/dist/cli/bin.mjs" +STAMP="${CONTROL_ROOT}/.selfheal-attempted" + +run_bin() { + if command -v bun >/dev/null 2>&1; then exec bun "$BIN" hook cursor "$@"; fi + exec node "$BIN" hook cursor "$@" +} + +# 1. Fast path: already vendored. +[ -f "$BIN" ] && run_bin "$@" + +# 2. Self-heal once. The stamp keeps 62 hooks from stampeding the registry when +# the install is genuinely impossible (offline, no bun, no network). +if [ ! -e "$STAMP" ] && command -v bun >/dev/null 2>&1; then + mkdir -p "$CONTROL_ROOT" 2>/dev/null && : > "$STAMP" 2>/dev/null + printf '{"name":"@fusengine/cursor-harness","private":true,"dependencies":{"@fusengine/harness":"%s"}}\n' \ + "$HARNESS_SPEC" > "${CONTROL_ROOT}/package.json" 2>/dev/null + (cd "$CONTROL_ROOT" && bun install --silent) >/dev/null 2>&1 + [ -f "$BIN" ] && run_bin "$@" +fi + +# 3. Last resort: npx on the latest published release. +command -v npx >/dev/null 2>&1 || exit 0 +exec npx -y "@fusengine/harness@${HARNESS_SPEC}" hook cursor "$@" diff --git a/.cursor-plugin/plugins/fuse-rules/hooks/hooks.json b/.cursor-plugin/plugins/fuse-rules/hooks/hooks.json index 945afb4..fe80abe 100644 --- a/.cursor-plugin/plugins/fuse-rules/hooks/hooks.json +++ b/.cursor-plugin/plugins/fuse-rules/hooks/hooks.json @@ -3,17 +3,17 @@ "hooks": { "sessionStart": [ { - "command": "npx -y @fusengine/harness hook cursor rules" + "command": "./scripts/hook.sh rules" } ], "subagentStart": [ { - "command": "npx -y @fusengine/harness hook cursor rules" + "command": "./scripts/hook.sh rules" } ], "beforeSubmitPrompt": [ { - "command": "npx -y @fusengine/harness hook cursor rules" + "command": "./scripts/hook.sh rules" } ] } diff --git a/.cursor-plugin/plugins/fuse-rules/rules/00-critical-rules.md b/.cursor-plugin/plugins/fuse-rules/rules/00-critical-rules.md index 70d9b0c..986cdf1 100644 --- a/.cursor-plugin/plugins/fuse-rules/rules/00-critical-rules.md +++ b/.cursor-plugin/plugins/fuse-rules/rules/00-critical-rules.md @@ -1,3 +1,8 @@ +--- +description: Critical global rules — response language and writing style, applied to every session +alwaysApply: true +--- + ## Response Language - Conversation / replies to the user: **in the user's language** (per the user's own configuration) — a shared rule must NEVER hardcode a human language, or every plugin user is forced into it - Written artifacts — docs (*.md), **code comments, lessons (`MEMORY/LESSON.md`), commit messages, CHANGELOG, skill/agent files**: **English** (international standard), regardless of conversation language diff --git a/.cursor-plugin/plugins/fuse-rules/rules/01-project-detection.md b/.cursor-plugin/plugins/fuse-rules/rules/01-project-detection.md index 39b755c..bd75813 100644 --- a/.cursor-plugin/plugins/fuse-rules/rules/01-project-detection.md +++ b/.cursor-plugin/plugins/fuse-rules/rules/01-project-detection.md @@ -1,3 +1,8 @@ +--- +description: Project detection — routes tasks to the matching domain agent +alwaysApply: true +--- + ## Project Detection -> Domain Agent Scan: plugin agents using runtime-provided paths when actually present (Cursor SessionStart delivery remains runtime-unverified; never hardcode marketplace paths) + ~/.cursor/agents/*.md diff --git a/.cursor-plugin/plugins/fuse-rules/rules/02-apex-workflow.md b/.cursor-plugin/plugins/fuse-rules/rules/02-apex-workflow.md index 84d343f..c5b3c7c 100644 --- a/.cursor-plugin/plugins/fuse-rules/rules/02-apex-workflow.md +++ b/.cursor-plugin/plugins/fuse-rules/rules/02-apex-workflow.md @@ -1,3 +1,8 @@ +--- +description: APEX workflow auto-trigger — when to use the full APEX methodology vs skip it +alwaysApply: true +--- + ## APEX Auto-Trigger **USE APEX:** create, implement, add, build, refactor, migrate, new component, multi-file, architecture changes diff --git a/.cursor-plugin/plugins/fuse-rules/rules/03-agent-teams.md b/.cursor-plugin/plugins/fuse-rules/rules/03-agent-teams.md index 58730d5..9523e8f 100644 --- a/.cursor-plugin/plugins/fuse-rules/rules/03-agent-teams.md +++ b/.cursor-plugin/plugins/fuse-rules/rules/03-agent-teams.md @@ -1,3 +1,8 @@ +--- +description: Agent team delegation rules — exclusive file ownership, well-scoped tasks +alwaysApply: true +--- + ## Agent Teams **Lead = Coordinator ONLY.** Never codes, only orchestrates. diff --git a/.cursor-plugin/plugins/fuse-rules/rules/04-solid-dry-rules.md b/.cursor-plugin/plugins/fuse-rules/rules/04-solid-dry-rules.md index 4829e7b..922c03e 100644 --- a/.cursor-plugin/plugins/fuse-rules/rules/04-solid-dry-rules.md +++ b/.cursor-plugin/plugins/fuse-rules/rules/04-solid-dry-rules.md @@ -1,3 +1,8 @@ +--- +description: SOLID/DRY enforcement — which SOLID skill and interfaces path to use per stack +alwaysApply: true +--- + ## SOLID Skill per Stack (read before coding) | Agent | Skill | Interfaces | diff --git a/.cursor-plugin/plugins/fuse-rules/rules/05-frontend-rules.md b/.cursor-plugin/plugins/fuse-rules/rules/05-frontend-rules.md index c6a6e93..a141ed7 100644 --- a/.cursor-plugin/plugins/fuse-rules/rules/05-frontend-rules.md +++ b/.cursor-plugin/plugins/fuse-rules/rules/05-frontend-rules.md @@ -1,3 +1,8 @@ +--- +description: Frontend task rules — UI workflow routing through the design expert +alwaysApply: true +--- + ## Frontend Tasks ### UI Workflow (MANDATORY: route through `fuse-design:design-expert`) diff --git a/.cursor-plugin/plugins/fuse-rules/rules/06-tooling-rules.md b/.cursor-plugin/plugins/fuse-rules/rules/06-tooling-rules.md index 2392a4e..ba9d03c 100644 --- a/.cursor-plugin/plugins/fuse-rules/rules/06-tooling-rules.md +++ b/.cursor-plugin/plugins/fuse-rules/rules/06-tooling-rules.md @@ -1,3 +1,8 @@ +--- +description: Tooling rules — MCP server usage, git, hooks, and documentation standards +alwaysApply: true +--- + ## MCP Servers | Server | Usage | Agent | diff --git a/.cursor-plugin/plugins/fuse-rules/rules/07-state-management.md b/.cursor-plugin/plugins/fuse-rules/rules/07-state-management.md index 03d063d..09fb475 100644 --- a/.cursor-plugin/plugins/fuse-rules/rules/07-state-management.md +++ b/.cursor-plugin/plugins/fuse-rules/rules/07-state-management.md @@ -1,3 +1,8 @@ +--- +description: React/Next.js state management rules — TanStack Query and Zustand placement +alwaysApply: true +--- + ## State Management (React / Next.js) | State Type | Solution | Location | diff --git a/.cursor-plugin/plugins/fuse-rules/rules/08-subagent-conduct.md b/.cursor-plugin/plugins/fuse-rules/rules/08-subagent-conduct.md index 76e6626..f32b244 100644 --- a/.cursor-plugin/plugins/fuse-rules/rules/08-subagent-conduct.md +++ b/.cursor-plugin/plugins/fuse-rules/rules/08-subagent-conduct.md @@ -1,3 +1,8 @@ +--- +description: Subagent conduct — mandatory cartography navigation and hook compliance +alwaysApply: true +--- + ## Cartography (MANDATORY — Step 1) `.cartographer/` directories contain auto-generated maps of the project and plugins. Each `index.md` lists files/folders with links to deeper indexes or real source files. 1. Read .cartographer/project/index.md and any plugin-skills-map path actually supplied in the current runtime context; do not assume Cursor SubagentStart injects one. diff --git a/.cursor-plugin/plugins/fuse-rules/scripts/hook.sh b/.cursor-plugin/plugins/fuse-rules/scripts/hook.sh new file mode 100755 index 0000000..cff4a47 --- /dev/null +++ b/.cursor-plugin/plugins/fuse-rules/scripts/hook.sh @@ -0,0 +1,45 @@ +#!/bin/sh +# Fusengine hook entrypoint — shipped inside every plugin as scripts/hook.sh. +# +# Resolves the shared harness itself so the plugin works from the Cursor +# marketplace, where no installer ever runs. Order: +# 1. the vendored install under $HOME/.cursor/.fusengine-global (fast path) +# 2. a self-heal `bun install` of that same shared directory, once +# 3. `npx` on latest (last resort, network) +# +# Always tracks the LATEST published harness: the vendored manifest uses the +# `latest` dist-tag and the npx fallback resolves it too, so a new release is +# picked up without editing 21 wrappers. +# +# Resolution is relative to THIS file, never to the caller's cwd. Always exits 0 +# on its own failures: Cursor fails hooks open, and a broken guard must never +# block the user's action. +# +# Usage (from hooks/hooks.json): ./scripts/hook.sh [scope...] + +HARNESS_SPEC="latest" +CONTROL_ROOT="${HOME}/.cursor/.fusengine-global" +BIN="${CONTROL_ROOT}/node_modules/@fusengine/harness/dist/cli/bin.mjs" +STAMP="${CONTROL_ROOT}/.selfheal-attempted" + +run_bin() { + if command -v bun >/dev/null 2>&1; then exec bun "$BIN" hook cursor "$@"; fi + exec node "$BIN" hook cursor "$@" +} + +# 1. Fast path: already vendored. +[ -f "$BIN" ] && run_bin "$@" + +# 2. Self-heal once. The stamp keeps 62 hooks from stampeding the registry when +# the install is genuinely impossible (offline, no bun, no network). +if [ ! -e "$STAMP" ] && command -v bun >/dev/null 2>&1; then + mkdir -p "$CONTROL_ROOT" 2>/dev/null && : > "$STAMP" 2>/dev/null + printf '{"name":"@fusengine/cursor-harness","private":true,"dependencies":{"@fusengine/harness":"%s"}}\n' \ + "$HARNESS_SPEC" > "${CONTROL_ROOT}/package.json" 2>/dev/null + (cd "$CONTROL_ROOT" && bun install --silent) >/dev/null 2>&1 + [ -f "$BIN" ] && run_bin "$@" +fi + +# 3. Last resort: npx on the latest published release. +command -v npx >/dev/null 2>&1 || exit 0 +exec npx -y "@fusengine/harness@${HARNESS_SPEC}" hook cursor "$@" diff --git a/.cursor-plugin/plugins/fuse-rust/hooks/hooks.json b/.cursor-plugin/plugins/fuse-rust/hooks/hooks.json index 960e856..c8dde2f 100644 --- a/.cursor-plugin/plugins/fuse-rust/hooks/hooks.json +++ b/.cursor-plugin/plugins/fuse-rust/hooks/hooks.json @@ -3,13 +3,13 @@ "hooks": { "preToolUse": [ { - "command": "npx -y @fusengine/harness hook cursor", + "command": "./scripts/hook.sh", "matcher": "Write" } ], "postToolUse": [ { - "command": "npx -y @fusengine/harness hook cursor" + "command": "./scripts/hook.sh" } ] } diff --git a/.cursor-plugin/plugins/fuse-rust/scripts/hook.sh b/.cursor-plugin/plugins/fuse-rust/scripts/hook.sh new file mode 100755 index 0000000..cff4a47 --- /dev/null +++ b/.cursor-plugin/plugins/fuse-rust/scripts/hook.sh @@ -0,0 +1,45 @@ +#!/bin/sh +# Fusengine hook entrypoint — shipped inside every plugin as scripts/hook.sh. +# +# Resolves the shared harness itself so the plugin works from the Cursor +# marketplace, where no installer ever runs. Order: +# 1. the vendored install under $HOME/.cursor/.fusengine-global (fast path) +# 2. a self-heal `bun install` of that same shared directory, once +# 3. `npx` on latest (last resort, network) +# +# Always tracks the LATEST published harness: the vendored manifest uses the +# `latest` dist-tag and the npx fallback resolves it too, so a new release is +# picked up without editing 21 wrappers. +# +# Resolution is relative to THIS file, never to the caller's cwd. Always exits 0 +# on its own failures: Cursor fails hooks open, and a broken guard must never +# block the user's action. +# +# Usage (from hooks/hooks.json): ./scripts/hook.sh [scope...] + +HARNESS_SPEC="latest" +CONTROL_ROOT="${HOME}/.cursor/.fusengine-global" +BIN="${CONTROL_ROOT}/node_modules/@fusengine/harness/dist/cli/bin.mjs" +STAMP="${CONTROL_ROOT}/.selfheal-attempted" + +run_bin() { + if command -v bun >/dev/null 2>&1; then exec bun "$BIN" hook cursor "$@"; fi + exec node "$BIN" hook cursor "$@" +} + +# 1. Fast path: already vendored. +[ -f "$BIN" ] && run_bin "$@" + +# 2. Self-heal once. The stamp keeps 62 hooks from stampeding the registry when +# the install is genuinely impossible (offline, no bun, no network). +if [ ! -e "$STAMP" ] && command -v bun >/dev/null 2>&1; then + mkdir -p "$CONTROL_ROOT" 2>/dev/null && : > "$STAMP" 2>/dev/null + printf '{"name":"@fusengine/cursor-harness","private":true,"dependencies":{"@fusengine/harness":"%s"}}\n' \ + "$HARNESS_SPEC" > "${CONTROL_ROOT}/package.json" 2>/dev/null + (cd "$CONTROL_ROOT" && bun install --silent) >/dev/null 2>&1 + [ -f "$BIN" ] && run_bin "$@" +fi + +# 3. Last resort: npx on the latest published release. +command -v npx >/dev/null 2>&1 || exit 0 +exec npx -y "@fusengine/harness@${HARNESS_SPEC}" hook cursor "$@" diff --git a/.cursor-plugin/plugins/fuse-security/hooks/hooks.json b/.cursor-plugin/plugins/fuse-security/hooks/hooks.json index a5b12d9..ffb7aa5 100644 --- a/.cursor-plugin/plugins/fuse-security/hooks/hooks.json +++ b/.cursor-plugin/plugins/fuse-security/hooks/hooks.json @@ -3,19 +3,19 @@ "hooks": { "preToolUse": [ { - "command": "npx -y @fusengine/harness hook cursor security", + "command": "./scripts/hook.sh security", "matcher": "Write" } ], "postToolUse": [ { - "command": "npx -y @fusengine/harness hook cursor security", + "command": "./scripts/hook.sh security", "matcher": "Read" } ], "afterMCPExecution": [ { - "command": "npx -y @fusengine/harness hook cursor security", + "command": "./scripts/hook.sh security", "matcher": "MCP:(resolve-library-id|query-docs|web_search_exa|get_code_context_exa|deep_researcher_start|deep_researcher_check)" } ] diff --git a/.cursor-plugin/plugins/fuse-security/scripts/hook.sh b/.cursor-plugin/plugins/fuse-security/scripts/hook.sh new file mode 100755 index 0000000..cff4a47 --- /dev/null +++ b/.cursor-plugin/plugins/fuse-security/scripts/hook.sh @@ -0,0 +1,45 @@ +#!/bin/sh +# Fusengine hook entrypoint — shipped inside every plugin as scripts/hook.sh. +# +# Resolves the shared harness itself so the plugin works from the Cursor +# marketplace, where no installer ever runs. Order: +# 1. the vendored install under $HOME/.cursor/.fusengine-global (fast path) +# 2. a self-heal `bun install` of that same shared directory, once +# 3. `npx` on latest (last resort, network) +# +# Always tracks the LATEST published harness: the vendored manifest uses the +# `latest` dist-tag and the npx fallback resolves it too, so a new release is +# picked up without editing 21 wrappers. +# +# Resolution is relative to THIS file, never to the caller's cwd. Always exits 0 +# on its own failures: Cursor fails hooks open, and a broken guard must never +# block the user's action. +# +# Usage (from hooks/hooks.json): ./scripts/hook.sh [scope...] + +HARNESS_SPEC="latest" +CONTROL_ROOT="${HOME}/.cursor/.fusengine-global" +BIN="${CONTROL_ROOT}/node_modules/@fusengine/harness/dist/cli/bin.mjs" +STAMP="${CONTROL_ROOT}/.selfheal-attempted" + +run_bin() { + if command -v bun >/dev/null 2>&1; then exec bun "$BIN" hook cursor "$@"; fi + exec node "$BIN" hook cursor "$@" +} + +# 1. Fast path: already vendored. +[ -f "$BIN" ] && run_bin "$@" + +# 2. Self-heal once. The stamp keeps 62 hooks from stampeding the registry when +# the install is genuinely impossible (offline, no bun, no network). +if [ ! -e "$STAMP" ] && command -v bun >/dev/null 2>&1; then + mkdir -p "$CONTROL_ROOT" 2>/dev/null && : > "$STAMP" 2>/dev/null + printf '{"name":"@fusengine/cursor-harness","private":true,"dependencies":{"@fusengine/harness":"%s"}}\n' \ + "$HARNESS_SPEC" > "${CONTROL_ROOT}/package.json" 2>/dev/null + (cd "$CONTROL_ROOT" && bun install --silent) >/dev/null 2>&1 + [ -f "$BIN" ] && run_bin "$@" +fi + +# 3. Last resort: npx on the latest published release. +command -v npx >/dev/null 2>&1 || exit 0 +exec npx -y "@fusengine/harness@${HARNESS_SPEC}" hook cursor "$@" diff --git a/.cursor-plugin/plugins/fuse-seo/.claude/apex/docs/task-1-generic.md b/.cursor-plugin/plugins/fuse-seo/.claude/apex/docs/task-1-generic.md deleted file mode 100644 index c565149..0000000 --- a/.cursor-plugin/plugins/fuse-seo/.claude/apex/docs/task-1-generic.md +++ /dev/null @@ -1,5 +0,0 @@ -# Task 1 - Generic Documentation -## Consulted: 2026-08-25T16:16:05Z | Source: skill:Read -## Key Info - -- **[README]** `README.md` - 2026-08-25T16:16:05Z diff --git a/.cursor-plugin/plugins/fuse-seo/.cursor-plugin/plugin.json b/.cursor-plugin/plugins/fuse-seo/.cursor-plugin/plugin.json index 4d80488..d0213c8 100644 --- a/.cursor-plugin/plugins/fuse-seo/.cursor-plugin/plugin.json +++ b/.cursor-plugin/plugins/fuse-seo/.cursor-plugin/plugin.json @@ -24,6 +24,5 @@ "featured-snippets", "entity-seo", "knowledge-graph" - ], - "category": "development" + ] } diff --git a/.cursor-plugin/plugins/fuse-seo/hooks/hooks.json b/.cursor-plugin/plugins/fuse-seo/hooks/hooks.json index b1f188e..a0f05b3 100644 --- a/.cursor-plugin/plugins/fuse-seo/hooks/hooks.json +++ b/.cursor-plugin/plugins/fuse-seo/hooks/hooks.json @@ -3,7 +3,7 @@ "hooks": { "postToolUse": [ { - "command": "npx -y @fusengine/harness hook cursor seo", + "command": "./scripts/hook.sh seo", "matcher": "Write" } ] diff --git a/.cursor-plugin/plugins/fuse-seo/scripts/hook.sh b/.cursor-plugin/plugins/fuse-seo/scripts/hook.sh new file mode 100755 index 0000000..cff4a47 --- /dev/null +++ b/.cursor-plugin/plugins/fuse-seo/scripts/hook.sh @@ -0,0 +1,45 @@ +#!/bin/sh +# Fusengine hook entrypoint — shipped inside every plugin as scripts/hook.sh. +# +# Resolves the shared harness itself so the plugin works from the Cursor +# marketplace, where no installer ever runs. Order: +# 1. the vendored install under $HOME/.cursor/.fusengine-global (fast path) +# 2. a self-heal `bun install` of that same shared directory, once +# 3. `npx` on latest (last resort, network) +# +# Always tracks the LATEST published harness: the vendored manifest uses the +# `latest` dist-tag and the npx fallback resolves it too, so a new release is +# picked up without editing 21 wrappers. +# +# Resolution is relative to THIS file, never to the caller's cwd. Always exits 0 +# on its own failures: Cursor fails hooks open, and a broken guard must never +# block the user's action. +# +# Usage (from hooks/hooks.json): ./scripts/hook.sh [scope...] + +HARNESS_SPEC="latest" +CONTROL_ROOT="${HOME}/.cursor/.fusengine-global" +BIN="${CONTROL_ROOT}/node_modules/@fusengine/harness/dist/cli/bin.mjs" +STAMP="${CONTROL_ROOT}/.selfheal-attempted" + +run_bin() { + if command -v bun >/dev/null 2>&1; then exec bun "$BIN" hook cursor "$@"; fi + exec node "$BIN" hook cursor "$@" +} + +# 1. Fast path: already vendored. +[ -f "$BIN" ] && run_bin "$@" + +# 2. Self-heal once. The stamp keeps 62 hooks from stampeding the registry when +# the install is genuinely impossible (offline, no bun, no network). +if [ ! -e "$STAMP" ] && command -v bun >/dev/null 2>&1; then + mkdir -p "$CONTROL_ROOT" 2>/dev/null && : > "$STAMP" 2>/dev/null + printf '{"name":"@fusengine/cursor-harness","private":true,"dependencies":{"@fusengine/harness":"%s"}}\n' \ + "$HARNESS_SPEC" > "${CONTROL_ROOT}/package.json" 2>/dev/null + (cd "$CONTROL_ROOT" && bun install --silent) >/dev/null 2>&1 + [ -f "$BIN" ] && run_bin "$@" +fi + +# 3. Last resort: npx on the latest published release. +command -v npx >/dev/null 2>&1 || exit 0 +exec npx -y "@fusengine/harness@${HARNESS_SPEC}" hook cursor "$@" diff --git a/.cursor-plugin/plugins/fuse-shadcn-ui/hooks/hooks.json b/.cursor-plugin/plugins/fuse-shadcn-ui/hooks/hooks.json index 960e856..c8dde2f 100644 --- a/.cursor-plugin/plugins/fuse-shadcn-ui/hooks/hooks.json +++ b/.cursor-plugin/plugins/fuse-shadcn-ui/hooks/hooks.json @@ -3,13 +3,13 @@ "hooks": { "preToolUse": [ { - "command": "npx -y @fusengine/harness hook cursor", + "command": "./scripts/hook.sh", "matcher": "Write" } ], "postToolUse": [ { - "command": "npx -y @fusengine/harness hook cursor" + "command": "./scripts/hook.sh" } ] } diff --git a/.cursor-plugin/plugins/fuse-shadcn-ui/scripts/hook.sh b/.cursor-plugin/plugins/fuse-shadcn-ui/scripts/hook.sh new file mode 100755 index 0000000..cff4a47 --- /dev/null +++ b/.cursor-plugin/plugins/fuse-shadcn-ui/scripts/hook.sh @@ -0,0 +1,45 @@ +#!/bin/sh +# Fusengine hook entrypoint — shipped inside every plugin as scripts/hook.sh. +# +# Resolves the shared harness itself so the plugin works from the Cursor +# marketplace, where no installer ever runs. Order: +# 1. the vendored install under $HOME/.cursor/.fusengine-global (fast path) +# 2. a self-heal `bun install` of that same shared directory, once +# 3. `npx` on latest (last resort, network) +# +# Always tracks the LATEST published harness: the vendored manifest uses the +# `latest` dist-tag and the npx fallback resolves it too, so a new release is +# picked up without editing 21 wrappers. +# +# Resolution is relative to THIS file, never to the caller's cwd. Always exits 0 +# on its own failures: Cursor fails hooks open, and a broken guard must never +# block the user's action. +# +# Usage (from hooks/hooks.json): ./scripts/hook.sh [scope...] + +HARNESS_SPEC="latest" +CONTROL_ROOT="${HOME}/.cursor/.fusengine-global" +BIN="${CONTROL_ROOT}/node_modules/@fusengine/harness/dist/cli/bin.mjs" +STAMP="${CONTROL_ROOT}/.selfheal-attempted" + +run_bin() { + if command -v bun >/dev/null 2>&1; then exec bun "$BIN" hook cursor "$@"; fi + exec node "$BIN" hook cursor "$@" +} + +# 1. Fast path: already vendored. +[ -f "$BIN" ] && run_bin "$@" + +# 2. Self-heal once. The stamp keeps 62 hooks from stampeding the registry when +# the install is genuinely impossible (offline, no bun, no network). +if [ ! -e "$STAMP" ] && command -v bun >/dev/null 2>&1; then + mkdir -p "$CONTROL_ROOT" 2>/dev/null && : > "$STAMP" 2>/dev/null + printf '{"name":"@fusengine/cursor-harness","private":true,"dependencies":{"@fusengine/harness":"%s"}}\n' \ + "$HARNESS_SPEC" > "${CONTROL_ROOT}/package.json" 2>/dev/null + (cd "$CONTROL_ROOT" && bun install --silent) >/dev/null 2>&1 + [ -f "$BIN" ] && run_bin "$@" +fi + +# 3. Last resort: npx on the latest published release. +command -v npx >/dev/null 2>&1 || exit 0 +exec npx -y "@fusengine/harness@${HARNESS_SPEC}" hook cursor "$@" diff --git a/.cursor-plugin/plugins/fuse-solid/hooks/hooks.json b/.cursor-plugin/plugins/fuse-solid/hooks/hooks.json index 6226a51..089dcb7 100644 --- a/.cursor-plugin/plugins/fuse-solid/hooks/hooks.json +++ b/.cursor-plugin/plugins/fuse-solid/hooks/hooks.json @@ -3,20 +3,20 @@ "hooks": { "sessionStart": [ { - "command": "npx -y @fusengine/harness hook cursor solid", + "command": "./scripts/hook.sh solid", "timeout": 5 } ], "preToolUse": [ { - "command": "npx -y @fusengine/harness hook cursor solid", + "command": "./scripts/hook.sh solid", "matcher": "Write", "timeout": 10 } ], "postToolUse": [ { - "command": "npx -y @fusengine/harness hook cursor solid", + "command": "./scripts/hook.sh solid", "matcher": "Write", "timeout": 10 } diff --git a/.cursor-plugin/plugins/fuse-solid/scripts/hook.sh b/.cursor-plugin/plugins/fuse-solid/scripts/hook.sh new file mode 100755 index 0000000..cff4a47 --- /dev/null +++ b/.cursor-plugin/plugins/fuse-solid/scripts/hook.sh @@ -0,0 +1,45 @@ +#!/bin/sh +# Fusengine hook entrypoint — shipped inside every plugin as scripts/hook.sh. +# +# Resolves the shared harness itself so the plugin works from the Cursor +# marketplace, where no installer ever runs. Order: +# 1. the vendored install under $HOME/.cursor/.fusengine-global (fast path) +# 2. a self-heal `bun install` of that same shared directory, once +# 3. `npx` on latest (last resort, network) +# +# Always tracks the LATEST published harness: the vendored manifest uses the +# `latest` dist-tag and the npx fallback resolves it too, so a new release is +# picked up without editing 21 wrappers. +# +# Resolution is relative to THIS file, never to the caller's cwd. Always exits 0 +# on its own failures: Cursor fails hooks open, and a broken guard must never +# block the user's action. +# +# Usage (from hooks/hooks.json): ./scripts/hook.sh [scope...] + +HARNESS_SPEC="latest" +CONTROL_ROOT="${HOME}/.cursor/.fusengine-global" +BIN="${CONTROL_ROOT}/node_modules/@fusengine/harness/dist/cli/bin.mjs" +STAMP="${CONTROL_ROOT}/.selfheal-attempted" + +run_bin() { + if command -v bun >/dev/null 2>&1; then exec bun "$BIN" hook cursor "$@"; fi + exec node "$BIN" hook cursor "$@" +} + +# 1. Fast path: already vendored. +[ -f "$BIN" ] && run_bin "$@" + +# 2. Self-heal once. The stamp keeps 62 hooks from stampeding the registry when +# the install is genuinely impossible (offline, no bun, no network). +if [ ! -e "$STAMP" ] && command -v bun >/dev/null 2>&1; then + mkdir -p "$CONTROL_ROOT" 2>/dev/null && : > "$STAMP" 2>/dev/null + printf '{"name":"@fusengine/cursor-harness","private":true,"dependencies":{"@fusengine/harness":"%s"}}\n' \ + "$HARNESS_SPEC" > "${CONTROL_ROOT}/package.json" 2>/dev/null + (cd "$CONTROL_ROOT" && bun install --silent) >/dev/null 2>&1 + [ -f "$BIN" ] && run_bin "$@" +fi + +# 3. Last resort: npx on the latest published release. +command -v npx >/dev/null 2>&1 || exit 0 +exec npx -y "@fusengine/harness@${HARNESS_SPEC}" hook cursor "$@" diff --git a/.cursor-plugin/plugins/fuse-swift-apple-expert/hooks/hooks.json b/.cursor-plugin/plugins/fuse-swift-apple-expert/hooks/hooks.json index 960e856..c8dde2f 100644 --- a/.cursor-plugin/plugins/fuse-swift-apple-expert/hooks/hooks.json +++ b/.cursor-plugin/plugins/fuse-swift-apple-expert/hooks/hooks.json @@ -3,13 +3,13 @@ "hooks": { "preToolUse": [ { - "command": "npx -y @fusengine/harness hook cursor", + "command": "./scripts/hook.sh", "matcher": "Write" } ], "postToolUse": [ { - "command": "npx -y @fusengine/harness hook cursor" + "command": "./scripts/hook.sh" } ] } diff --git a/.cursor-plugin/plugins/fuse-swift-apple-expert/scripts/hook.sh b/.cursor-plugin/plugins/fuse-swift-apple-expert/scripts/hook.sh new file mode 100755 index 0000000..cff4a47 --- /dev/null +++ b/.cursor-plugin/plugins/fuse-swift-apple-expert/scripts/hook.sh @@ -0,0 +1,45 @@ +#!/bin/sh +# Fusengine hook entrypoint — shipped inside every plugin as scripts/hook.sh. +# +# Resolves the shared harness itself so the plugin works from the Cursor +# marketplace, where no installer ever runs. Order: +# 1. the vendored install under $HOME/.cursor/.fusengine-global (fast path) +# 2. a self-heal `bun install` of that same shared directory, once +# 3. `npx` on latest (last resort, network) +# +# Always tracks the LATEST published harness: the vendored manifest uses the +# `latest` dist-tag and the npx fallback resolves it too, so a new release is +# picked up without editing 21 wrappers. +# +# Resolution is relative to THIS file, never to the caller's cwd. Always exits 0 +# on its own failures: Cursor fails hooks open, and a broken guard must never +# block the user's action. +# +# Usage (from hooks/hooks.json): ./scripts/hook.sh [scope...] + +HARNESS_SPEC="latest" +CONTROL_ROOT="${HOME}/.cursor/.fusengine-global" +BIN="${CONTROL_ROOT}/node_modules/@fusengine/harness/dist/cli/bin.mjs" +STAMP="${CONTROL_ROOT}/.selfheal-attempted" + +run_bin() { + if command -v bun >/dev/null 2>&1; then exec bun "$BIN" hook cursor "$@"; fi + exec node "$BIN" hook cursor "$@" +} + +# 1. Fast path: already vendored. +[ -f "$BIN" ] && run_bin "$@" + +# 2. Self-heal once. The stamp keeps 62 hooks from stampeding the registry when +# the install is genuinely impossible (offline, no bun, no network). +if [ ! -e "$STAMP" ] && command -v bun >/dev/null 2>&1; then + mkdir -p "$CONTROL_ROOT" 2>/dev/null && : > "$STAMP" 2>/dev/null + printf '{"name":"@fusengine/cursor-harness","private":true,"dependencies":{"@fusengine/harness":"%s"}}\n' \ + "$HARNESS_SPEC" > "${CONTROL_ROOT}/package.json" 2>/dev/null + (cd "$CONTROL_ROOT" && bun install --silent) >/dev/null 2>&1 + [ -f "$BIN" ] && run_bin "$@" +fi + +# 3. Last resort: npx on the latest published release. +command -v npx >/dev/null 2>&1 || exit 0 +exec npx -y "@fusengine/harness@${HARNESS_SPEC}" hook cursor "$@" diff --git a/.cursor-plugin/plugins/fuse-tailwindcss/hooks/hooks.json b/.cursor-plugin/plugins/fuse-tailwindcss/hooks/hooks.json index 07d31e0..be0f851 100644 --- a/.cursor-plugin/plugins/fuse-tailwindcss/hooks/hooks.json +++ b/.cursor-plugin/plugins/fuse-tailwindcss/hooks/hooks.json @@ -3,13 +3,13 @@ "hooks": { "preToolUse": [ { - "command": "npx -y @fusengine/harness hook cursor", + "command": "./scripts/hook.sh", "matcher": "Write" } ], "postToolUse": [ { - "command": "npx -y @fusengine/harness hook cursor tailwindcss" + "command": "./scripts/hook.sh tailwindcss" } ] } diff --git a/.cursor-plugin/plugins/fuse-tailwindcss/scripts/hook.sh b/.cursor-plugin/plugins/fuse-tailwindcss/scripts/hook.sh new file mode 100755 index 0000000..cff4a47 --- /dev/null +++ b/.cursor-plugin/plugins/fuse-tailwindcss/scripts/hook.sh @@ -0,0 +1,45 @@ +#!/bin/sh +# Fusengine hook entrypoint — shipped inside every plugin as scripts/hook.sh. +# +# Resolves the shared harness itself so the plugin works from the Cursor +# marketplace, where no installer ever runs. Order: +# 1. the vendored install under $HOME/.cursor/.fusengine-global (fast path) +# 2. a self-heal `bun install` of that same shared directory, once +# 3. `npx` on latest (last resort, network) +# +# Always tracks the LATEST published harness: the vendored manifest uses the +# `latest` dist-tag and the npx fallback resolves it too, so a new release is +# picked up without editing 21 wrappers. +# +# Resolution is relative to THIS file, never to the caller's cwd. Always exits 0 +# on its own failures: Cursor fails hooks open, and a broken guard must never +# block the user's action. +# +# Usage (from hooks/hooks.json): ./scripts/hook.sh [scope...] + +HARNESS_SPEC="latest" +CONTROL_ROOT="${HOME}/.cursor/.fusengine-global" +BIN="${CONTROL_ROOT}/node_modules/@fusengine/harness/dist/cli/bin.mjs" +STAMP="${CONTROL_ROOT}/.selfheal-attempted" + +run_bin() { + if command -v bun >/dev/null 2>&1; then exec bun "$BIN" hook cursor "$@"; fi + exec node "$BIN" hook cursor "$@" +} + +# 1. Fast path: already vendored. +[ -f "$BIN" ] && run_bin "$@" + +# 2. Self-heal once. The stamp keeps 62 hooks from stampeding the registry when +# the install is genuinely impossible (offline, no bun, no network). +if [ ! -e "$STAMP" ] && command -v bun >/dev/null 2>&1; then + mkdir -p "$CONTROL_ROOT" 2>/dev/null && : > "$STAMP" 2>/dev/null + printf '{"name":"@fusengine/cursor-harness","private":true,"dependencies":{"@fusengine/harness":"%s"}}\n' \ + "$HARNESS_SPEC" > "${CONTROL_ROOT}/package.json" 2>/dev/null + (cd "$CONTROL_ROOT" && bun install --silent) >/dev/null 2>&1 + [ -f "$BIN" ] && run_bin "$@" +fi + +# 3. Last resort: npx on the latest published release. +command -v npx >/dev/null 2>&1 || exit 0 +exec npx -y "@fusengine/harness@${HARNESS_SPEC}" hook cursor "$@" diff --git a/.cursor-plugin/plugins/fuse-tanstack-start/hooks/hooks.json b/.cursor-plugin/plugins/fuse-tanstack-start/hooks/hooks.json index 960e856..c8dde2f 100644 --- a/.cursor-plugin/plugins/fuse-tanstack-start/hooks/hooks.json +++ b/.cursor-plugin/plugins/fuse-tanstack-start/hooks/hooks.json @@ -3,13 +3,13 @@ "hooks": { "preToolUse": [ { - "command": "npx -y @fusengine/harness hook cursor", + "command": "./scripts/hook.sh", "matcher": "Write" } ], "postToolUse": [ { - "command": "npx -y @fusengine/harness hook cursor" + "command": "./scripts/hook.sh" } ] } diff --git a/.cursor-plugin/plugins/fuse-tanstack-start/scripts/hook.sh b/.cursor-plugin/plugins/fuse-tanstack-start/scripts/hook.sh new file mode 100755 index 0000000..cff4a47 --- /dev/null +++ b/.cursor-plugin/plugins/fuse-tanstack-start/scripts/hook.sh @@ -0,0 +1,45 @@ +#!/bin/sh +# Fusengine hook entrypoint — shipped inside every plugin as scripts/hook.sh. +# +# Resolves the shared harness itself so the plugin works from the Cursor +# marketplace, where no installer ever runs. Order: +# 1. the vendored install under $HOME/.cursor/.fusengine-global (fast path) +# 2. a self-heal `bun install` of that same shared directory, once +# 3. `npx` on latest (last resort, network) +# +# Always tracks the LATEST published harness: the vendored manifest uses the +# `latest` dist-tag and the npx fallback resolves it too, so a new release is +# picked up without editing 21 wrappers. +# +# Resolution is relative to THIS file, never to the caller's cwd. Always exits 0 +# on its own failures: Cursor fails hooks open, and a broken guard must never +# block the user's action. +# +# Usage (from hooks/hooks.json): ./scripts/hook.sh [scope...] + +HARNESS_SPEC="latest" +CONTROL_ROOT="${HOME}/.cursor/.fusengine-global" +BIN="${CONTROL_ROOT}/node_modules/@fusengine/harness/dist/cli/bin.mjs" +STAMP="${CONTROL_ROOT}/.selfheal-attempted" + +run_bin() { + if command -v bun >/dev/null 2>&1; then exec bun "$BIN" hook cursor "$@"; fi + exec node "$BIN" hook cursor "$@" +} + +# 1. Fast path: already vendored. +[ -f "$BIN" ] && run_bin "$@" + +# 2. Self-heal once. The stamp keeps 62 hooks from stampeding the registry when +# the install is genuinely impossible (offline, no bun, no network). +if [ ! -e "$STAMP" ] && command -v bun >/dev/null 2>&1; then + mkdir -p "$CONTROL_ROOT" 2>/dev/null && : > "$STAMP" 2>/dev/null + printf '{"name":"@fusengine/cursor-harness","private":true,"dependencies":{"@fusengine/harness":"%s"}}\n' \ + "$HARNESS_SPEC" > "${CONTROL_ROOT}/package.json" 2>/dev/null + (cd "$CONTROL_ROOT" && bun install --silent) >/dev/null 2>&1 + [ -f "$BIN" ] && run_bin "$@" +fi + +# 3. Last resort: npx on the latest published release. +command -v npx >/dev/null 2>&1 || exit 0 +exec npx -y "@fusengine/harness@${HARNESS_SPEC}" hook cursor "$@" diff --git a/.cursor-plugin/plugins/fuse-typescript/hooks/hooks.json b/.cursor-plugin/plugins/fuse-typescript/hooks/hooks.json index 960e856..c8dde2f 100644 --- a/.cursor-plugin/plugins/fuse-typescript/hooks/hooks.json +++ b/.cursor-plugin/plugins/fuse-typescript/hooks/hooks.json @@ -3,13 +3,13 @@ "hooks": { "preToolUse": [ { - "command": "npx -y @fusengine/harness hook cursor", + "command": "./scripts/hook.sh", "matcher": "Write" } ], "postToolUse": [ { - "command": "npx -y @fusengine/harness hook cursor" + "command": "./scripts/hook.sh" } ] } diff --git a/.cursor-plugin/plugins/fuse-typescript/scripts/hook.sh b/.cursor-plugin/plugins/fuse-typescript/scripts/hook.sh new file mode 100755 index 0000000..cff4a47 --- /dev/null +++ b/.cursor-plugin/plugins/fuse-typescript/scripts/hook.sh @@ -0,0 +1,45 @@ +#!/bin/sh +# Fusengine hook entrypoint — shipped inside every plugin as scripts/hook.sh. +# +# Resolves the shared harness itself so the plugin works from the Cursor +# marketplace, where no installer ever runs. Order: +# 1. the vendored install under $HOME/.cursor/.fusengine-global (fast path) +# 2. a self-heal `bun install` of that same shared directory, once +# 3. `npx` on latest (last resort, network) +# +# Always tracks the LATEST published harness: the vendored manifest uses the +# `latest` dist-tag and the npx fallback resolves it too, so a new release is +# picked up without editing 21 wrappers. +# +# Resolution is relative to THIS file, never to the caller's cwd. Always exits 0 +# on its own failures: Cursor fails hooks open, and a broken guard must never +# block the user's action. +# +# Usage (from hooks/hooks.json): ./scripts/hook.sh [scope...] + +HARNESS_SPEC="latest" +CONTROL_ROOT="${HOME}/.cursor/.fusengine-global" +BIN="${CONTROL_ROOT}/node_modules/@fusengine/harness/dist/cli/bin.mjs" +STAMP="${CONTROL_ROOT}/.selfheal-attempted" + +run_bin() { + if command -v bun >/dev/null 2>&1; then exec bun "$BIN" hook cursor "$@"; fi + exec node "$BIN" hook cursor "$@" +} + +# 1. Fast path: already vendored. +[ -f "$BIN" ] && run_bin "$@" + +# 2. Self-heal once. The stamp keeps 62 hooks from stampeding the registry when +# the install is genuinely impossible (offline, no bun, no network). +if [ ! -e "$STAMP" ] && command -v bun >/dev/null 2>&1; then + mkdir -p "$CONTROL_ROOT" 2>/dev/null && : > "$STAMP" 2>/dev/null + printf '{"name":"@fusengine/cursor-harness","private":true,"dependencies":{"@fusengine/harness":"%s"}}\n' \ + "$HARNESS_SPEC" > "${CONTROL_ROOT}/package.json" 2>/dev/null + (cd "$CONTROL_ROOT" && bun install --silent) >/dev/null 2>&1 + [ -f "$BIN" ] && run_bin "$@" +fi + +# 3. Last resort: npx on the latest published release. +command -v npx >/dev/null 2>&1 || exit 0 +exec npx -y "@fusengine/harness@${HARNESS_SPEC}" hook cursor "$@" diff --git a/.cursor-plugin/scripts/bun.lock b/.cursor-plugin/scripts/bun.lock new file mode 100644 index 0000000..4d4382f --- /dev/null +++ b/.cursor-plugin/scripts/bun.lock @@ -0,0 +1,36 @@ +{ + "lockfileVersion": 2, + "configVersion": 1, + "workspaces": { + "": { + "name": "@fusengine/hooks-loader", + "dependencies": { + "@clack/prompts": "^1.7.0", + }, + "devDependencies": { + "@types/bun": "^1.3.14", + }, + }, + }, + "packages": { + "@clack/core": ["@clack/core@1.4.3", "", { "dependencies": { "fast-wrap-ansi": "^0.2.0", "sisteransi": "^1.0.5" } }, "sha512-/kr3UWNtdJfxZtPgDqUOmG2pvwlmcLGheex5yiZKdwbzZJxhV+HMNR9QNmyY5cGwTNV6LrR7Jtp+KjhUAP1qBQ=="], + + "@clack/prompts": ["@clack/prompts@1.7.0", "", { "dependencies": { "@clack/core": "1.4.3", "fast-string-width": "^3.0.2", "fast-wrap-ansi": "^0.2.0", "sisteransi": "^1.0.5" } }, "sha512-y7/yvZ2TPAnR9+jnc00klvNNLkJiXFFrQA/hlLCcxA9a2A4zQIOimyFQ9XfwYKiGD1fb5GY8vbKIIgO8d5Tb2A=="], + + "@types/bun": ["@types/bun@1.4.0", "", { "dependencies": { "bun-types": "1.4.0" } }, "sha512-K+lZULY23vRgK/CfTjFIV+tyifaNdSMlPh9j+6mQ/cLfpOznLyAuzgV/JQysyECpkBQLVMSyvjlr2fBUSA9wFQ=="], + + "@types/node": ["@types/node@26.4.0", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-faiGnoIrLH/V8cibOMEAZ8pMw6oXqSukl29ra4mN8GdaB2ZewzeaLj+INpV5N+Z1eKWzY+IzaIZH2EIR6YZRNQ=="], + + "bun-types": ["bun-types@1.4.0", "", { "dependencies": { "@types/node": "*" } }, "sha512-iIKw23BspnQQYd3prITOBxeUsxBHnwzX6YJfGMuNOZzeNcMmVqzIIVGRm1l69ogaPQmb4wB6BN8mA5bE9YuC5Q=="], + + "fast-string-truncated-width": ["fast-string-truncated-width@3.0.3", "", {}, "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g=="], + + "fast-string-width": ["fast-string-width@3.0.2", "", { "dependencies": { "fast-string-truncated-width": "^3.0.2" } }, "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg=="], + + "fast-wrap-ansi": ["fast-wrap-ansi@0.2.2", "", { "dependencies": { "fast-string-width": "^3.0.2" } }, "sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q=="], + + "sisteransi": ["sisteransi@1.0.5", "", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="], + + "undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="], + } +} diff --git a/.cursor-plugin/scripts/ensure-harness-deps.ts b/.cursor-plugin/scripts/ensure-harness-deps.ts new file mode 100644 index 0000000..482d6ed --- /dev/null +++ b/.cursor-plugin/scripts/ensure-harness-deps.ts @@ -0,0 +1,24 @@ +#!/usr/bin/env bun +/** + * ensure-harness-deps.ts - standalone self-heal entry point. + * + * Reinstalls node_modules for scripts/, plugins/ (shared harness), and + * plugins/core-guards/statusline when the marketplace git clone was + * re-checked-out and wiped their gitignored node_modules. Runnable alone + * (manual repair, or wiring as a hook / from /update-harness): + * + * bun scripts/ensure-harness-deps.ts + * + * Import chain is relative-source-only (no third-party package), so it boots + * even when every node_modules under the marketplace clone is gone — this is + * the same constraint `hooks-loader.ts` observes when it calls `ensureDeps` + * in-process instead of spawning this file (zero per-hook spawn overhead). + */ +import { join } from "node:path"; +import { getManagedDepsTargets } from "./src/services/deps-targets"; +import { ensureDeps } from "./src/services/ensure-deps"; + +const HOME = process.env.HOME || process.env.USERPROFILE || ""; +const PLUGINS_DIR = join(HOME, ".cursor/plugins/local"); + +await ensureDeps(getManagedDepsTargets(import.meta.dir, PLUGINS_DIR)); diff --git a/.cursor-plugin/scripts/env-shell/bash-env-loader.sh b/.cursor-plugin/scripts/env-shell/bash-env-loader.sh new file mode 100644 index 0000000..bc7b1e9 --- /dev/null +++ b/.cursor-plugin/scripts/env-shell/bash-env-loader.sh @@ -0,0 +1,33 @@ +# Cursor - BASH_ENV shim: load ~/.cursor/.env, minus the FUSE_* keys. +# Install: copy to ~/.cursor/bash-env-loader.sh (pointed at by BASH_ENV). +# +# SOURCED, never executed: no `exit`, exports land in the current shell. +# Reads the .env on every invocation, so a newly added API key is picked up +# with no resync. FUSE_* are per-harness (FUSE_HARNESS_REFS points at one +# harness' rules tree) and must NEVER leak into another agent's environment. + +if [ -f "$HOME/.cursor/.env" ]; then + while IFS= read -r __fuse_line || [ -n "$__fuse_line" ]; do + __fuse_line="${__fuse_line#"${__fuse_line%%[! ]*}"}" + case "$__fuse_line" in ''|'#'*) continue ;; esac + case "$__fuse_line" in 'export '*) __fuse_line="${__fuse_line#export }" ;; esac + case "$__fuse_line" in *=*) ;; *) continue ;; esac + __fuse_key="${__fuse_line%%=*}" + __fuse_val="${__fuse_line#*=}" + case "$__fuse_key" in + FUSE_*) continue ;; + [!A-Za-z_]*|*[!A-Za-z0-9_]*) continue ;; + esac + case "$__fuse_val" in + '"'*) __fuse_val="${__fuse_val#\"}"; __fuse_val="${__fuse_val%%\"*}" ;; + "'"*) __fuse_val="${__fuse_val#\'}"; __fuse_val="${__fuse_val%%\'*}" ;; + *) + __fuse_val="${__fuse_val%% #*}" + __fuse_val="${__fuse_val%% #*}" + __fuse_val="${__fuse_val%"${__fuse_val##*[! ]}"}" + ;; + esac + export "$__fuse_key=$__fuse_val" + done < "$HOME/.cursor/.env" + unset __fuse_line __fuse_key __fuse_val +fi diff --git a/.cursor-plugin/scripts/env-shell/cursor-env.bash b/.cursor-plugin/scripts/env-shell/cursor-env.bash new file mode 100644 index 0000000..63e3fd0 --- /dev/null +++ b/.cursor-plugin/scripts/env-shell/cursor-env.bash @@ -0,0 +1,33 @@ +#!/bin/bash +# Cursor - Load API keys from ~/.cursor/.env +# Add to ~/.bashrc: source /path/to/cursor-env.bash +# +# Parsed line by line instead of `source`d: FUSE_* keys are per-harness +# (FUSE_HARNESS_REFS points at ONE harness' rules tree) and must never be +# exported globally, or a Codex/Kimi run inherits Claude's SOLID rules. + +if [ -f "$HOME/.cursor/.env" ]; then + while IFS= read -r __fuse_line || [ -n "$__fuse_line" ]; do + __fuse_line="${__fuse_line#"${__fuse_line%%[! ]*}"}" + case "$__fuse_line" in ''|'#'*) continue ;; esac + case "$__fuse_line" in 'export '*) __fuse_line="${__fuse_line#export }" ;; esac + case "$__fuse_line" in *=*) ;; *) continue ;; esac + __fuse_key="${__fuse_line%%=*}" + __fuse_val="${__fuse_line#*=}" + case "$__fuse_key" in + FUSE_*) continue ;; + [!A-Za-z_]*|*[!A-Za-z0-9_]*) continue ;; + esac + case "$__fuse_val" in + '"'*) __fuse_val="${__fuse_val#\"}"; __fuse_val="${__fuse_val%%\"*}" ;; + "'"*) __fuse_val="${__fuse_val#\'}"; __fuse_val="${__fuse_val%%\'*}" ;; + *) + __fuse_val="${__fuse_val%% #*}" + __fuse_val="${__fuse_val%% #*}" + __fuse_val="${__fuse_val%"${__fuse_val##*[! ]}"}" + ;; + esac + export "$__fuse_key=$__fuse_val" + done < "$HOME/.cursor/.env" + unset __fuse_line __fuse_key __fuse_val +fi diff --git a/.cursor-plugin/scripts/env-shell/cursor-env.fish b/.cursor-plugin/scripts/env-shell/cursor-env.fish new file mode 100644 index 0000000..7a02b72 --- /dev/null +++ b/.cursor-plugin/scripts/env-shell/cursor-env.fish @@ -0,0 +1,37 @@ +# Cursor - Load API keys from ~/.cursor/.env +# Install: copy to ~/.config/fish/conf.d/cursor-env.fish +# +# FUSE_* keys are skipped on purpose: they are per-harness +# (FUSE_HARNESS_REFS points at ONE harness' rules tree), so exporting them +# globally makes every other agent read Claude's rules instead of its own. + +if test -f ~/.cursor/.env + while read -l line + set -l entry (string trim -- $line) + if test -z "$entry"; or string match -q '#*' -- $entry + continue + end + set entry (string replace -r '^export\s+' '' -- $entry) + if not string match -qr '^[A-Za-z_][A-Za-z0-9_]*=' -- $entry + continue + end + set -l key (string split -m1 '=' -- $entry)[1] + set -l val (string split -m1 '=' -- $entry)[2] + if string match -q 'FUSE_*' -- $key + continue + end + if string match -q '"*' -- $val + set val (string replace -r '^"([^"]*)".*$' '$1' -- $val) + else if string match -q "'*" -- $val + set val (string replace -r "^'([^']*)'.*\$" '$1' -- $val) + else + set val (string replace -r '\s+#.*$' '' -- $val) + set val (string trim -r -- $val) + end + set -gx $key $val + end <~/.cursor/.env + + # Non-interactive bash (used by Cursor) loads this shim, not the raw + # .env: sourcing the .env directly would re-leak the FUSE_* filtered above. + set -gx BASH_ENV ~/.cursor/bash-env-loader.sh +end diff --git a/.cursor-plugin/scripts/env-shell/cursor-env.ps1 b/.cursor-plugin/scripts/env-shell/cursor-env.ps1 new file mode 100644 index 0000000..20df937 --- /dev/null +++ b/.cursor-plugin/scripts/env-shell/cursor-env.ps1 @@ -0,0 +1,24 @@ +# Cursor - Load API keys from ~/.cursor/.env +# Add to $PROFILE: . /path/to/cursor-env.ps1 +# +# FUSE_* keys are skipped on purpose: they are per-harness +# (FUSE_HARNESS_REFS points at ONE harness' rules tree), so exporting them +# globally makes every other agent read Claude's rules instead of its own. + +$envFile = Join-Path $env:USERPROFILE ".cursor\.env" + +if (Test-Path $envFile) { + foreach ($line in Get-Content $envFile) { + $entry = $line.Trim() + if ($entry -eq "" -or $entry.StartsWith("#")) { continue } + $entry = $entry -replace '^export\s+', '' + if ($entry -notmatch '^([A-Za-z_][A-Za-z0-9_]*)=(.*)$') { continue } + $key = $matches[1] + $val = $matches[2] + if ($key -like "FUSE_*") { continue } + if ($val -match '^"([^"]*)"') { $val = $matches[1] } + elseif ($val -match "^'([^']*)'") { $val = $matches[1] } + else { $val = ($val -replace '\s+#.*$', '').TrimEnd() } + [System.Environment]::SetEnvironmentVariable($key, $val, "Process") + } +} diff --git a/.cursor-plugin/scripts/env-shell/cursor-env.zsh b/.cursor-plugin/scripts/env-shell/cursor-env.zsh new file mode 100644 index 0000000..c9b552c --- /dev/null +++ b/.cursor-plugin/scripts/env-shell/cursor-env.zsh @@ -0,0 +1,33 @@ +#!/bin/zsh +# Cursor - Load API keys from ~/.cursor/.env +# Add to ~/.zshrc: source /path/to/cursor-env.zsh +# +# Parsed line by line instead of `source`d: FUSE_* keys are per-harness +# (FUSE_HARNESS_REFS points at ONE harness' rules tree) and must never be +# exported globally, or a Codex/Kimi run inherits Claude's SOLID rules. + +if [ -f "$HOME/.cursor/.env" ]; then + while IFS= read -r __fuse_line || [ -n "$__fuse_line" ]; do + __fuse_line="${__fuse_line#"${__fuse_line%%[! ]*}"}" + case "$__fuse_line" in ''|'#'*) continue ;; esac + case "$__fuse_line" in 'export '*) __fuse_line="${__fuse_line#export }" ;; esac + case "$__fuse_line" in *=*) ;; *) continue ;; esac + __fuse_key="${__fuse_line%%=*}" + __fuse_val="${__fuse_line#*=}" + case "$__fuse_key" in + FUSE_*) continue ;; + [!A-Za-z_]*|*[!A-Za-z0-9_]*) continue ;; + esac + case "$__fuse_val" in + '"'*) __fuse_val="${__fuse_val#\"}"; __fuse_val="${__fuse_val%%\"*}" ;; + "'"*) __fuse_val="${__fuse_val#\'}"; __fuse_val="${__fuse_val%%\'*}" ;; + *) + __fuse_val="${__fuse_val%% #*}" + __fuse_val="${__fuse_val%% #*}" + __fuse_val="${__fuse_val%"${__fuse_val##*[! ]}"}" + ;; + esac + export "$__fuse_key=$__fuse_val" + done < "$HOME/.cursor/.env" + unset __fuse_line __fuse_key __fuse_val +fi diff --git a/.cursor-plugin/scripts/env-shell/install-env.ps1 b/.cursor-plugin/scripts/env-shell/install-env.ps1 new file mode 100644 index 0000000..7df2177 --- /dev/null +++ b/.cursor-plugin/scripts/env-shell/install-env.ps1 @@ -0,0 +1,68 @@ +# Cursor - Environment Installer for Windows PowerShell +# Run: .\install-env.ps1 + +$ErrorActionPreference = "Stop" + +Write-Host "Cursor - Environment Installer (PowerShell)" -ForegroundColor Blue +Write-Host "─────────────────────────────────────────────────" +Write-Host "" + +$envFile = "$HOME\.cursor\.env" +$profileDir = Split-Path $PROFILE -Parent +$profileFile = $PROFILE + +# Check if .env exists +if (-not (Test-Path $envFile)) { + Write-Host "Warning: $envFile does not exist" -ForegroundColor Yellow + Write-Host "" + Write-Host "Create it with your API keys:" + Write-Host " New-Item -ItemType Directory -Force -Path `"$HOME\.cursor`"" + Write-Host " @'" + Write-Host " export CONTEXT7_API_KEY=`"ctx7sk-xxx`"" + Write-Host " export EXA_API_KEY=`"xxx`"" + Write-Host " export MAGIC_API_KEY=`"xxx`"" + Write-Host " '@ | Set-Content `"$envFile`"" + Write-Host "" +} + +# Create profile directory if needed +if (-not (Test-Path $profileDir)) { + New-Item -ItemType Directory -Force -Path $profileDir | Out-Null + Write-Host " Created: $profileDir" -ForegroundColor Green +} + +# Check if already installed +if ((Test-Path $profileFile) -and (Select-String -Path $profileFile -Pattern "cursor" -Quiet)) { + Write-Host " PowerShell: Already installed" -ForegroundColor Yellow +} else { + # Append loader to profile + $loaderScript = @' + +# Cursor - Load API keys from ~/.cursor/.env (FUSE_* excluded: per-harness) +$cursorEnvFile = "$HOME\.cursor\.env" +if (Test-Path $cursorEnvFile) { + foreach ($line in Get-Content $cursorEnvFile) { + $entry = $line.Trim() + if ($entry -eq "" -or $entry.StartsWith("#")) { continue } + $entry = $entry -replace '^export\s+', '' + if ($entry -notmatch '^([A-Za-z_][A-Za-z0-9_]*)=(.*)$') { continue } + $key = $matches[1] + $val = $matches[2] + if ($key -like "FUSE_*") { continue } + if ($val -match '^"([^"]*)"') { $val = $matches[1] } + elseif ($val -match "^'([^']*)'") { $val = $matches[1] } + else { $val = ($val -replace '\s+#.*$', '').TrimEnd() } + [System.Environment]::SetEnvironmentVariable($key, $val, "Process") + } +} +'@ + Add-Content -Path $profileFile -Value $loaderScript + Write-Host " PowerShell: Installed ($profileFile)" -ForegroundColor Green +} + +Write-Host "" +Write-Host "Done!" -ForegroundColor Green +Write-Host "" +Write-Host "Next steps:" +Write-Host " 1. Ensure $envFile exists with your API keys" +Write-Host " 2. Restart PowerShell or run: . `$PROFILE" diff --git a/.cursor-plugin/scripts/env-shell/install-env.sh b/.cursor-plugin/scripts/env-shell/install-env.sh new file mode 100755 index 0000000..bca0414 --- /dev/null +++ b/.cursor-plugin/scripts/env-shell/install-env.sh @@ -0,0 +1,247 @@ +#!/bin/bash +# Cursor - Auto-detect shell and install environment loader +# Supports: macOS, Linux, Windows (WSL/Git Bash) +# For native Windows PowerShell, run: install-env.ps1 + +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" +ENV_FILE="$HOME/.cursor/.env" +ENV_EXAMPLE="$PROJECT_ROOT/.env.example" + +# Colors +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' + +echo -e "${BLUE}Cursor - Environment Installer${NC}" +echo "────────────────────────────────────" + +# Detect OS +detect_os() { + case "$(uname -s)" in + Darwin*) echo "macos" ;; + Linux*) + if grep -qi microsoft /proc/version 2>/dev/null; then + echo "wsl" + else + echo "linux" + fi + ;; + MINGW*|MSYS*|CYGWIN*) echo "windows-bash" ;; + *) echo "unknown" ;; + esac +} + +# Detect user's default shell +detect_user_shell() { + local os="$1" + + case "$os" in + macos) + dscl . -read "/Users/$USER" UserShell 2>/dev/null | awk '{print $2}' | xargs basename + ;; + linux|wsl) + getent passwd "$USER" 2>/dev/null | cut -d: -f7 | xargs basename || echo "bash" + ;; + windows-bash) + echo "bash" + ;; + *) + echo "unknown" + ;; + esac +} + +# Install for zsh/bash +install_posix_shell() { + local shell="$1" + local rc_file="$2" + + # Create file if not exists + touch "$rc_file" 2>/dev/null || true + + if grep -q "cursor/.env" "$rc_file" 2>/dev/null; then + echo -e " ${YELLOW}$shell: Already installed${NC}" + return 0 + fi + + # Quoted heredoc: the block below is written verbatim, never expanded here. + # Parsed line by line instead of `source`d, so FUSE_* (per-harness keys) + # never leak into a non-Claude agent's environment. + echo "" >> "$rc_file" + cat >> "$rc_file" << 'POSIX_LOADER' +# Cursor - Load API keys from ~/.cursor/.env (FUSE_* excluded: per-harness) +if [ -f "$HOME/.cursor/.env" ]; then + while IFS= read -r __fuse_line || [ -n "$__fuse_line" ]; do + __fuse_line="${__fuse_line#"${__fuse_line%%[! ]*}"}" + case "$__fuse_line" in ''|'#'*) continue ;; esac + case "$__fuse_line" in 'export '*) __fuse_line="${__fuse_line#export }" ;; esac + case "$__fuse_line" in *=*) ;; *) continue ;; esac + __fuse_key="${__fuse_line%%=*}" + __fuse_val="${__fuse_line#*=}" + case "$__fuse_key" in + FUSE_*) continue ;; + [!A-Za-z_]*|*[!A-Za-z0-9_]*) continue ;; + esac + case "$__fuse_val" in + '"'*) __fuse_val="${__fuse_val#\"}"; __fuse_val="${__fuse_val%%\"*}" ;; + "'"*) __fuse_val="${__fuse_val#\'}"; __fuse_val="${__fuse_val%%\'*}" ;; + *) + __fuse_val="${__fuse_val%% #*}" + __fuse_val="${__fuse_val%% #*}" + __fuse_val="${__fuse_val%"${__fuse_val##*[! ]}"}" + ;; + esac + export "$__fuse_key=$__fuse_val" + done < "$HOME/.cursor/.env" + unset __fuse_line __fuse_key __fuse_val +fi +POSIX_LOADER + echo -e " ${GREEN}$shell: Installed ($rc_file)${NC}" +} + +# Install for fish +install_fish() { + local conf_dir="$HOME/.config/fish/conf.d" + local conf_file="$conf_dir/cursor-env.fish" + + mkdir -p "$conf_dir" + + # BASH_ENV shim: non-interactive bash must load the filtered loader, never + # the raw .env (which would re-export the FUSE_* the fish config skips). + mkdir -p "$HOME/.cursor" + cp "$SCRIPT_DIR/bash-env-loader.sh" "$HOME/.cursor/bash-env-loader.sh" + + if [[ -f "$conf_file" ]]; then + echo -e " ${YELLOW}fish: Already installed${NC}" + return 0 + fi + + cp "$SCRIPT_DIR/cursor-env.fish" "$conf_file" + echo -e " ${GREEN}fish: Installed ($conf_file)${NC}" +} + +# Install for PowerShell (Windows/cross-platform) +install_powershell() { + local profile_dir="" + local profile_file="" + + # Detect PowerShell profile location + if [[ -n "$USERPROFILE" ]]; then + # Windows + profile_dir="$USERPROFILE/Documents/PowerShell" + profile_file="$profile_dir/Microsoft.PowerShell_profile.ps1" + else + # macOS/Linux PowerShell Core + profile_dir="$HOME/.config/powershell" + profile_file="$profile_dir/Microsoft.PowerShell_profile.ps1" + fi + + mkdir -p "$profile_dir" 2>/dev/null || true + + if [[ -f "$profile_file" ]] && grep -q "cursor" "$profile_file" 2>/dev/null; then + echo -e " ${YELLOW}powershell: Already installed${NC}" + return 0 + fi + + # Append PowerShell loader + cat >> "$profile_file" << 'PWSH' + +# Cursor - Load API keys from ~/.cursor/.env (FUSE_* excluded: per-harness) +$envFile = "$HOME/.cursor/.env" +if (Test-Path $envFile) { + foreach ($line in Get-Content $envFile) { + $entry = $line.Trim() + if ($entry -eq "" -or $entry.StartsWith("#")) { continue } + $entry = $entry -replace '^export\s+', '' + if ($entry -notmatch '^([A-Za-z_][A-Za-z0-9_]*)=(.*)$') { continue } + $key = $matches[1] + $val = $matches[2] + if ($key -like "FUSE_*") { continue } + if ($val -match '^"([^"]*)"') { $val = $matches[1] } + elseif ($val -match "^'([^']*)'") { $val = $matches[1] } + else { $val = ($val -replace '\s+#.*$', '').TrimEnd() } + [System.Environment]::SetEnvironmentVariable($key, $val, "Process") + } +} +PWSH + echo -e " ${GREEN}powershell: Installed ($profile_file)${NC}" +} + +# Main +echo "" +OS=$(detect_os) +USER_SHELL=$(detect_user_shell "$OS") + +echo -e "OS detected: ${BLUE}$OS${NC}" +echo -e "User's default shell: ${BLUE}$USER_SHELL${NC}" +echo "" + +# Create .env from .env.example if not exists +if [[ ! -f "$ENV_FILE" ]]; then + echo -e "${YELLOW}$ENV_FILE does not exist${NC}" + + if [[ -f "$ENV_EXAMPLE" ]]; then + mkdir -p "$(dirname "$ENV_FILE")" + # Copy and convert VAR=value to export VAR=value + sed 's/^[[:space:]]*\([A-Z_][A-Z0-9_]*\)=/export \1=/g' "$ENV_EXAMPLE" > "$ENV_FILE" + echo -e "${GREEN}Created $ENV_FILE from .env.example${NC}" + echo -e "${YELLOW}Edit it with your API keys!${NC}" + else + echo -e "${RED}.env.example not found at $ENV_EXAMPLE${NC}" + echo "" + echo "Create manually:" + echo " mkdir -p ~/.cursor" + echo " cat > ~/.cursor/.env << 'EOF'" + echo " export CONTEXT7_API_KEY=\"ctx7sk-xxx\"" + echo " export EXA_API_KEY=\"xxx\"" + echo " export MAGIC_API_KEY=\"xxx\"" + echo " EOF" + fi + echo "" +else + echo -e "${GREEN}$ENV_FILE exists${NC}" + echo "" +fi + +# Install ONLY for user's default shell +echo "Installing for default shell ($USER_SHELL)..." +case "$USER_SHELL" in + bash) + install_posix_shell "bash" "$HOME/.bashrc" + ;; + zsh) + install_posix_shell "zsh" "$HOME/.zshrc" + ;; + fish) + install_fish + ;; + pwsh|powershell) + install_powershell + ;; + *) + echo -e " ${RED}Unknown shell: $USER_SHELL${NC}" + echo -e " ${YELLOW}Falling back to bash${NC}" + install_posix_shell "bash" "$HOME/.bashrc" + ;; +esac + + +echo "" +echo -e "${GREEN}Done!${NC}" +echo "" +echo "Shell configured:" +case "$USER_SHELL" in + bash) echo " - bash (~/.bashrc)" ;; + zsh) echo " - zsh (~/.zshrc)" ;; + fish) echo " - fish (~/.config/fish/conf.d/cursor-env.fish)" ;; + pwsh|powershell) echo " - powershell" ;; +esac +echo "" +echo "Next steps:" +echo " 1. Ensure ~/.cursor/.env exists with your API keys" +echo " 2. Restart your terminal or Cursor" diff --git a/.cursor-plugin/scripts/global-artifacts.mjs b/.cursor-plugin/scripts/global-artifacts.mjs deleted file mode 100644 index c5e3345..0000000 --- a/.cursor-plugin/scripts/global-artifacts.mjs +++ /dev/null @@ -1,32 +0,0 @@ -import crypto from "node:crypto"; -import fs from "node:fs"; -import path from "node:path"; - -function visitDirectory(root, relative, hash) { - const directory = path.join(root, relative); - for (const entry of fs.readdirSync(directory, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) { - const childRelative = path.join(relative, entry.name); - const child = path.join(root, childRelative); - if (entry.isSymbolicLink()) throw new Error(`owned artifact contains a symlink: ${child}`); - hash.update(`${childRelative}\0${entry.isDirectory() ? "d" : "f"}\0`); - if (entry.isDirectory()) visitDirectory(root, childRelative, hash); - else if (entry.isFile()) hash.update(fs.readFileSync(child)); - else throw new Error(`unsupported owned artifact type: ${child}`); - } -} - -/** Hash a real directory tree deterministically without following symlinks. */ -export function hashDirectory(root) { - const stat = fs.lstatSync(root); - if (stat.isSymbolicLink() || !stat.isDirectory()) throw new Error(`expected real directory: ${root}`); - const hash = crypto.createHash("sha256"); - visitDirectory(root, "", hash); - return hash.digest("hex"); -} - -/** Hash a real regular file without following symlinks. */ -export function hashFile(file) { - const stat = fs.lstatSync(file); - if (stat.isSymbolicLink() || !stat.isFile()) throw new Error(`expected real file: ${file}`); - return crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex"); -} diff --git a/.cursor-plugin/scripts/global-install-transaction.mjs b/.cursor-plugin/scripts/global-install-transaction.mjs deleted file mode 100644 index a10cb37..0000000 --- a/.cursor-plugin/scripts/global-install-transaction.mjs +++ /dev/null @@ -1,123 +0,0 @@ -import fs from "node:fs"; -import path from "node:path"; -import { restoreFile, writeFileAtomic } from "./project-install-files.mjs"; - -const journalName = ".fusengine-global-transaction.json"; -const snapshotFiles = ["rules/fuse-global.mdc", ".fusengine-global/receipt.json", ".fusengine-global/.managed-by-fusengine"]; - -function transactionPaths(cursorRoot, nonce) { - const pluginsRoot = path.join(cursorRoot, "plugins"); - return { - backup: path.join(pluginsRoot, `.fusengine-local-backup-${nonce}`), - local: path.join(pluginsRoot, "local"), - stage: path.join(pluginsRoot, `.fusengine-local-stage-${nonce}`), - }; -} - -function snapshot(cursorRoot) { - return Object.fromEntries(snapshotFiles.map((relative) => { - const file = path.join(cursorRoot, relative); - return [relative, fs.existsSync(file) ? fs.readFileSync(file).toString("base64") : null]; - })); -} - -function restoreSnapshot(cursorRoot, values) { - for (const relative of snapshotFiles) { - const encoded = values[relative]; - restoreFile(path.join(cursorRoot, relative), encoded === null ? null : Buffer.from(encoded, "base64")); - } -} - -function journalFile(cursorRoot) { - return path.join(cursorRoot, journalName); -} - -function writeJournal(cursorRoot, journal) { - writeFileAtomic(journalFile(cursorRoot), `${JSON.stringify(journal, null, 2)}\n`); -} - -function removeTree(target) { - if (fs.existsSync(target)) fs.rmSync(target, { recursive: true }); -} - -/** Recover an interrupted shared global-local swap before any new staging work. */ -export function recoverGlobalTransaction(cursorRoot) { - const file = journalFile(cursorRoot); - if (!fs.existsSync(file)) return; - const journal = JSON.parse(fs.readFileSync(file, "utf8")); - if (journal.version !== 1 || !/^\d+-\d+$/.test(journal.nonce) || !snapshotFiles.every((name) => Object.hasOwn(journal.snapshot, name))) { - throw new Error(`invalid global transaction journal: ${file}`); - } - const roots = transactionPaths(cursorRoot, journal.nonce); - if (journal.phase === "prepared") { - if (journal.hadLocal && !fs.existsSync(roots.local) && fs.existsSync(roots.backup)) { - fs.renameSync(roots.backup, roots.local); - } else if (journal.hadLocal && fs.existsSync(roots.local) && fs.existsSync(roots.backup) && !fs.existsSync(roots.stage)) { - removeTree(roots.local); - fs.renameSync(roots.backup, roots.local); - restoreSnapshot(cursorRoot, journal.snapshot); - } - removeTree(roots.stage); - } - else if (journal.phase === "old-moved") { - if (journal.hadLocal && fs.existsSync(roots.local) && fs.existsSync(roots.backup)) { - removeTree(roots.local); - fs.renameSync(roots.backup, roots.local); - restoreSnapshot(cursorRoot, journal.snapshot); - } else if (journal.hadLocal && !fs.existsSync(roots.local) && fs.existsSync(roots.backup)) { - fs.renameSync(roots.backup, roots.local); - } else if (!journal.hadLocal && fs.existsSync(roots.local) && !fs.existsSync(roots.stage)) { - removeTree(roots.local); - restoreSnapshot(cursorRoot, journal.snapshot); - } - removeTree(roots.stage); - } else if (journal.phase === "new-moved") { - removeTree(roots.local); - if (journal.hadLocal && fs.existsSync(roots.backup)) fs.renameSync(roots.backup, roots.local); - restoreSnapshot(cursorRoot, journal.snapshot); - } else if (journal.phase !== "finalized") throw new Error(`invalid global transaction phase: ${journal.phase}`); - removeTree(roots.backup); - removeTree(roots.stage); - fs.rmSync(file); -} - -function inject(point) { - if (process.env.NODE_ENV === "test" && process.env.FUSE_GLOBAL_TEST_FAIL_POINT === point) { - throw new Error(`injected global transaction failure at ${point}`); - } -} - -/** Atomically swap the shared local-plugin snapshot and finalize global files with crash recovery. */ -export function commitGlobalSnapshot({ cursorRoot, stageRoot, finalFiles }) { - recoverGlobalTransaction(cursorRoot); - const match = path.basename(stageRoot).match(/^\.fusengine-local-stage-(\d+-\d+)$/); - if (!match) throw new Error(`invalid global stage path: ${stageRoot}`); - const nonce = match[1]; - const roots = transactionPaths(cursorRoot, nonce); - const journal = { version: 1, nonce, phase: "prepared", hadLocal: fs.existsSync(roots.local), snapshot: snapshot(cursorRoot) }; - writeJournal(cursorRoot, journal); - try { - inject("prepared"); - if (journal.hadLocal) fs.renameSync(roots.local, roots.backup); - inject("after-old-rename-before-journal"); - journal.phase = "old-moved"; - writeJournal(cursorRoot, journal); - inject("after-old-move"); - fs.renameSync(stageRoot, roots.local); - inject("after-new-rename-before-journal"); - journal.phase = "new-moved"; - writeJournal(cursorRoot, journal); - inject("after-new-move"); - for (const item of finalFiles) { - if (item.content === null) restoreFile(item.path, null); - else writeFileAtomic(item.path, item.content); - } - journal.phase = "finalized"; - writeJournal(cursorRoot, journal); - inject("after-finalize"); - } catch (error) { - recoverGlobalTransaction(cursorRoot); - throw error; - } - recoverGlobalTransaction(cursorRoot); -} diff --git a/.cursor-plugin/scripts/global-install.mjs b/.cursor-plugin/scripts/global-install.mjs deleted file mode 100644 index a2a020b..0000000 --- a/.cursor-plugin/scripts/global-install.mjs +++ /dev/null @@ -1,160 +0,0 @@ -#!/usr/bin/env node -/** User-global Cursor plugin installer. All writes stay below the resolved user home. */ -import fs from "node:fs"; -import path from "node:path"; -import { fileURLToPath } from "node:url"; -import { hashDirectory, hashFile } from "./global-artifacts.mjs"; -import { commitGlobalSnapshot, recoverGlobalTransaction } from "./global-install-transaction.mjs"; -import { resolveMarketplacePlugins } from "./marketplace-sources.mjs"; -import { assertNotSymlink, withProjectLock } from "./project-install-files.mjs"; - -const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); -const sourceRoot = process.env.NODE_ENV === "test" && process.env.FUSE_INSTALL_TEST_SOURCE_ROOT - ? path.resolve(process.env.FUSE_INSTALL_TEST_SOURCE_ROOT) - : repositoryRoot; -const homeInput = process.env.HOME || process.env.USERPROFILE; -if (!homeInput || !fs.existsSync(homeInput)) throw new Error("HOME or USERPROFILE must reference an existing directory"); -const homeRoot = fs.realpathSync(homeInput); -const cursorRoot = path.join(homeRoot, ".cursor"); -const pluginsRoot = path.join(cursorRoot, "plugins"); -const localRoot = path.join(pluginsRoot, "local"); -const rulesRoot = path.join(cursorRoot, "rules"); -const rulePath = path.join(rulesRoot, "fuse-global.mdc"); -const controlRoot = path.join(cursorRoot, ".fusengine-global"); -const receiptPath = path.join(controlRoot, "receipt.json"); -const controlMarker = path.join(controlRoot, ".managed-by-fusengine"); -const markerContent = "fusengine cursor global installation\n"; -const options = { dryRun: false, uninstall: false }; - -for (const argument of process.argv.slice(2)) { - if (argument === "--dry-run") options.dryRun = true; - else if (argument === "--uninstall") options.uninstall = true; - else if (argument === "--help" || argument === "-h") { - process.stdout.write("Usage: install.sh [--dry-run] [--uninstall]\n install.sh --project [--dry-run] [--uninstall]\n"); - process.exit(0); - } else throw new Error(`unknown global option: ${argument}`); -} - -function readJson(file) { - return JSON.parse(fs.readFileSync(file, "utf8")); -} - -function validatePathChain(pluginNames = []) { - for (const target of [cursorRoot, pluginsRoot, localRoot, rulesRoot, rulePath, controlRoot, receiptPath, controlMarker]) { - assertNotSymlink(target); - } - for (const name of pluginNames) assertNotSymlink(path.join(localRoot, name)); -} - -function readReceipt() { - if (!fs.existsSync(controlRoot)) return null; - if (!fs.existsSync(receiptPath) || !fs.existsSync(controlMarker)) throw new Error(`refusing unowned global control directory: ${controlRoot}`); - if (fs.readFileSync(controlMarker, "utf8") !== markerContent) throw new Error(`invalid global ownership marker: ${controlMarker}`); - const receipt = readJson(receiptPath); - if (receipt.version !== 1 || !receipt.plugins || typeof receipt.plugins !== "object") throw new Error(`invalid global receipt: ${receiptPath}`); - return receipt; -} - -function inventory() { - const market = readJson(path.join(sourceRoot, ".cursor-plugin", "marketplace.json")); - if (!Array.isArray(market.plugins) || market.plugins.length !== 24) throw new Error("global install requires exactly 24 marketplace plugins"); - return resolveMarketplacePlugins(sourceRoot, market); -} - -function preflightOwned(receipt, plugins) { - for (const plugin of plugins) { - const target = path.join(localRoot, plugin.name); - if (!fs.existsSync(target)) continue; - const priorHash = receipt?.plugins?.[plugin.name]; - if (!priorHash) throw new Error(`refusing pre-existing global plugin: ${target}`); - if (hashDirectory(target) !== priorHash) throw new Error(`refusing to overwrite modified owned plugin: ${target}`); - } - if (fs.existsSync(rulePath)) { - if (!receipt?.ruleHash) throw new Error(`refusing pre-existing global rule: ${rulePath}`); - if (hashFile(rulePath) !== receipt.ruleHash) throw new Error(`refusing to overwrite modified owned rule: ${rulePath}`); - } -} - -function stageLocal(nonce) { - const stage = path.join(pluginsRoot, `.fusengine-local-stage-${nonce}`); - fs.mkdirSync(pluginsRoot, { recursive: true }); - if (fs.existsSync(localRoot)) fs.cpSync(localRoot, stage, { recursive: true }); - else fs.mkdirSync(stage); - return stage; -} - -function installGlobal() { - recoverGlobalTransaction(cursorRoot); - const plugins = inventory(); - const rulesPlugin = plugins.find((plugin) => plugin.name === "fuse-rules"); - if (!rulesPlugin) throw new Error("global install requires fuse-rules"); - const ruleSource = path.join(rulesPlugin.source, "user-rules", "fuse-global.mdc"); - validatePathChain(plugins.map((plugin) => plugin.name)); - const previous = readReceipt(); - preflightOwned(previous, plugins); - const rule = fs.readFileSync(ruleSource); - if (options.dryRun) { - process.stdout.write(`Would install 24 plugins under ${localRoot}\nWould write ${rulePath}\nDry run: nothing written.\n`); - return; - } - const nonce = `${process.pid}-${Date.now()}`; - const stage = stageLocal(nonce); - try { - const hashes = {}; - for (const plugin of plugins) { - const target = path.join(stage, plugin.name); - if (fs.existsSync(target)) fs.rmSync(target, { recursive: true }); - fs.cpSync(plugin.source, target, { recursive: true }); - hashes[plugin.name] = hashDirectory(target); - } - const receipt = Buffer.from(`${JSON.stringify({ version: 1, plugins: hashes, ruleHash: hashFile(ruleSource) }, null, 2)}\n`); - commitGlobalSnapshot({ cursorRoot, stageRoot: stage, finalFiles: [ - { path: rulePath, content: rule }, { path: receiptPath, content: receipt }, { path: controlMarker, content: markerContent }, - ] }); - } catch (error) { - if (fs.existsSync(stage)) fs.rmSync(stage, { recursive: true }); - throw error; - } - process.stdout.write(`Installed 24 user-global plugins in ${localRoot}\nReload the Cursor window to activate them.\n`); -} - -function uninstallGlobal() { - recoverGlobalTransaction(cursorRoot); - validatePathChain(); - const receipt = readReceipt(); - if (!receipt) throw new Error(`no managed global installation at ${controlRoot}`); - if (options.dryRun) { - process.stdout.write(`Would remove only intact Fusengine global plugins and rule under ${cursorRoot}\nDry run: nothing written.\n`); - return; - } - const nonce = `${process.pid}-${Date.now()}`; - const stage = stageLocal(nonce); - try { - for (const [name, expectedHash] of Object.entries(receipt.plugins)) { - if (path.basename(name) !== name) throw new Error(`unsafe receipt plugin name: ${name}`); - const active = path.join(localRoot, name); - if (!fs.existsSync(active)) continue; - if (hashDirectory(active) === expectedHash) fs.rmSync(path.join(stage, name), { recursive: true }); - else process.stderr.write(`warning: preserved modified global plugin ${active}\n`); - } - const removeRule = fs.existsSync(rulePath) && hashFile(rulePath) === receipt.ruleHash; - if (fs.existsSync(rulePath) && !removeRule) process.stderr.write(`warning: preserved modified global rule ${rulePath}\n`); - const finalFiles = [{ path: receiptPath, content: null }, { path: controlMarker, content: null }]; - if (removeRule) finalFiles.push({ path: rulePath, content: null }); - commitGlobalSnapshot({ cursorRoot, stageRoot: stage, finalFiles }); - try { fs.rmdirSync(controlRoot); } catch { /* preserved if non-empty */ } - } catch (error) { - if (fs.existsSync(stage)) fs.rmSync(stage, { recursive: true }); - throw error; - } - process.stdout.write(`Uninstalled intact user-global Fusengine artifacts from ${cursorRoot}\n`); -} - -try { - validatePathChain(); - if (options.dryRun) (options.uninstall ? uninstallGlobal : installGlobal)(); - else withProjectLock(cursorRoot, options.uninstall ? uninstallGlobal : installGlobal); -} catch (error) { - process.stderr.write(`error: ${error.message}\n`); - process.exit(1); -} diff --git a/.cursor-plugin/scripts/hooks-loader.ts b/.cursor-plugin/scripts/hooks-loader.ts new file mode 100755 index 0000000..3afb28d --- /dev/null +++ b/.cursor-plugin/scripts/hooks-loader.ts @@ -0,0 +1,95 @@ +#!/usr/bin/env bun +/** + * hooks-loader.ts - Hook dispatcher entry point + * Delegates to services for scanning and execution. + * + * @description Cursor invokes this as `bun ` (per `~/.cursor/hooks.json`), + * piping the event's JSON payload on stdin and reading Cursor's per-event response shape + * back from stdout. Input/output here follow Cursor's contract, never Claude Code's. + * @see https://cursor.com/docs/hooks + */ +import { join } from "node:path"; +import type { HookInput } from "./src/interfaces/hooks"; +import { getManagedDepsTargets } from "./src/services/deps-targets"; +import { ensureDeps } from "./src/services/ensure-deps"; +import { buildCursorResponse, executeHooks } from "./src/services/hook-executor"; +import { extractHooks, scanPlugins } from "./src/services/plugin-scanner"; + +const HOME = process.env.HOME || process.env.USERPROFILE || ""; +const PLUGINS_DIR = join( + HOME, + ".cursor/plugins/local", +); + +/** + * Cursor-only input field, absent from the (Claude Code-shaped) `HookInput` interface. + * Extended locally instead of editing `src/interfaces/hooks.ts`, which is out of this + * file's exclusive ownership. + * @see https://cursor.com/docs/hooks — `subagentStart`/`subagentStop` payloads carry + * `subagent_type`; Claude Code's `agent_type` does not exist in Cursor's contract. + */ +type CursorHookInput = HookInput & { subagent_type?: string }; + +async function main(): Promise { + const hookType = process.argv[2]; + if (!hookType) process.exit(0); + + // Self-heal FIRST: a marketplace re-checkout wipes gitignored node_modules + // (scripts/, plugins/ shared harness, plugins/core-guards/statusline). + // Every import above is builtins/relative-source-only (verified: hook-executor + // and plugin-scanner pull zero third-party packages), so this is safe to run + // before any hook-scanning logic — no third-party import can crash first. + await ensureDeps(getManagedDepsTargets(import.meta.dir, PLUGINS_DIR)); + + // Read input (may be empty for sessionStart, stop, etc.) + const rawInput = await Bun.stdin.text(); + + // Parse input or use empty object + let input: CursorHookInput = {}; + if (rawInput.trim()) { + try { + input = JSON.parse(rawInput); + } catch { + // Continue with empty input + } + } + + const toolName = input.tool_name ?? ""; + const agentType = input.subagent_type ?? ""; + + // DEBUG: Log subagentStart/subagentStop payload + if (hookType === "subagentStart" || hookType === "subagentStop") { + const { mkdirSync, appendFileSync } = await import("node:fs"); + const debugDir = join(HOME, ".cursor/fusengine-cache"); + mkdirSync(debugDir, { recursive: true }); + appendFileSync(join(debugDir, "subagent-debug.log"), + `${new Date().toISOString()} ${hookType} subagent_type="${agentType}" raw=${JSON.stringify(input)}\n`); + } + + // Scan plugins + const plugins = scanPlugins({ pluginsDir: PLUGINS_DIR }); + + // Extract matching hooks. Cursor has no "notification" event (Claude Code-only), + // so that matcher branch in extractHooks is unreachable here — pass "" (dead arg). + const hooks = extractHooks(plugins, hookType, toolName, "", agentType); + + // No matching hooks → exit early + if (hooks.length === 0) process.exit(0); + + // Execute hooks (pass rawInput or empty JSON) + const result = await executeHooks(hooks, rawInput || "{}"); + + // Hard block: exit code 2 from any hook == `permission: "deny"` in Cursor too. + if (result.blocked) { + console.error(result.stderr); + process.exit(2); + } + + // Translate to Cursor's per-event response shape (never Claude Code's + // `hookSpecificOutput`/`systemMessage`). Empty string → print nothing, which + // is correct for events with no documented response schema (e.g. `afterFileEdit`). + const stdout = buildCursorResponse(hookType, result.outcome, result.stderr); + if (stdout) console.log(stdout); +} + +main().catch(() => process.exit(0)); diff --git a/.cursor-plugin/scripts/install-hooks.ts b/.cursor-plugin/scripts/install-hooks.ts new file mode 100755 index 0000000..f574f93 --- /dev/null +++ b/.cursor-plugin/scripts/install-hooks.ts @@ -0,0 +1,71 @@ +#!/usr/bin/env bun +/** + * install-hooks.ts - Single entry point for the Fusengine Cursor installer. + * @description Parses --project/--dry-run/--uninstall/--skip-env, spawns the + * matching TS deployment engine (global-install.ts or project-install.ts, + * transactional deploy with rollback), then chains the environment + * configuration stage (hooks, MCP, shell loaders, vendored harness) unless + * the requested mode makes it moot. + */ +import { dirname, join } from "node:path"; +import * as p from "@clack/prompts"; +import { runSetup } from "./src/services/setup-runner"; + +const HOME = process.env.HOME || process.env.USERPROFILE || ""; +const SCRIPT_DIR = dirname(import.meta.path); +const PROJECT_ROOT = dirname(SCRIPT_DIR); + +/** + * Cursor deploys plugins flat under `~/.cursor/plugins/local/`, with no + * intermediate `plugins/` directory — unlike Claude Code's marketplace layout. + * `marketplace` therefore points one level up, so the setup runner's + * `join(marketplace, "plugins")` resolves to that same flat directory. + */ +const PATHS = { + settings: join(HOME, ".cursor/.fusengine-global/settings.json"), + marketplace: join(HOME, ".cursor"), + pluginsDir: join(HOME, ".cursor/plugins/local"), + loaderSrc: join(SCRIPT_DIR, "hooks-loader.ts"), + cursorMdSrc: join( + PROJECT_ROOT, + "plugins/fuse-rules/templates/CLAUDE.md.template", + ), + // Cursor recognises AGENTS.md only at the ROOT OF A WORKSPACE FOLDER (verified + // in Cursor 3.18.25: `getWorkspaceFolder(uri)` returns false outside one), so a + // file under ~/.cursor/ would never be read. The global instructions channel is + // the user rules directory — `getRuleTargetDirectory` resolves to + // joinPath(userHome, ".cursor", "rules") — hence a .mdc there. + cursorMdDest: join(HOME, ".cursor/rules/fuse-agents.mdc"), +}; + +/** + * Spawn the deployment engine matching `argv` (global by default, project + * when `--project ` is present), forwarding stdio and its exit code. + * `--skip-env` is a configuration-stage-only flag: the deploy engines never + * saw it even in the two-process era, so it is stripped before forwarding. + */ +function runDeployEngine(argv: string[]): void { + const isProject = argv.includes("--project"); + const engine = join(SCRIPT_DIR, "src/services", isProject ? "project-install.ts" : "global-install.ts"); + const deployArgs = argv.filter((arg) => arg !== "--skip-env"); + const result = Bun.spawnSync({ + cmd: [process.execPath, engine, ...deployArgs], + stdio: ["inherit", "inherit", "inherit"], + }); + if (!result.success) process.exit(result.exitCode); +} + +async function main(): Promise { + const argv = process.argv.slice(2); + runDeployEngine(argv); + // The configuration stage works on PATHS, which are all global (~/.cursor). + // `--project` deploys into /.cursor and wires its own hooks, so it + // must not fall through to the global setup wizard. + if (argv.includes("--dry-run") || argv.includes("--uninstall") || argv.includes("--project")) return; + await runSetup(PATHS, argv.includes("--skip-env")); +} + +main().catch((e) => { + p.log.error(e.message); + process.exit(1); +}); diff --git a/.cursor-plugin/scripts/install.mjs b/.cursor-plugin/scripts/install.mjs deleted file mode 100644 index 962e0f5..0000000 --- a/.cursor-plugin/scripts/install.mjs +++ /dev/null @@ -1,4 +0,0 @@ -#!/usr/bin/env node -/** Dispatch the default global installer or the explicit project installer. */ -const target = process.argv.slice(2).includes("--project") ? "./project-install.mjs" : "./global-install.mjs"; -await import(target); diff --git a/.cursor-plugin/scripts/load-project-plugins.ts b/.cursor-plugin/scripts/load-project-plugins.ts new file mode 100755 index 0000000..5932aff --- /dev/null +++ b/.cursor-plugin/scripts/load-project-plugins.ts @@ -0,0 +1,15 @@ +#!/usr/bin/env bun +/** + * load-project-plugins.ts - Cursor workspaceOpen loader entry point (dev source) + * @description The installed copy of this script runs in a target project as plain + * `node .cursor/fusengine/load-plugins.mjs` (see src/services/project-verify.ts's regex and + * project-install-transaction.mjs's buildStage), with zero dependencies and no Bun + * requirement on that machine. This file is the typed dev-source mirror of that same + * contract: validate argv, then print `{"pluginPaths":[...]}` on stdout — preserve exactly, + * a Cursor hook consumes it. + */ +import { loadProjectPlugins, validateLoaderArguments } from "./src/services/load-project-plugins"; + +const loaderArguments = process.argv.slice(2); +validateLoaderArguments(loaderArguments); +process.stdout.write(`${JSON.stringify(loadProjectPlugins(import.meta.dir, process.cwd()))}\n`); diff --git a/.cursor-plugin/scripts/mcp/mcp.json b/.cursor-plugin/scripts/mcp/mcp.json new file mode 100644 index 0000000..b90ed8e --- /dev/null +++ b/.cursor-plugin/scripts/mcp/mcp.json @@ -0,0 +1,235 @@ +{ + "mcpServers": { + "_comment": "MCP Servers catalog for Fusengine Plugins installer - Verified packages only", + + "sequential-thinking": { + "_description": "Dynamic problem-solving with step-by-step reasoning", + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-sequential-thinking"], + "type": "stdio", + "requiresApiKey": false + }, + "memory": { + "_description": "Knowledge graph-based persistent memory system", + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-memory"], + "type": "stdio", + "requiresApiKey": false + }, + "filesystem": { + "_description": "Secure local file operations with configurable access", + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-filesystem", "${userHome}"], + "type": "stdio", + "requiresApiKey": false, + "default": false + }, + "fuse-browser": { + "_description": "Stealth browser automation: snapshots, multi-step plans, themed (light/dark) screenshots", + "command": "npx", + "args": ["-y", "@fusengine/browser-mcp@latest"], + "type": "stdio", + "requiresApiKey": false + }, + "postgres": { + "_description": "PostgreSQL database queries via natural language", + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-postgres"], + "type": "stdio", + "requiresApiKey": false, + "default": false, + "env": { + "DATABASE_URL": "${env:DATABASE_URL}" + } + }, + "shadcn": { + "_description": "shadcn/ui component registry and installation", + "command": "npx", + "args": ["shadcn@latest", "mcp"], + "type": "stdio", + "requiresApiKey": false + }, + "next-devtools": { + "_description": "Next.js development tools and debugging", + "command": "npx", + "args": ["-y", "next-devtools-mcp@latest"], + "type": "stdio", + "requiresApiKey": false + }, + "XcodeBuildMCP": { + "_description": "Xcode build, test and simulator automation for iOS/macOS", + "command": "npx", + "args": ["-y", "xcodebuildmcp@latest", "mcp"], + "type": "stdio", + "requiresApiKey": false + }, + "apple-docs": { + "_description": "Apple developer documentation and WWDC content", + "command": "npx", + "args": ["-y", "@kimsungwhee/apple-docs-mcp"], + "type": "stdio", + "requiresApiKey": false + }, + "astro-docs": { + "_description": "Search official Astro framework documentation", + "type": "http", + "url": "https://mcp.docs.astro.build/mcp", + "requiresApiKey": false + }, + + "context7": { + "_description": "Up-to-date documentation lookup to reduce AI hallucinations", + "command": "npx", + "args": [ + "-y", + "@upstash/context7-mcp", + "--api-key", + "${env:CONTEXT7_API_KEY}" + ], + "type": "stdio", + "default": true, + "requiresApiKey": true, + "apiKeyEnv": "CONTEXT7_API_KEY", + "apiKeyUrl": "https://context7.com" + }, + "exa": { + "_description": "AI-powered web search, code context and deep research", + "default": true, + "type": "http", + "url": "https://mcp.exa.ai/mcp?tools=web_search_exa,get_code_context_exa,crawling_exa,company_research_exa,linkedin_search_exa,deep_researcher_start,deep_researcher_check&exaApiKey=${env:EXA_API_KEY}", + "requiresApiKey": true, + "apiKeyEnv": "EXA_API_KEY", + "apiKeyUrl": "https://exa.ai" + }, + "magic": { + "_description": "AI-powered UI component generation from 21st.dev", + "command": "npx", + "args": ["-y", "@21st-dev/magic@latest"], + "env": { + "MAGIC_API_KEY": "${env:MAGIC_API_KEY}" + }, + "type": "stdio", + "default": true, + "requiresApiKey": true, + "apiKeyEnv": "MAGIC_API_KEY", + "apiKeyUrl": "https://21st.dev" + }, + "gemini-design": { + "_description": "AI frontend generation with Tailwind CSS and shadcn/ui", + "command": "npx", + "args": ["-y", "gemini-design-mcp@latest"], + "env": { + "API_KEY": "${env:GEMINI_DESIGN_API_KEY}" + }, + "type": "stdio", + "default": true, + "requiresApiKey": true, + "apiKeyEnv": "GEMINI_DESIGN_API_KEY", + "apiKeyUrl": "https://gemini-design-mcp.com" + }, + "github": { + "_description": "GitHub repos, PRs, issues, commits and code review", + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-github"], + "env": { + "GITHUB_TOKEN": "${env:GITHUB_TOKEN}" + }, + "type": "stdio", + "requiresApiKey": true, + "apiKeyEnv": "GITHUB_TOKEN", + "apiKeyUrl": "https://github.com/settings/tokens" + }, + "supabase": { + "_description": "Supabase database, auth, storage and migrations", + "command": "npx", + "args": ["-y", "supabase", "mcp"], + "env": { + "SUPABASE_ACCESS_TOKEN": "${env:SUPABASE_ACCESS_TOKEN}" + }, + "type": "stdio", + "requiresApiKey": true, + "apiKeyEnv": "SUPABASE_ACCESS_TOKEN", + "apiKeyUrl": "https://supabase.com/dashboard/account/tokens" + }, + "slack": { + "_description": "Slack messages, channels and workspace management", + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-slack"], + "env": { + "SLACK_TOKEN": "${env:SLACK_TOKEN}" + }, + "type": "stdio", + "requiresApiKey": true, + "apiKeyEnv": "SLACK_TOKEN", + "apiKeyUrl": "https://api.slack.com/apps" + }, + "sentry": { + "_description": "Sentry error tracking, issues and debugging", + "command": "npx", + "args": ["-y", "@sentry/mcp-server"], + "env": { + "SENTRY_AUTH_TOKEN": "${env:SENTRY_AUTH_TOKEN}" + }, + "type": "stdio", + "requiresApiKey": true, + "apiKeyEnv": "SENTRY_AUTH_TOKEN", + "apiKeyUrl": "https://sentry.io/settings/auth-tokens" + }, + "brave-search": { + "_description": "Private web search via Brave Search API", + "command": "npx", + "args": ["-y", "@brave/brave-search-mcp-server"], + "env": { + "BRAVE_API_KEY": "${env:BRAVE_API_KEY}" + }, + "type": "stdio", + "requiresApiKey": true, + "apiKeyEnv": "BRAVE_API_KEY", + "apiKeyUrl": "https://brave.com/search/api" + }, + "serper": { + "_description": "Google search (SERP) and webpage scraping via Serper API", + "command": "npx", + "args": ["-y", "serper-search-scrape-mcp-server"], + "env": { + "SERPER_API_KEY": "${env:SERPER_API_KEY}" + }, + "type": "stdio", + "requiresApiKey": true, + "apiKeyEnv": "SERPER_API_KEY", + "apiKeyUrl": "https://serper.dev/api-key" + }, + "stripe": { + "_description": "Stripe payments, invoices, customers and subscriptions", + "command": "npx", + "args": ["-y", "@stripe/mcp"], + "env": { + "STRIPE_SECRET_KEY": "${env:STRIPE_SECRET_KEY}" + }, + "type": "stdio", + "requiresApiKey": true, + "apiKeyEnv": "STRIPE_SECRET_KEY", + "apiKeyUrl": "https://dashboard.stripe.com/apikeys" + }, + "notion": { + "_description": "Notion pages, databases and knowledge base access", + "command": "npx", + "args": ["-y", "@notionhq/notion-mcp-server"], + "env": { + "NOTION_TOKEN": "${env:NOTION_TOKEN}" + }, + "type": "stdio", + "requiresApiKey": true, + "apiKeyEnv": "NOTION_TOKEN", + "apiKeyUrl": "https://www.notion.so/my-integrations" + }, + "replicate": { + "_description": "Run 1000+ AI models (Stable Diffusion, Whisper, etc.)", + "type": "http", + "url": "https://replicate.com/mcp?token=${env:REPLICATE_API_TOKEN}", + "requiresApiKey": true, + "apiKeyEnv": "REPLICATE_API_TOKEN", + "apiKeyUrl": "https://replicate.com/account/api-tokens" + } + } +} diff --git a/.cursor-plugin/scripts/package.json b/.cursor-plugin/scripts/package.json new file mode 100644 index 0000000..39f7e5a --- /dev/null +++ b/.cursor-plugin/scripts/package.json @@ -0,0 +1,21 @@ +{ + "name": "@fusengine/hooks-loader", + "version": "2.0.0", + "devDependencies": { + "@types/bun": "^1.3.14" + }, + "description": "Hooks loader for Cursor plugins - Bun + SOLID", + "engines": { + "bun": ">=1.0.0" + }, + "scripts": { + "install-hooks": "bun install-hooks.ts", + "typecheck": "bunx tsc --noEmit", + "test": "bun test", + "test:watch": "bun test --watch" + }, + "type": "module", + "dependencies": { + "@clack/prompts": "^1.7.0" + } +} diff --git a/.cursor-plugin/scripts/project-hooks.mjs b/.cursor-plugin/scripts/project-hooks.mjs deleted file mode 100644 index fa57dfa..0000000 --- a/.cursor-plugin/scripts/project-hooks.mjs +++ /dev/null @@ -1,56 +0,0 @@ -/** Project-relative Cursor loader command prefix owned by this installer. */ -export const baseCommand = "node .cursor/fusengine/load-plugins.mjs"; - -/** Build the uniquely owned loader command persisted in the install receipt. */ -export function loaderCommand(token) { - return `${baseCommand} --fusengine-owner=${token}`; -} - -function isMatching(entry) { - return entry && !Array.isArray(entry) && typeof entry === "object" - && (entry.command === baseCommand || new RegExp(`^${baseCommand.replaceAll(".", "\\.")} --fusengine-owner=[a-f0-9]{16}$`).test(entry.command)); -} - -function isExact(entry, command) { - return isMatching(entry) && entry.command === command && Object.keys(entry).length === 1; -} - -/** Merge a loader entry only when no matching foreign entry already supplies discovery. */ -export function mergeLoaderHook(document, previousReceipt, candidateToken) { - const entries = document.hooks.workspaceOpen; - if (previousReceipt.hookAdded === true && /^[a-f0-9]{16}$/.test(previousReceipt.hookToken ?? "")) { - const command = loaderCommand(previousReceipt.hookToken); - let hookIndex = previousReceipt.hookIndex; - if (!Number.isInteger(hookIndex) || !isExact(entries[hookIndex], command)) { - const indexes = entries.flatMap((entry, index) => isExact(entry, command) ? [index] : []); - hookIndex = indexes.length === 1 ? indexes[0] : -1; - } - if (hookIndex < 0) { - hookIndex = entries.length; - entries.push({ command }); - } - return { document, hookAdded: true, hookCommand: command, hookIndex, hookToken: previousReceipt.hookToken }; - } - if (entries.some(isMatching)) return { document, hookAdded: false, hookCommand: baseCommand, hookIndex: -1, hookToken: null }; - const command = loaderCommand(candidateToken); - const hookIndex = entries.length; - entries.push({ command }); - return { document, hookAdded: true, hookCommand: command, hookIndex, hookToken: candidateToken }; -} - -/** Remove at most one exact canonical entry, and only when the receipt says we added it. */ -export function removeLoaderHook(document, receipt) { - if (receipt.hookAdded !== true || !/^[a-f0-9]{16}$/.test(receipt.hookToken ?? "")) return false; - const entries = document.hooks?.workspaceOpen; - if (!Array.isArray(entries)) return false; - const command = loaderCommand(receipt.hookToken); - let index = receipt.hookIndex; - if (!Number.isInteger(index) || !isExact(entries[index], command)) { - const indexes = entries.flatMap((entry, entryIndex) => isExact(entry, command) ? [entryIndex] : []); - if (indexes.length !== 1) throw new Error("owned loader hook occurrence is missing or ambiguous"); - [index] = indexes; - } - entries.splice(index, 1); - if (entries.length === 0) delete document.hooks.workspaceOpen; - return true; -} diff --git a/.cursor-plugin/scripts/project-install-files.d.mts b/.cursor-plugin/scripts/project-install-files.d.mts new file mode 100644 index 0000000..f094590 --- /dev/null +++ b/.cursor-plugin/scripts/project-install-files.d.mts @@ -0,0 +1,9 @@ +/** + * Ambient typing for the untyped project-install-files.mjs module (owned by + * another agent's lot). Narrows only the exports consumed by the global-* + * services; delete once that module is ported to TypeScript. + */ +export function assertNotSymlink(file: string): void; +export function writeFileAtomic(file: string, content: Buffer | string): void; +export function restoreFile(file: string, content: Buffer | string | null): void; +export function withProjectLock(cursorRoot: string, action: () => T): T; diff --git a/.cursor-plugin/scripts/project-install-files.mjs b/.cursor-plugin/scripts/project-install-files.mjs deleted file mode 100644 index 4b35d03..0000000 --- a/.cursor-plugin/scripts/project-install-files.mjs +++ /dev/null @@ -1,54 +0,0 @@ -import fs from "node:fs"; -import path from "node:path"; - -/** Reject existing or dangling symlinks before a write-capable operation. */ -export function assertNotSymlink(file) { - let stat; - try { stat = fs.lstatSync(file); } - catch (error) { - if (error.code === "ENOENT") return; - throw error; - } - if (stat.isSymbolicLink()) throw new Error(`refusing to write through symlink: ${file}`); -} - -/** Replace one file atomically on its current filesystem. */ -export function writeFileAtomic(file, content) { - fs.mkdirSync(path.dirname(file), { recursive: true }); - const temporary = `${file}.fusengine-${process.pid}-${process.hrtime.bigint()}`; - try { - fs.writeFileSync(temporary, content, { flag: "wx" }); - fs.renameSync(temporary, file); - } finally { - if (fs.existsSync(temporary)) fs.rmSync(temporary); - } -} - -/** Restore an earlier file snapshot atomically, or remove a newly created file. */ -export function restoreFile(file, content) { - if (content === null) { - if (fs.existsSync(file)) fs.rmSync(file); - return; - } - writeFileAtomic(file, content); -} - -/** Serialize writes to one project's managed Cursor installation. */ -export function withProjectLock(cursorRoot, action) { - fs.mkdirSync(cursorRoot, { recursive: true }); - const lock = path.join(cursorRoot, ".fusengine-install.lock"); - try { fs.mkdirSync(lock); } - catch (error) { - if (error.code === "EEXIST") throw new Error(`another Fusengine install is active: ${lock}`); - throw error; - } - try { - if (process.env.NODE_ENV === "test") { - const holdMs = Number.parseInt(process.env.FUSE_INSTALL_TEST_HOLD_LOCK_MS ?? "0", 10); - if (holdMs > 0) Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, holdMs); - } - return action(); - } finally { - fs.rmSync(lock, { recursive: true }); - } -} diff --git a/.cursor-plugin/scripts/project-install-transaction.mjs b/.cursor-plugin/scripts/project-install-transaction.mjs deleted file mode 100644 index d8f4d78..0000000 --- a/.cursor-plugin/scripts/project-install-transaction.mjs +++ /dev/null @@ -1,124 +0,0 @@ -import fs from "node:fs"; -import path from "node:path"; -import { spawnSync } from "node:child_process"; -import { resolvePluginPaths } from "./project-plugin-inventory.mjs"; - -function validateStage(stageRoot, plugins) { - const loader = path.join(stageRoot, "load-plugins.mjs"); - const inventory = path.join(stageRoot, "project-plugin-inventory.mjs"); - for (const script of [loader, inventory]) { - const run = spawnSync(process.execPath, ["--check", script], { encoding: "utf8" }); - if (run.status !== 0) throw new Error(`staged loader validation failed: ${run.stderr.trim()}`); - } - const pluginPaths = resolvePluginPaths(stageRoot); - const expected = plugins.map((plugin) => fs.realpathSync(path.join(stageRoot, "plugins", plugin.name))); - if (JSON.stringify(pluginPaths) !== JSON.stringify(expected)) { - throw new Error("staged loader paths do not match the marketplace inventory"); - } -} - -function buildStage({ stageRoot, sourceRoot, plugins, receipt }) { - fs.mkdirSync(path.join(stageRoot, "plugins"), { recursive: true }); - fs.writeFileSync(path.join(stageRoot, ".managed-by-fusengine"), "fusengine cursor project installation\n"); - const failAfter = process.env.NODE_ENV === "test" - ? Number.parseInt(process.env.FUSE_INSTALL_TEST_FAIL_AFTER_PLUGIN ?? "0", 10) - : 0; - for (let index = 0; index < plugins.length; index += 1) { - const plugin = plugins[index]; - fs.cpSync(plugin.source, path.join(stageRoot, "plugins", plugin.name), { recursive: true }); - if (failAfter === index + 1) throw new Error(`injected copy failure after plugin ${plugin.name}`); - } - const runtimeRoot = path.join(sourceRoot, ".cursor-plugin", "scripts"); - fs.copyFileSync(path.join(runtimeRoot, "load-project-plugins.mjs"), path.join(stageRoot, "load-plugins.mjs")); - fs.copyFileSync(path.join(runtimeRoot, "project-plugin-inventory.mjs"), path.join(stageRoot, "project-plugin-inventory.mjs")); - fs.copyFileSync(path.join(runtimeRoot, "marketplace-sources.mjs"), path.join(stageRoot, "marketplace-sources.mjs")); - fs.copyFileSync(path.join(sourceRoot, ".cursor-plugin", "marketplace.json"), path.join(stageRoot, "marketplace.json")); - fs.writeFileSync(path.join(stageRoot, "install-receipt.json"), `${JSON.stringify(receipt, null, 2)}\n`); - validateStage(stageRoot, plugins); -} - -function isOwnedBackup(candidate) { - try { - const stat = fs.lstatSync(candidate); - const marker = path.join(candidate, ".managed-by-fusengine"); - return stat.isDirectory() && !stat.isSymbolicLink() - && fs.lstatSync(marker).isFile() - && fs.readFileSync(marker, "utf8") === "fusengine cursor project installation\n"; - } catch { return false; } -} - -function ownedRoots(cursorRoot, pattern) { - if (!fs.existsSync(cursorRoot)) return []; - return fs.readdirSync(cursorRoot) - .filter((name) => pattern.test(name)) - .map((name) => path.join(cursorRoot, name)) - .filter(isOwnedBackup); -} - -/** Restore a sole crash backup before removing stale owned recovery artifacts. */ -export function recoverManagedRoot(cursorRoot, managedRoot) { - if (!fs.existsSync(cursorRoot)) return; - const backups = ownedRoots(cursorRoot, /^\.fusengine-backup-\d+-\d+$/); - if (!fs.existsSync(managedRoot)) { - if (backups.length > 1) throw new Error("multiple owned crash backups require manual recovery"); - if (backups.length === 1) fs.renameSync(backups[0], managedRoot); - } - if (fs.existsSync(managedRoot) && !isOwnedBackup(managedRoot)) { - throw new Error(`managed root has no valid ownership marker: ${managedRoot}`); - } - for (const backup of backups) { - if (fs.existsSync(backup)) fs.rmSync(backup, { recursive: true }); - } - for (const stage of ownedRoots(cursorRoot, /^\.fusengine-stage-\d+-\d+$/)) { - fs.rmSync(stage, { recursive: true }); - } -} - -function injectFailure(point) { - if (process.env.NODE_ENV === "test" && process.env.FUSE_INSTALL_TEST_FAIL_POINT === point) { - throw new Error(`injected transaction failure at ${point}`); - } -} - -/** Build and validate a complete managed root, then replace it with rollback around finalization. */ -export function replaceManagedRoot({ cursorRoot, managedRoot, sourceRoot, plugins, receipt, finalize, rollbackFinalize }) { - fs.mkdirSync(cursorRoot, { recursive: true }); - recoverManagedRoot(cursorRoot, managedRoot); - const nonce = `${process.pid}-${Date.now()}`; - const stageRoot = path.join(cursorRoot, `.fusengine-stage-${nonce}`); - const backupRoot = path.join(cursorRoot, `.fusengine-backup-${nonce}`); - let oldMoved = false; - let newMoved = false; - let finalizationStarted = false; - try { - buildStage({ stageRoot, sourceRoot, plugins, receipt }); - injectFailure("before-old-move"); - if (fs.existsSync(managedRoot)) { - fs.renameSync(managedRoot, backupRoot); - oldMoved = true; - } - injectFailure("after-old-move"); - fs.renameSync(stageRoot, managedRoot); - newMoved = true; - injectFailure("after-new-move"); - finalizationStarted = true; - finalize(); - injectFailure("after-finalize"); - } catch (error) { - if (finalizationStarted) rollbackFinalize(); - if (newMoved && fs.existsSync(managedRoot)) fs.rmSync(managedRoot, { recursive: true }); - if (oldMoved && fs.existsSync(backupRoot)) fs.renameSync(backupRoot, managedRoot); - throw error; - } finally { - if (fs.existsSync(stageRoot)) fs.rmSync(stageRoot, { recursive: true }); - } - if (oldMoved && fs.existsSync(backupRoot)) { - const retainForTest = process.env.NODE_ENV === "test" && process.env.FUSE_INSTALL_TEST_FAIL_BACKUP_CLEANUP === "1"; - try { - if (retainForTest) throw new Error("injected backup cleanup failure"); - fs.rmSync(backupRoot, { recursive: true }); - } catch (error) { - process.stderr.write(`warning: install committed; recovery backup retained at ${backupRoot}: ${error.message}\n`); - } - } -} diff --git a/.cursor-plugin/scripts/project-install.mjs b/.cursor-plugin/scripts/project-install.mjs deleted file mode 100755 index a6a3e5c..0000000 --- a/.cursor-plugin/scripts/project-install.mjs +++ /dev/null @@ -1,156 +0,0 @@ -#!/usr/bin/env node -/** Project-local Cursor installer. All writes stay below the explicit target. */ -import crypto from "node:crypto"; -import fs from "node:fs"; -import path from "node:path"; -import { fileURLToPath } from "node:url"; -import { assertNotSymlink, restoreFile, withProjectLock, writeFileAtomic } from "./project-install-files.mjs"; -import { resolveMarketplacePlugins } from "./marketplace-sources.mjs"; -import { mergeLoaderHook } from "./project-hooks.mjs"; -import { recoverManagedRoot, replaceManagedRoot } from "./project-install-transaction.mjs"; -import { uninstallProject } from "./project-uninstall.mjs"; - -const sourceRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); -const markerName = ".managed-by-fusengine"; -const args = process.argv.slice(2); -const options = { dryRun: false, uninstall: false, project: "" }; - -function fail(message) { - process.stderr.write(`error: ${message}\n`); - process.exit(1); -} - -function usage() { - process.stdout.write("Usage: install.sh --project [--dry-run] [--uninstall]\n"); - process.exit(0); -} - -for (let index = 0; index < args.length; index += 1) { - const arg = args[index]; - if (arg === "--project") options.project = args[++index] ?? ""; - else if (arg === "--dry-run") options.dryRun = true; - else if (arg === "--uninstall") options.uninstall = true; - else if (arg === "--help" || arg === "-h") usage(); - else fail(`unknown option: ${arg}`); -} -if (!options.project) fail("--project is required; no global scope is implied"); - -const requestedProject = path.resolve(options.project); -if (!fs.existsSync(requestedProject) || !fs.statSync(requestedProject).isDirectory()) fail(`project directory does not exist: ${requestedProject}`); -const projectRoot = fs.realpathSync(requestedProject); -const cursorRoot = path.join(projectRoot, ".cursor"); -const managedRoot = path.join(cursorRoot, "fusengine"); -const markerPath = path.join(managedRoot, markerName); -const hooksPath = path.join(cursorRoot, "hooks.json"); -const rulePath = path.join(cursorRoot, "rules", "fusengine.mdc"); -const receiptPath = path.join(managedRoot, "install-receipt.json"); - -function readJson(file) { - try { return JSON.parse(fs.readFileSync(file, "utf8")); } - catch (error) { fail(`${file} is not valid JSON: ${error.message}`); } -} - -function writeJson(file, value) { - writeFileAtomic(file, `${JSON.stringify(value, null, 2)}\n`); -} - -function sha256(value) { - return crypto.createHash("sha256").update(value).digest("hex"); -} - -function hookDocument() { - const existed = fs.existsSync(hooksPath); - const document = existed ? readJson(hooksPath) : { version: 1, hooks: {} }; - if (!document || Array.isArray(document) || typeof document !== "object") fail(`${hooksPath} must contain an object`); - if (document.version !== undefined && document.version !== 1) fail(`${hooksPath} must use Cursor hooks version 1`); - if (document.hooks !== undefined && (Array.isArray(document.hooks) || typeof document.hooks !== "object")) fail(`${hooksPath}.hooks must be an object`); - document.version = 1; - document.hooks ??= {}; - document.hooks.workspaceOpen ??= []; - if (!Array.isArray(document.hooks.workspaceOpen)) fail(`${hooksPath}.hooks.workspaceOpen must be an array`); - return { document, existed }; -} - -function buildRule() { - const defaultSource = path.join(sourceRoot, "AGENTS.md"); - const ruleSource = process.env.NODE_ENV === "test" && process.env.FUSE_INSTALL_TEST_RULE_SOURCE - ? process.env.FUSE_INSTALL_TEST_RULE_SOURCE - : defaultSource; - const agents = fs.readFileSync(ruleSource, "utf8") - .replace(/\n\n/, "\n"); - return `---\ndescription: Fusengine project engineering rules.\nalwaysApply: true\n---\n\n${agents}`; -} - -function install() { - assertNotSymlink(cursorRoot); - assertNotSymlink(managedRoot); - assertNotSymlink(hooksPath); - assertNotSymlink(path.dirname(rulePath)); - assertNotSymlink(rulePath); - recoverManagedRoot(cursorRoot, managedRoot); - assertNotSymlink(managedRoot); - const market = readJson(path.join(sourceRoot, ".cursor-plugin", "marketplace.json")); - if (!Array.isArray(market.plugins) || market.plugins.length === 0) fail("marketplace has no plugins"); - const plugins = resolveMarketplacePlugins(sourceRoot, market); - const previousReceipt = fs.existsSync(receiptPath) ? readJson(receiptPath) : {}; - const originalHooks = fs.existsSync(hooksPath) ? fs.readFileSync(hooksPath) : null; - const originalRule = fs.existsSync(rulePath) ? fs.readFileSync(rulePath) : null; - const ruleExisted = typeof previousReceipt.ruleFileExisted === "boolean" - ? previousReceipt.ruleFileExisted - : fs.existsSync(rulePath); - const { document, existed } = hookDocument(); - const candidateToken = crypto.randomBytes(8).toString("hex"); - const hookPlan = mergeLoaderHook(document, previousReceipt, candidateToken); - const { document: clean, hookAdded, hookCommand, hookIndex, hookToken } = hookPlan; - const rule = buildRule(); - if (options.dryRun) { - process.stdout.write(`Would install ${market.plugins.length} plugins under ${managedRoot}\n`); - process.stdout.write(`Would merge ${hookCommand} into ${hooksPath}\nWould write ${rulePath}\nDry run: nothing written.\n`); - return; - } - if (fs.existsSync(managedRoot) && !fs.existsSync(markerPath)) fail(`refusing to replace unowned directory: ${managedRoot}`); - const currentRule = fs.existsSync(rulePath) ? fs.readFileSync(rulePath) : null; - const ownedRuleIntact = currentRule !== null - && previousReceipt.ruleFileExisted === false - && sha256(currentRule) === previousReceipt.ruleSha256; - if (currentRule !== null && currentRule.toString("utf8") !== rule && !ownedRuleIntact) { - fail(`refusing to overwrite existing rule: ${rulePath}`); - } - const receipt = { - hooksFileExisted: typeof previousReceipt.hooksFileExisted === "boolean" ? previousReceipt.hooksFileExisted : existed, - ruleFileExisted: ruleExisted, - hookAdded, - hookIndex, - hookToken, - ruleSha256: sha256(rule), - }; - const finalize = () => { - writeFileAtomic(rulePath, rule); - writeJson(hooksPath, clean); - }; - const rollbackFinalize = () => { - restoreFile(rulePath, originalRule); - restoreFile(hooksPath, originalHooks); - }; - replaceManagedRoot({ cursorRoot, managedRoot, sourceRoot, plugins, receipt, finalize, rollbackFinalize }); - process.stdout.write(`Installed ${market.plugins.length} project-local plugins in ${projectRoot}\nReload the Cursor window to activate them.\n`); -} - -if (options.dryRun && options.uninstall) { - process.stdout.write(`Would remove only the Fusengine hook, rule, and managed directory under ${projectRoot}\nDry run: nothing written.\n`); -} else if (options.uninstall) { - try { - assertNotSymlink(cursorRoot); - withProjectLock(cursorRoot, () => uninstallProject({ cursorRoot, hooksPath, managedRoot, receiptPath, rulePath })); - process.stdout.write(`Uninstalled project-local plugins from ${projectRoot}\n`); - } - catch (error) { fail(error.message); } -} -else { - try { - assertNotSymlink(cursorRoot); - if (options.dryRun) install(); - else withProjectLock(cursorRoot, install); - } - catch (error) { fail(error.message); } -} diff --git a/.cursor-plugin/scripts/project-uninstall.mjs b/.cursor-plugin/scripts/project-uninstall.mjs deleted file mode 100644 index 6ad3e7e..0000000 --- a/.cursor-plugin/scripts/project-uninstall.mjs +++ /dev/null @@ -1,97 +0,0 @@ -import crypto from "node:crypto"; -import fs from "node:fs"; -import path from "node:path"; -import { assertNotSymlink, restoreFile, writeFileAtomic } from "./project-install-files.mjs"; -import { removeLoaderHook } from "./project-hooks.mjs"; - -const markerContent = "fusengine cursor project installation\n"; - -function sha256(value) { - return crypto.createHash("sha256").update(value).digest("hex"); -} - -function readJson(file) { - return JSON.parse(fs.readFileSync(file, "utf8")); -} - -function requireType(file, type) { - const stat = fs.lstatSync(file); - if ((type === "file" && !stat.isFile()) || (type === "directory" && !stat.isDirectory())) { - throw new Error(`expected ${type}: ${file}`); - } -} - -function serialize(document) { - return Buffer.from(`${JSON.stringify(document, null, 2)}\n`); -} - -function buildPlan(paths) { - const { cursorRoot, managedRoot, hooksPath, rulePath, receiptPath } = paths; - for (const target of [cursorRoot, managedRoot, hooksPath, path.dirname(rulePath), rulePath, receiptPath]) { - assertNotSymlink(target); - } - requireType(cursorRoot, "directory"); - requireType(managedRoot, "directory"); - const markerPath = path.join(managedRoot, ".managed-by-fusengine"); - assertNotSymlink(markerPath); - requireType(markerPath, "file"); - if (fs.readFileSync(markerPath, "utf8") !== markerContent) throw new Error(`invalid ownership marker: ${markerPath}`); - if (fs.existsSync(receiptPath)) requireType(receiptPath, "file"); - const receipt = fs.existsSync(receiptPath) ? readJson(receiptPath) : {}; - - let hooksAction = { type: "none" }; - const hooksExist = fs.existsSync(hooksPath); - if (hooksExist) requireType(hooksPath, "file"); - const originalHooks = hooksExist ? fs.readFileSync(hooksPath) : null; - if (originalHooks !== null) { - const document = readJson(hooksPath); - if (removeLoaderHook(document, receipt)) { - const hookKeys = Object.keys(document.hooks ?? {}); - const onlyBaseKeys = Object.keys(document).every((key) => key === "version" || key === "hooks"); - hooksAction = receipt.hooksFileExisted === false && hookKeys.length === 0 && onlyBaseKeys - ? { type: "remove" } - : { type: "write", content: serialize(document) }; - } - } - - let ruleAction = { type: "none" }; - const ruleExists = fs.existsSync(rulePath); - if (ruleExists) requireType(rulePath, "file"); - const originalRule = ruleExists ? fs.readFileSync(rulePath) : null; - if (originalRule !== null) { - ruleAction = sha256(originalRule) === receipt.ruleSha256 && receipt.ruleFileExisted !== true - ? { type: "remove" } - : { type: "preserve" }; - } - return { hooksAction, originalHooks, originalRule, receipt, ruleAction }; -} - -function applyFileAction(file, action) { - if (action.type === "write") writeFileAtomic(file, action.content); - else if (action.type === "remove") fs.rmSync(file); -} - -function injectAfterHook() { - if (process.env.NODE_ENV === "test" && process.env.FUSE_INSTALL_TEST_FAIL_UNINSTALL_AFTER_HOOK === "1") { - throw new Error("injected uninstall failure after hook mutation"); - } -} - -/** Preflight and atomically coordinate removal of one owned project installation. */ -export function uninstallProject(paths) { - const plan = buildPlan(paths); - const tombstone = path.join(paths.cursorRoot, `.fusengine-uninstall-${process.pid}-${Date.now()}`); - try { - applyFileAction(paths.hooksPath, plan.hooksAction); - injectAfterHook(); - applyFileAction(paths.rulePath, plan.ruleAction); - fs.renameSync(paths.managedRoot, tombstone); - } catch (error) { - restoreFile(paths.rulePath, plan.originalRule); - restoreFile(paths.hooksPath, plan.originalHooks); - throw error; - } - if (plan.ruleAction.type === "preserve") process.stderr.write(`warning: preserved pre-existing or modified rule ${paths.rulePath}\n`); - try { fs.rmSync(tombstone, { recursive: true }); } - catch (error) { process.stderr.write(`warning: uninstall committed; retained cleanup directory ${tombstone}: ${error.message}\n`); } -} diff --git a/.cursor-plugin/scripts/project-verify.mjs b/.cursor-plugin/scripts/project-verify.mjs deleted file mode 100755 index 674dc51..0000000 --- a/.cursor-plugin/scripts/project-verify.mjs +++ /dev/null @@ -1,51 +0,0 @@ -#!/usr/bin/env node -/** Verify one installed project without changing it. */ -import fs from "node:fs"; -import path from "node:path"; -import { spawnSync } from "node:child_process"; - -const argument = process.argv[2]; -if (!argument) { - process.stderr.write("Usage: verify-project.sh \n"); - process.exit(1); -} - -const project = fs.realpathSync(path.resolve(argument)); -const cursorRoot = path.join(project, ".cursor"); -const managedRoot = path.join(cursorRoot, "fusengine"); -const command = /^node \.cursor\/fusengine\/load-plugins\.mjs(?: --fusengine-owner=[a-f0-9]{16})?$/; -const failures = []; -const pass = (message) => process.stdout.write(`PASS ${message}\n`); -const check = (condition, message) => condition ? pass(message) : failures.push(message); - -const hooksPath = path.join(cursorRoot, "hooks.json"); -const marketPath = path.join(managedRoot, "marketplace.json"); -check(fs.existsSync(path.join(managedRoot, ".managed-by-fusengine")), "managed installation marker exists"); -check(fs.existsSync(path.join(cursorRoot, "rules", "fusengine.mdc")), "project rule exists"); -check(fs.existsSync(hooksPath), "project hooks file exists"); -check(fs.existsSync(marketPath), "installed marketplace inventory exists"); - -if (fs.existsSync(hooksPath)) { - const hooks = JSON.parse(fs.readFileSync(hooksPath, "utf8")); - const entries = hooks.hooks?.workspaceOpen?.filter((hook) => command.test(hook?.command ?? "")) ?? []; - check(hooks.version === 1, "hooks schema version is 1"); - check(entries.length === 1, "workspaceOpen loader is registered exactly once"); -} - -if (fs.existsSync(marketPath)) { - const market = JSON.parse(fs.readFileSync(marketPath, "utf8")); - const run = spawnSync(process.execPath, [".cursor/fusengine/load-plugins.mjs"], { cwd: project, encoding: "utf8" }); - check(run.status === 0, "project loader exits successfully"); - if (run.status === 0) { - const response = JSON.parse(run.stdout); - const paths = response.pluginPaths ?? []; - check(paths.length === market.plugins.length, "loader returns one path per marketplace entry"); - check(!paths.includes(managedRoot), "loader does not return the marketplace root"); - check(paths.every((pluginPath) => path.isAbsolute(pluginPath)), "all plugin paths are absolute"); - check(paths.every((pluginPath) => fs.existsSync(path.join(pluginPath, ".cursor-plugin", "plugin.json"))), "all paths resolve to individual plugin roots"); - } -} - -for (const failure of failures) process.stderr.write(`FAIL ${failure}\n`); -if (failures.length > 0) process.exit(1); -process.stdout.write("VERDICT project installation verified\n"); diff --git a/.cursor-plugin/scripts/src/__tests__/api-keys.test.ts b/.cursor-plugin/scripts/src/__tests__/api-keys.test.ts new file mode 100644 index 0000000..cb249f7 --- /dev/null +++ b/.cursor-plugin/scripts/src/__tests__/api-keys.test.ts @@ -0,0 +1,70 @@ +/** + * Tests for api-keys config + */ +import { describe, expect, test } from "bun:test"; +import { API_KEYS } from "../config/api-keys"; +import type { EnvKey } from "../interfaces/env"; + +describe("api-keys config", () => { + test("API_KEYS is an array", () => { + expect(Array.isArray(API_KEYS)).toBe(true); + }); + + test("all keys have required properties", () => { + for (const key of API_KEYS) { + expect(key).toHaveProperty("name"); + expect(key).toHaveProperty("description"); + expect(typeof key.name).toBe("string"); + expect(typeof key.description).toBe("string"); + } + }); + + test("contains expected API keys", () => { + const keyNames = API_KEYS.map((k) => k.name); + + expect(keyNames).toContain("CONTEXT7_API_KEY"); + expect(keyNames).toContain("EXA_API_KEY"); + expect(keyNames).toContain("MAGIC_API_KEY"); + expect(keyNames).toContain("GEMINI_DESIGN_API_KEY"); + }); + + test("key names follow naming convention", () => { + for (const key of API_KEYS) { + // Should be UPPER_SNAKE_CASE ending with _API_KEY, _TOKEN, _SECRET_KEY, _ACCESS_TOKEN, _AUTH_TOKEN, or _API_TOKEN + expect(key.name).toMatch( + /^[A-Z][A-Z0-9_]*_(API_KEY|TOKEN|SECRET_KEY|ACCESS_TOKEN|AUTH_TOKEN|API_TOKEN)$/, + ); + } + }); + + test("descriptions are not empty", () => { + for (const key of API_KEYS) { + expect(key.description.length).toBeGreaterThan(0); + } + }); + + test("implements EnvKey interface correctly", () => { + const testKey: EnvKey = API_KEYS[0]; + expect(testKey.name).toBeDefined(); + expect(testKey.description).toBeDefined(); + }); + + test("url field is optional and valid when present", () => { + for (const key of API_KEYS) { + if (key.url) { + // Should be a valid URL + expect(key.url).toMatch(/^https?:\/\/.+/); + } + } + }); + + test("all keys have url field populated", () => { + for (const key of API_KEYS) { + expect(key.url).toBeDefined(); + if (key.url) { + expect(typeof key.url).toBe("string"); + expect(key.url.length).toBeGreaterThan(0); + } + } + }); +}); diff --git a/.cursor-plugin/scripts/src/__tests__/deps-targets.test.ts b/.cursor-plugin/scripts/src/__tests__/deps-targets.test.ts new file mode 100644 index 0000000..ccb0cbc --- /dev/null +++ b/.cursor-plugin/scripts/src/__tests__/deps-targets.test.ts @@ -0,0 +1,39 @@ +/** + * Tests for getManagedDepsTargets — the 3 self-heal targets. + */ +import { describe, expect, test } from "bun:test"; +import { join } from "node:path"; +import { getManagedDepsTargets } from "../services/deps-targets"; + +const SCRIPTS_DIR = "/fake/marketplace/scripts"; +const PLUGINS_DIR = "/fake/marketplace/plugins"; + +describe("getManagedDepsTargets", () => { + test("returns exactly 3 targets", () => { + const targets = getManagedDepsTargets(SCRIPTS_DIR, PLUGINS_DIR); + expect(targets).toHaveLength(3); + }); + + test("scripts target points at @clack/prompts marker", () => { + const [scripts] = getManagedDepsTargets(SCRIPTS_DIR, PLUGINS_DIR); + expect(scripts.dir).toBe(SCRIPTS_DIR); + expect(scripts.marker).toBe( + join(SCRIPTS_DIR, "node_modules/@clack/prompts/package.json"), + ); + }); + + test("plugins target points at the shared harness bin.mjs marker", () => { + const [, plugins] = getManagedDepsTargets(SCRIPTS_DIR, PLUGINS_DIR); + expect(plugins.dir).toBe(PLUGINS_DIR); + expect(plugins.marker).toBe( + join(PLUGINS_DIR, "node_modules/@fusengine/harness/dist/cli/bin.mjs"), + ); + }); + + test("statusline target points at core-guards/statusline/node_modules", () => { + const [, , statusline] = getManagedDepsTargets(SCRIPTS_DIR, PLUGINS_DIR); + const statuslineDir = join(PLUGINS_DIR, "core-guards/statusline"); + expect(statusline.dir).toBe(statuslineDir); + expect(statusline.marker).toBe(join(statuslineDir, "node_modules")); + }); +}); diff --git a/.cursor-plugin/scripts/src/__tests__/ensure-deps.test.ts b/.cursor-plugin/scripts/src/__tests__/ensure-deps.test.ts new file mode 100644 index 0000000..ef9cbf8 --- /dev/null +++ b/.cursor-plugin/scripts/src/__tests__/ensure-deps.test.ts @@ -0,0 +1,105 @@ +/** + * Tests for ensureDeps: wipe repair, nominal no-op, concurrency single-flight, + * and fail-open on a throwing installer. The real `bun install` is never + * invoked — every test injects a fake installer (DI), per ensureDeps' design. + */ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { DepsTarget } from "../interfaces/ensure-deps"; +import { ensureDeps } from "../services/ensure-deps"; + +const TEST_DIR = "/tmp/fusengine-test-ensure-deps"; +const LOCK_DIR = join(tmpdir(), "fusengine-ensure-deps"); + +function makeTarget(name: string): DepsTarget { + const dir = join(TEST_DIR, name); + return { dir, marker: join(dir, "node_modules/marker.txt"), label: name }; +} + +beforeEach(() => { + rmSync(TEST_DIR, { recursive: true, force: true }); + rmSync(LOCK_DIR, { recursive: true, force: true }); +}); + +afterEach(() => { + rmSync(TEST_DIR, { recursive: true, force: true }); + rmSync(LOCK_DIR, { recursive: true, force: true }); +}); + +describe("ensureDeps", () => { + test("nominal: marker already present → zero installs", async () => { + const target = makeTarget("nominal"); + mkdirSync(join(target.dir, "node_modules"), { recursive: true }); + writeFileSync(target.marker, "ok"); + writeFileSync(join(target.dir, "package.json"), "{}"); + + let calls = 0; + await ensureDeps([target], async () => { + calls++; + }); + + expect(calls).toBe(0); + }); + + test("wipe: marker missing → installer called once with the right cwd, marker repaired", async () => { + const target = makeTarget("wiped"); + mkdirSync(target.dir, { recursive: true }); + writeFileSync(join(target.dir, "package.json"), "{}"); + + const calls: string[] = []; + await ensureDeps([target], async (dir) => { + calls.push(dir); + mkdirSync(join(target.dir, "node_modules"), { recursive: true }); + writeFileSync(target.marker, "installed"); + }); + + expect(calls).toEqual([target.dir]); + }); + + test("no package.json → skipped, installer never called", async () => { + const target = makeTarget("empty"); + mkdirSync(target.dir, { recursive: true }); + + let calls = 0; + await ensureDeps([target], async () => { + calls++; + }); + + expect(calls).toBe(0); + }); + + test("concurrency: two racing ensureDeps calls install only once (single-flight)", async () => { + const target = makeTarget("race"); + mkdirSync(target.dir, { recursive: true }); + writeFileSync(join(target.dir, "package.json"), "{}"); + + let calls = 0; + const slowInstall = async (dir: string) => { + calls++; + await new Promise((r) => setTimeout(r, 50)); + mkdirSync(join(dir, "node_modules"), { recursive: true }); + writeFileSync(target.marker, "installed"); + }; + + await Promise.all([ + ensureDeps([target], slowInstall), + ensureDeps([target], slowInstall), + ]); + + expect(calls).toBe(1); + }); + + test("fail-open: a throwing installer does not propagate", async () => { + const target = makeTarget("throws"); + mkdirSync(target.dir, { recursive: true }); + writeFileSync(join(target.dir, "package.json"), "{}"); + + await expect( + ensureDeps([target], async () => { + throw new Error("network down"); + }), + ).resolves.toBeUndefined(); + }); +}); diff --git a/.cursor-plugin/scripts/src/__tests__/ensure-harness-deps.test.ts b/.cursor-plugin/scripts/src/__tests__/ensure-harness-deps.test.ts new file mode 100644 index 0000000..d58dd05 --- /dev/null +++ b/.cursor-plugin/scripts/src/__tests__/ensure-harness-deps.test.ts @@ -0,0 +1,29 @@ +/** + * Test for the standalone entry point `scripts/ensure-harness-deps.ts` — + * proves it is directly executable via `bun scripts/ensure-harness-deps.ts` + * (not only reachable through the loader). Runs against this dev machine's + * real HOME/marketplace, where node_modules is already present (nominal + * path), so this is a fast, deterministic, network-free smoke test. + */ +import { describe, expect, test } from "bun:test"; +import { join } from "node:path"; + +const ENTRY_POINT = join(import.meta.dir, "../../ensure-harness-deps.ts"); + +describe("ensure-harness-deps.ts standalone entry point", () => { + test( + "runs directly via `bun ` and exits 0 without crashing", + async () => { + const proc = Bun.spawn([process.execPath, ENTRY_POINT], { + stdout: "ignore", + stderr: "pipe", + }); + const exitCode = await proc.exited; + const stderr = await new Response(proc.stderr).text(); + + expect(exitCode).toBe(0); + expect(stderr).toBe(""); + }, + 15_000, + ); +}); diff --git a/.cursor-plugin/scripts/src/__tests__/env-file-load.test.ts b/.cursor-plugin/scripts/src/__tests__/env-file-load.test.ts new file mode 100644 index 0000000..cbf4e32 --- /dev/null +++ b/.cursor-plugin/scripts/src/__tests__/env-file-load.test.ts @@ -0,0 +1,79 @@ +/** + * Tests for env-file loadEnvFile parsing logic + * Uses isolated temp directory to avoid modifying real user files + */ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; + +// Mock HOME for isolated testing +const TEST_HOME = "/tmp/fusengine-test-home-load"; +const TEST_ENV_FILE = join(TEST_HOME, ".cursor", ".env"); + +describe("loadEnvFile (logic test)", () => { + beforeEach(() => { + mkdirSync(join(TEST_HOME, ".cursor"), { recursive: true }); + }); + + afterEach(() => { + rmSync(TEST_HOME, { recursive: true, force: true }); + }); + + test("parses export statements correctly", () => { + const content = `export API_KEY="value123" +export OTHER_KEY='single quotes' +export NO_QUOTES=noquotes`; + + writeFileSync(TEST_ENV_FILE, content); + const fileContent = readFileSync(TEST_ENV_FILE, "utf8"); + + // Test parsing logic + const env: Record = {}; + for (const line of fileContent.split("\n")) { + const match = line.match(/^export\s+(\w+)=["']?([^"'\n]*)["']?/); + if (match) { + env[match[1]] = match[2]; + } + } + + expect(env.API_KEY).toBe("value123"); + expect(env.OTHER_KEY).toBe("single quotes"); + expect(env.NO_QUOTES).toBe("noquotes"); + }); + + test("handles empty file", () => { + writeFileSync(TEST_ENV_FILE, ""); + const content = readFileSync(TEST_ENV_FILE, "utf8"); + + const env: Record = {}; + for (const line of content.split("\n")) { + const match = line.match(/^export\s+(\w+)=["']?([^"'\n]*)["']?/); + if (match) { + env[match[1]] = match[2]; + } + } + + expect(Object.keys(env)).toHaveLength(0); + }); + + test("ignores comments and invalid lines", () => { + const content = `# This is a comment +export VALID_KEY="value" +INVALID_LINE + export INDENTED="ignored"`; + + writeFileSync(TEST_ENV_FILE, content); + const fileContent = readFileSync(TEST_ENV_FILE, "utf8"); + + const env: Record = {}; + for (const line of fileContent.split("\n")) { + const match = line.match(/^export\s+(\w+)=["']?([^"'\n]*)["']?/); + if (match) { + env[match[1]] = match[2]; + } + } + + expect(env.VALID_KEY).toBe("value"); + expect(env.INVALID_LINE).toBeUndefined(); + }); +}); diff --git a/.cursor-plugin/scripts/src/__tests__/env-file-writer-sandbox.test.ts b/.cursor-plugin/scripts/src/__tests__/env-file-writer-sandbox.test.ts new file mode 100644 index 0000000..bf326f0 --- /dev/null +++ b/.cursor-plugin/scripts/src/__tests__/env-file-writer-sandbox.test.ts @@ -0,0 +1,197 @@ +/** + * E2E sandbox test for the installer's ~/.cursor/.fusengine-global/.env writer/reader, exercising + * the REAL exports (no mocks) against a temp HOME. `ENV_FILE` is a module-level + * const resolved at import time, so each assertion spawns the real code in a + * subprocess with HOME pointed at the sandbox — see fixtures/env-writer-cli.ts. + * Real ~/.cursor is never touched. See settings-env-purge-hooks-sandbox.test.ts + * for the companion settings.json coverage. + */ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { saveSettings } from "../services/settings-manager"; + +const FIXTURE = `${import.meta.dir}/fixtures/env-writer-cli.ts`; + +let tmpHome: string; +let settingsPath: string; +let envFilePath: string; + +beforeEach(() => { + tmpHome = mkdtempSync(join(tmpdir(), "fusengine-env-writer-sandbox-")); + settingsPath = join(tmpHome, ".cursor", "settings.json"); + envFilePath = join(tmpHome, ".cursor", ".env"); +}); + +afterEach(() => { + rmSync(tmpHome, { recursive: true, force: true }); +}); + +/** Run the real upsertEnvVar writer in a subprocess whose HOME is the sandbox. */ +async function writeEnvVarsInSandbox(pairs: [string, string][]): Promise { + const proc = Bun.spawn([process.execPath, FIXTURE, JSON.stringify(pairs)], { + env: { ...process.env, HOME: tmpHome, USERPROFILE: tmpHome }, + stdout: "pipe", + stderr: "pipe", + }); + const exitCode = await proc.exited; + if (exitCode !== 0) { + const stderr = await new Response(proc.stderr).text(); + throw new Error(`env-writer-cli exited ${exitCode}: ${stderr}`); + } +} + +/** + * Run upsert/remove ops (real upsertEnvVar/removeEnvVar) in a sandbox + * subprocess, optionally returning the real loadEnvFile() result. + */ +async function runOpsInSandbox( + ops: ([string, string] | [string])[], + loadAfter = false, +): Promise | undefined> { + const args = [process.execPath, FIXTURE, JSON.stringify(ops)]; + if (loadAfter) args.push("--load"); + const proc = Bun.spawn(args, { + env: { ...process.env, HOME: tmpHome, USERPROFILE: tmpHome }, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, exitCode] = await Promise.all([new Response(proc.stdout).text(), proc.exited]); + if (exitCode !== 0) { + const stderr = await new Response(proc.stderr).text(); + throw new Error(`env-writer-cli exited ${exitCode}: ${stderr}`); + } + return loadAfter ? (JSON.parse(stdout) as Record) : undefined; +} + +/** Seed the sandbox .env file with raw content before invoking the real writer/reader. */ +function seedEnvFile(content: string): void { + mkdirSync(dirname(envFilePath), { recursive: true }); + writeFileSync(envFilePath, content); +} + +describe("installer sandbox: FUSE_* routed to ~/.cursor/.fusengine-global/.env (real code, temp HOME)", () => { + test("upsertEnvVar writes FUSE_* to /.cursor/.fusengine-global/.env, never to settings.json", async () => { + await saveSettings(settingsPath, {}); + await writeEnvVarsInSandbox([ + ["FUSE_SOLID_MAX_LINES", "90"], + ["FUSE_ENFORCE_TTL_SEC", "300"], + ["FUSE_HARNESS_REFS", "solid-generic"], + ]); + + const envContent = readFileSync(envFilePath, "utf8"); + expect(envContent).toContain('export FUSE_SOLID_MAX_LINES="90"'); + expect(envContent).toContain('export FUSE_ENFORCE_TTL_SEC="300"'); + expect(envContent).toContain('export FUSE_HARNESS_REFS="solid-generic"'); + + const settingsContent = readFileSync(settingsPath, "utf8"); + expect(settingsContent).not.toContain("FUSE_SOLID_MAX_LINES"); + expect(settingsContent).not.toContain("FUSE_ENFORCE_TTL_SEC"); + expect(settingsContent).not.toContain("FUSE_HARNESS_REFS"); + }); + + test("CLAUDE_CODE_* never lands in .env", async () => { + await writeEnvVarsInSandbox([["FUSE_SOLID_MAX_LINES", "77"]]); + const envContent = readFileSync(envFilePath, "utf8"); + expect(envContent).not.toContain("CLAUDE_CODE"); + }); + + test("idempotence: re-running the env writer with identical values does not duplicate lines", async () => { + const pairs: [string, string][] = [["FUSE_HARNESS_REFS", "solid-generic"]]; + await writeEnvVarsInSandbox(pairs); + await writeEnvVarsInSandbox(pairs); + + const envContent = readFileSync(envFilePath, "utf8"); + const occurrences = envContent.split('export FUSE_HARNESS_REFS="solid-generic"').length - 1; + expect(occurrences).toBe(1); + }); +}); + +describe("saveEnvFile: preserves existing file content (line-preserving writer)", () => { + test("upsertEnvVar keeps comments, blank lines, and non-export lines verbatim", async () => { + const seed = + "# my personal comment - do not remove\n" + + "STRIPE_KEY=sk_live_handpasted_no_export\n" + + "\n" + + 'export FUSE_SOLID_MAX_LINES="90"\n' + + "EMPTY_VAL="; + seedEnvFile(seed); + + await runOpsInSandbox([["FUSE_NEW", "42"]]); + + const envContent = readFileSync(envFilePath, "utf8"); + expect(envContent).toBe(`${seed}\nexport FUSE_NEW="42"\n`); + }); + + test("removeEnvVar drops only the target key's line, rest of file intact", async () => { + const seed = 'export KEEPME="1"\nexport K="v"\n# trailing comment'; + seedEnvFile(seed); + + await runOpsInSandbox([["K"]]); + + const envContent = readFileSync(envFilePath, "utf8"); + expect(envContent).toBe('export KEEPME="1"\n# trailing comment\n'); + expect(envContent).not.toContain("export K="); + }); + + test("loadEnvFile parses a mixed-format file correctly (reader-only check, no ops run — loadEnvFile's regex/logic is untouched by this fix, see saveEnvFile diff)", async () => { + const seed = [ + "# comment", + 'export A="1"', + "export_thing=y", + "B=2", + 'export C="3" # trailing comment', + "export D='single'", + "", + "export E=", + ].join("\n"); + seedEnvFile(seed); + + const loaded = await runOpsInSandbox([], true); + + // Frozen expected value, captured from loadEnvFile's untouched regex/logic. + expect(loaded).toEqual({ A: "1", C: "3", D: "single", E: "" }); + }); + + test("repeated upserts never accumulate a trailing blank line", async () => { + await runOpsInSandbox([["FUSE_HARNESS_REFS", "solid-generic"]]); + await runOpsInSandbox([["FUSE_HARNESS_REFS", "solid-generic"]]); + await runOpsInSandbox([["FUSE_HARNESS_REFS", "solid-generic"]]); + + const envContent = readFileSync(envFilePath, "utf8"); + expect(envContent).toBe('export FUSE_HARNESS_REFS="solid-generic"\n'); + }); + + test("a seeded __proto__ line is dropped cleanly on upsert, not corrupted (Object.hasOwn guard)", async () => { + const seed = 'export __proto__="topsecret"\nexport A="1"\n'; + seedEnvFile(seed); + + await runOpsInSandbox([["FUSE_X", "1"]]); + + const envContent = readFileSync(envFilePath, "utf8"); + expect(envContent).toBe('export A="1"\nexport FUSE_X="1"\n'); + expect(envContent).not.toContain("[object Object]"); + expect(envContent).not.toContain("topsecret"); + }); + + test('removeEnvVar("__proto__") drops the seeded line (matches Object.entries-only semantics)', async () => { + seedEnvFile('export __proto__="topsecret"\n'); + + await runOpsInSandbox([["__proto__"]]); + + const envContent = readFileSync(envFilePath, "utf8"); + expect(envContent).not.toContain("topsecret"); + expect(envContent).not.toContain("__proto__"); + }); + + test("upsert into an empty or newline-only existing file never leaves a leading blank line", async () => { + seedEnvFile(""); + await runOpsInSandbox([["FUSE_X", "1"]]); + expect(readFileSync(envFilePath, "utf8")).toBe('export FUSE_X="1"\n'); + + seedEnvFile("\n"); + await runOpsInSandbox([["FUSE_Y", "2"]]); + expect(readFileSync(envFilePath, "utf8")).toBe('export FUSE_Y="2"\n'); + }); +}); diff --git a/.cursor-plugin/scripts/src/__tests__/env-loader-fuse-filter-sandbox.test.ts b/.cursor-plugin/scripts/src/__tests__/env-loader-fuse-filter-sandbox.test.ts new file mode 100644 index 0000000..70db6d9 --- /dev/null +++ b/.cursor-plugin/scripts/src/__tests__/env-loader-fuse-filter-sandbox.test.ts @@ -0,0 +1,119 @@ +/** + * E2E sandbox test for the shell env loaders in scripts/env-shell/: every one + * of them must export the API keys from ~/.cursor/.fusengine-global/.env while SKIPPING FUSE_*. + * + * FUSE_* are per-harness (FUSE_HARNESS_REFS names ONE harness' rules tree) and + * the harness never overwrites a key already in process.env, so a leaked + * export silently makes another agent read Claude's rules instead of its own. + * + * Real HOME is never touched: each case runs against a mkdtemp() sandbox. + * See env-file-writer-sandbox.test.ts for the ~/.cursor/.fusengine-global/.env writer coverage — + * the writer must KEEP writing FUSE_* there; only the EXPORT is filtered. + */ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + ENV_SHELL_DIR, + EXPECTED, + emittedPosixBlock, + PROBE, + runProbe, + seedSandboxEnv, +} from "./helpers/shell-loader-probe"; + +const FISH_INSTALL_CLI = `${import.meta.dir}/fixtures/fish-install-cli.ts`; + +let tmpHome: string; + +beforeEach(() => { + tmpHome = mkdtempSync(join(tmpdir(), "fusengine-env-loader-sandbox-")); + seedSandboxEnv(tmpHome); +}); + +afterEach(() => { + rmSync(tmpHome, { recursive: true, force: true }); +}); + +describe("shell loaders: FUSE_* filtered, API keys and quoting preserved", () => { + // A `for` loop rather than test.each: bun:test has no per-row skip inside + // each(), and each case must skip on its own when its shell is missing — + // runProbe spawns argv[0] directly, so an absent binary throws ENOENT and + // FAILS the test instead of skipping it (ubuntu-latest ships neither zsh nor + // fish). CI installs both, so a skip there means a missing tool, never + // silent loss of coverage. + const LOADER_CASES: Array<[string, string[], string]> = [ + ["bash", ["bash", "--noprofile", "--norc", "-c"], "cursor-env.bash"], + ["zsh", ["zsh", "-f", "-c"], "cursor-env.zsh"], + ["sh (POSIX)", ["sh", "-c"], "cursor-env.bash"], + ]; + + for (const [name, argv, file] of LOADER_CASES) { + test.skipIf(!Bun.which(argv[0]))( + `${name} loader skips FUSE_* and keeps every other key verbatim`, + async () => { + const loader = join(ENV_SHELL_DIR, file); + const got = await runProbe([...argv, `. "${loader}"\n${PROBE}`], tmpHome); + expect(got).toEqual(EXPECTED); + }, + ); + } + + test("the block install-env.sh appends to an rc file filters FUSE_* too", async () => { + const rcFile = join(tmpHome, "fake_rc.sh"); + writeFileSync(rcFile, emittedPosixBlock()); + const got = await runProbe(["bash", "--noprofile", "--norc", "-c", `. "${rcFile}"\n${PROBE}`], tmpHome); + expect(got).toEqual(EXPECTED); + }); + + test("the BASH_ENV shim filters FUSE_* through the channel bash actually uses", async () => { + const shim = join(tmpHome, ".cursor", "bash-env-loader.sh"); + writeFileSync(shim, readFileSync(join(ENV_SHELL_DIR, "bash-env-loader.sh"), "utf8")); + const got = await runProbe(["bash", "-c", PROBE], tmpHome, { BASH_ENV: shim }); + expect(got).toEqual(EXPECTED); + }); + + test("the BASH_ENV shim is sourced, so it must never call exit", () => { + const shim = readFileSync(join(ENV_SHELL_DIR, "bash-env-loader.sh"), "utf8"); + expect(shim).not.toMatch(/^\s*exit\b/m); + }); + + test.skipIf(!Bun.which("fish"))("fish loader filters FUSE_* and points BASH_ENV at the shim", async () => { + const cfg = join(ENV_SHELL_DIR, "cursor-env.fish"); + // PROBE goes through a file, never inline: it contains single quotes, + // which would close fish's own quoting and turn `` into a redirect. + const probeFile = join(tmpHome, "probe.sh"); + writeFileSync(probeFile, PROBE); + // BASH_ENV is cleared first so the child bash reports what FISH exported, + // not what the shim would re-add on its own. + const got = await runProbe( + ["fish", "--no-config", "-c", `source "${cfg}"; set -e BASH_ENV; exec bash --noprofile --norc "${probeFile}"`], + tmpHome, + ); + expect(got).toEqual(EXPECTED); + + const bashEnv = await runProbe( + ["fish", "--no-config", "-c", `source "${cfg}"; echo BASH_ENV=$BASH_ENV`], + tmpHome, + ); + expect(bashEnv.BASH_ENV).toBe(join(tmpHome, ".cursor", "bash-env-loader.sh")); + }); +}); + +describe("installFishConfig installs the shim its BASH_ENV points at", () => { + test("both the fish conf.d snippet and ~/.cursor/bash-env-loader.sh land in HOME", async () => { + const proc = Bun.spawn([process.execPath, FISH_INSTALL_CLI], { + env: { ...process.env, HOME: tmpHome, USERPROFILE: tmpHome }, + stdout: "pipe", + stderr: "pipe", + }); + const exitCode = await proc.exited; + if (exitCode !== 0) { + throw new Error(`fish-install-cli exited ${exitCode}: ${await new Response(proc.stderr).text()}`); + } + + expect(existsSync(join(tmpHome, ".config", "fish", "conf.d", "cursor-env.fish"))).toBe(true); + expect(existsSync(join(tmpHome, ".cursor", "bash-env-loader.sh"))).toBe(true); + }); +}); diff --git a/.cursor-plugin/scripts/src/__tests__/env-manager.test.ts b/.cursor-plugin/scripts/src/__tests__/env-manager.test.ts new file mode 100644 index 0000000..5d4b21b --- /dev/null +++ b/.cursor-plugin/scripts/src/__tests__/env-manager.test.ts @@ -0,0 +1,70 @@ +/** + * Tests for env-manager service (saveEnvFile + shell detection) + * loadEnvFile parsing lives in env-file-load.test.ts + */ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; + +// Mock HOME for isolated testing (file-persistence tests only) +const TEST_HOME = "/tmp/fusengine-test-home"; +const TEST_ENV_FILE = join(TEST_HOME, ".cursor", ".env"); + +describe("saveEnvFile (logic test)", () => { + beforeEach(() => { + mkdirSync(join(TEST_HOME, ".cursor"), { recursive: true }); + }); + + afterEach(() => { + rmSync(TEST_HOME, { recursive: true, force: true }); + }); + + test("writes env variables in correct format", () => { + const env = { + KEY1: "value1", + KEY2: "value2", + }; + + const lines = Object.entries(env) + .filter(([_, value]) => value) + .map(([key, value]) => `export ${key}="${value}"`); + + const content = `${lines.join("\n")}\n`; + writeFileSync(TEST_ENV_FILE, content); + + const written = readFileSync(TEST_ENV_FILE, "utf8"); + expect(written).toContain('export KEY1="value1"'); + expect(written).toContain('export KEY2="value2"'); + }); + + test("filters out empty values", () => { + const env = { + FILLED: "has-value", + EMPTY: "", + }; + + const lines = Object.entries(env) + .filter(([_, value]) => value) + .map(([key, value]) => `export ${key}="${value}"`); + + expect(lines).toHaveLength(1); + expect(lines[0]).toContain("FILLED"); + }); +}); + +// Pure string logic — deliberately outside the filesystem fixture above. +describe("shell detection (logic test)", () => { + test("detects shell from SHELL env var", () => { + const testCases = [ + { shell: "/bin/zsh", expected: "zsh" }, + { shell: "/bin/bash", expected: "bash" }, + { shell: "/usr/bin/fish", expected: "fish" }, + { shell: "/usr/local/bin/pwsh", expected: "pwsh" }, + ]; + + for (const { shell, expected } of testCases) { + const detected = shell.split("/").pop() || "bash"; + expect(detected).toBe(expected); + } + }); +}); diff --git a/.cursor-plugin/scripts/src/__tests__/fixtures/env-writer-cli.ts b/.cursor-plugin/scripts/src/__tests__/fixtures/env-writer-cli.ts new file mode 100644 index 0000000..72eb76c --- /dev/null +++ b/.cursor-plugin/scripts/src/__tests__/fixtures/env-writer-cli.ts @@ -0,0 +1,29 @@ +/** + * Sandbox CLI fixture: writes/removes FUSE_* vars via the real env-file + * writer, and optionally prints the real reader's output. Spawned as a + * subprocess with HOME pointed at a temp dir so the module-level ENV_FILE + * const in env-file.ts resolves to the sandbox home, never the real + * ~/.cursor/.fusengine-global/.env (that const is computed once at import time, so it must + * be set before the process starts, not after). + * + * Ops: 2-tuple `[name, value]` -> upsertEnvVar; 1-tuple `[name]` -> removeEnvVar. + * Invoked as: bun env-writer-cli.ts '[["FUSE_X","1"],["FUSE_Y"]]' [--load] + * With `--load`, prints `JSON.stringify(loadEnvFile())` to stdout after ops. + */ +import { loadEnvFile, removeEnvVar, upsertEnvVar } from "../../services/env-file"; + +type Op = [string, string] | [string]; + +const ops = JSON.parse(process.argv[2] ?? "[]") as Op[]; + +for (const op of ops) { + if (op.length === 2) { + upsertEnvVar(op[0], op[1]); + } else { + removeEnvVar(op[0]); + } +} + +if (process.argv[3] === "--load") { + process.stdout.write(JSON.stringify(loadEnvFile())); +} diff --git a/.cursor-plugin/scripts/src/__tests__/fixtures/fish-install-cli.ts b/.cursor-plugin/scripts/src/__tests__/fixtures/fish-install-cli.ts new file mode 100644 index 0000000..1a51791 --- /dev/null +++ b/.cursor-plugin/scripts/src/__tests__/fixtures/fish-install-cli.ts @@ -0,0 +1,14 @@ +/** + * Sandbox CLI fixture: runs the REAL installFishConfig() so the test can check + * that it installs both the fish conf.d snippet AND the BASH_ENV shim. + * + * Spawned as a subprocess with HOME pointed at a temp dir: the HOME const in + * shell-installers.ts is resolved once at import time, so it must be set + * before the process starts, not after. The real ~/.config and ~/.cursor are + * therefore never touched. + * + * Invoked as: bun fish-install-cli.ts + */ +import { installFishConfig } from "../../services/shell-installers"; + +installFishConfig(); diff --git a/.cursor-plugin/scripts/src/__tests__/fixtures/hook-fixture.mjs b/.cursor-plugin/scripts/src/__tests__/fixtures/hook-fixture.mjs new file mode 100644 index 0000000..6bd1774 --- /dev/null +++ b/.cursor-plugin/scripts/src/__tests__/fixtures/hook-fixture.mjs @@ -0,0 +1,38 @@ +/** + * Deterministic test hook fixture (no shell required). + * Invoked as `bun hook-fixture.mjs [arg]`; behaviour selected by . + * Used by hook-executor tests to exercise the shell-free spawn path cross-platform. + */ +const mode = process.argv[2]; +const arg = process.argv[3]; + +switch (mode) { + case "stdout": + process.stdout.write("hello-world"); + break; + case "ctx": + process.stdout.write(JSON.stringify({ additionalContext: arg })); + break; + case "plain": + process.stdout.write("plain text output"); + break; + case "block": + process.stderr.write("blocked reason"); + process.exit(2); + case "fail": + process.stderr.write("boom"); + process.exit(3); + case "stdin": { + const chunks = []; + for await (const c of process.stdin) chunks.push(c); + process.stdout.write(`STDIN:${Buffer.concat(chunks).toString()}`); + break; + } + case "sleep": + await new Promise((r) => setTimeout(r, 50)); + process.stdout.write(arg || "slept"); + break; + default: + break; +} +process.exit(0); diff --git a/.cursor-plugin/scripts/src/__tests__/fs-helpers.test.ts b/.cursor-plugin/scripts/src/__tests__/fs-helpers.test.ts new file mode 100644 index 0000000..d971926 --- /dev/null +++ b/.cursor-plugin/scripts/src/__tests__/fs-helpers.test.ts @@ -0,0 +1,203 @@ +/** + * Tests for fs-helpers utilities + */ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { + existsSync, + mkdirSync, + rmSync, + statSync, + writeFileSync, +} from "node:fs"; +import { join } from "node:path"; +import { copyExecutable, copyFile, filesAreEqual } from "../utils/fs-helpers"; + +const TEST_DIR = "/tmp/fusengine-test-fs"; + +describe("fs-helpers", () => { + beforeEach(() => { + mkdirSync(TEST_DIR, { recursive: true }); + }); + + afterEach(() => { + rmSync(TEST_DIR, { recursive: true, force: true }); + }); + + describe("copyFile", () => { + test("returns false if source does not exist", () => { + const result = copyFile( + join(TEST_DIR, "nonexistent.txt"), + join(TEST_DIR, "dest.txt"), + ); + expect(result).toBe(false); + }); + + test("copies file to destination", () => { + const src = join(TEST_DIR, "source.txt"); + const dest = join(TEST_DIR, "dest.txt"); + writeFileSync(src, "hello world"); + + const result = copyFile(src, dest); + + expect(result).toBe(true); + expect(existsSync(dest)).toBe(true); + }); + + test("creates destination directory if not exists", () => { + const src = join(TEST_DIR, "source.txt"); + const dest = join(TEST_DIR, "nested/deep/dest.txt"); + writeFileSync(src, "content"); + + const result = copyFile(src, dest); + + expect(result).toBe(true); + expect(existsSync(dest)).toBe(true); + }); + + test("preserves file content", () => { + const src = join(TEST_DIR, "source.txt"); + const dest = join(TEST_DIR, "dest.txt"); + const content = "important content\nwith newlines"; + writeFileSync(src, content); + + copyFile(src, dest); + + const copied = require("node:fs").readFileSync(dest, "utf8"); + expect(copied).toBe(content); + }); + + test("overwrites existing destination", () => { + const src = join(TEST_DIR, "source.txt"); + const dest = join(TEST_DIR, "dest.txt"); + writeFileSync(src, "new content"); + writeFileSync(dest, "old content"); + + copyFile(src, dest); + + const copied = require("node:fs").readFileSync(dest, "utf8"); + expect(copied).toBe("new content"); + }); + }); + + describe("copyExecutable", () => { + test("returns false if source does not exist", async () => { + const result = await copyExecutable( + join(TEST_DIR, "nonexistent.sh"), + join(TEST_DIR, "dest.sh"), + ); + expect(result).toBe(false); + }); + + test("copies file and makes it executable", async () => { + const src = join(TEST_DIR, "script.sh"); + const dest = join(TEST_DIR, "dest.sh"); + writeFileSync(src, "#!/bin/bash\necho hello"); + + const result = await copyExecutable(src, dest); + + expect(result).toBe(true); + expect(existsSync(dest)).toBe(true); + + // Check executable bit (on Unix) + if (process.platform !== "win32") { + const stats = statSync(dest); + const isExecutable = (stats.mode & 0o111) !== 0; + expect(isExecutable).toBe(true); + } + }); + + test("creates destination directory", async () => { + const src = join(TEST_DIR, "script.sh"); + const dest = join(TEST_DIR, "bin/nested/script.sh"); + writeFileSync(src, "#!/bin/bash"); + + const result = await copyExecutable(src, dest); + + expect(result).toBe(true); + expect(existsSync(dest)).toBe(true); + }); + }); + + describe("filesAreEqual", () => { + test("returns false if first file does not exist", async () => { + const existing = join(TEST_DIR, "existing.txt"); + writeFileSync(existing, "content"); + + const result = await filesAreEqual( + join(TEST_DIR, "nonexistent.txt"), + existing, + ); + + expect(result).toBe(false); + }); + + test("returns false if second file does not exist", async () => { + const existing = join(TEST_DIR, "existing.txt"); + writeFileSync(existing, "content"); + + const result = await filesAreEqual( + existing, + join(TEST_DIR, "nonexistent.txt"), + ); + + expect(result).toBe(false); + }); + + test("returns true for identical files", async () => { + const file1 = join(TEST_DIR, "file1.txt"); + const file2 = join(TEST_DIR, "file2.txt"); + const content = "identical content\nwith multiple lines"; + writeFileSync(file1, content); + writeFileSync(file2, content); + + const result = await filesAreEqual(file1, file2); + + expect(result).toBe(true); + }); + + test("returns false for different files", async () => { + const file1 = join(TEST_DIR, "file1.txt"); + const file2 = join(TEST_DIR, "file2.txt"); + writeFileSync(file1, "content A"); + writeFileSync(file2, "content B"); + + const result = await filesAreEqual(file1, file2); + + expect(result).toBe(false); + }); + + test("returns false for files with different whitespace", async () => { + const file1 = join(TEST_DIR, "file1.txt"); + const file2 = join(TEST_DIR, "file2.txt"); + writeFileSync(file1, "content"); + writeFileSync(file2, "content "); + + const result = await filesAreEqual(file1, file2); + + expect(result).toBe(false); + }); + + test("handles empty files", async () => { + const file1 = join(TEST_DIR, "empty1.txt"); + const file2 = join(TEST_DIR, "empty2.txt"); + writeFileSync(file1, ""); + writeFileSync(file2, ""); + + const result = await filesAreEqual(file1, file2); + + expect(result).toBe(true); + }); + + test("handles large files", async () => { + const file1 = join(TEST_DIR, "large1.txt"); + const file2 = join(TEST_DIR, "large2.txt"); + const largeContent = "x".repeat(100000); + writeFileSync(file1, largeContent); + writeFileSync(file2, largeContent); + + const result = await filesAreEqual(file1, file2); + + expect(result).toBe(true); + }); + }); +}); diff --git a/.cursor-plugin/scripts/src/__tests__/harness-path-resolver.test.ts b/.cursor-plugin/scripts/src/__tests__/harness-path-resolver.test.ts new file mode 100644 index 0000000..e0c2a0f --- /dev/null +++ b/.cursor-plugin/scripts/src/__tests__/harness-path-resolver.test.ts @@ -0,0 +1,52 @@ +/** + * Tests for resolveHomeInHooks: install-time `$HOME` resolution in deployed + * hooks.json. Proves (a) substitution by the resolved HOME and (b) idempotence. + * Isolated under /tmp/fusengine-test-resolver so the repo SOURCE stays untouched + * (mirrors the env-file-load.test.ts / env-manager.test.ts fixture convention). + */ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { resolveHomeInHooks } from "../services/harness-path-resolver"; + +const HOME = process.env.HOME || process.env.USERPROFILE || ""; +const TEST_DIR = "/tmp/fusengine-test-resolver"; +const HOOKS_FILE = join(TEST_DIR, "sample-plugin", "hooks", "hooks.json"); +const HOOK_CMD = "bun $HOME/.cursor/plugins/local/node_modules/@fusengine/harness/dist/cli/bin.mjs hook cursor aipilot"; + +/** Seed a fresh temp pluginsDir holding one plugin with a `$HOME` hooks.json. */ +beforeEach(() => { + rmSync(TEST_DIR, { recursive: true, force: true }); + mkdirSync(join(TEST_DIR, "sample-plugin", "hooks"), { recursive: true }); + const cfg = { hooks: { PreToolUse: [{ hooks: [{ type: "command", command: HOOK_CMD }] }] } }; + writeFileSync(HOOKS_FILE, JSON.stringify(cfg, null, 2)); +}); + +afterEach(() => rmSync(TEST_DIR, { recursive: true, force: true })); + +describe("resolveHomeInHooks", () => { + test("substitutes $HOME with the resolved absolute home path", async () => { + const rewritten = await resolveHomeInHooks(TEST_DIR); + + expect(rewritten).toBe(1); + const out = readFileSync(HOOKS_FILE, "utf8"); + expect(out).not.toContain("$HOME"); + expect(out).toContain(`bun ${HOME}/.cursor/plugins`); + }); + + test("also resolves the `${HOME}` brace form", async () => { + writeFileSync(HOOKS_FILE, JSON.stringify({ cmd: "x ${HOME}/y" })); + + await resolveHomeInHooks(TEST_DIR); + + const out = readFileSync(HOOKS_FILE, "utf8"); + expect(out).toContain(`x ${HOME}/y`); + expect(out).not.toContain("${HOME}"); + }); + + test("is idempotent: a second pass rewrites nothing", async () => { + expect(await resolveHomeInHooks(TEST_DIR)).toBe(1); + expect(await resolveHomeInHooks(TEST_DIR)).toBe(0); + expect(readFileSync(HOOKS_FILE, "utf8")).not.toContain("$HOME"); + }); +}); diff --git a/.cursor-plugin/scripts/src/__tests__/helpers/hook-fixture.ts b/.cursor-plugin/scripts/src/__tests__/helpers/hook-fixture.ts new file mode 100644 index 0000000..6decfd6 --- /dev/null +++ b/.cursor-plugin/scripts/src/__tests__/helpers/hook-fixture.ts @@ -0,0 +1,30 @@ +/** + * Test helper: build ExecutableHook commands that invoke the shell-free .mjs fixture. + * Shared across the hook-executor test files to keep them DRY. + */ +import type { ExecutableHook } from "../../interfaces/hooks"; + +/** Absolute path to the deterministic test fixture (resolved from this file's dir). */ +export const FIXTURE_PATH = `${import.meta.dir}/../fixtures/hook-fixture.mjs`; + +/** + * Build a hook command that runs the fixture in `mode` (no shell involved). + * + * @param mode Fixture behaviour: stdout | ctx | plain | block | fail | stdin | sleep. + * @param extra Optional single extra token appended (no whitespace inside it). + * @param isAsync Whether the produced hook is fire-and-forget. + * @returns An ExecutableHook whose command targets the fixture. + */ +export function fixtureHook( + mode: string, + extra = "", + isAsync = false, +): ExecutableHook { + const tail = extra ? ` ${extra}` : ""; + return { + command: `bun ${FIXTURE_PATH} ${mode}${tail}`, + isAsync, + pluginName: "test", + pluginPath: "/test/plugins/test", + }; +} diff --git a/.cursor-plugin/scripts/src/__tests__/helpers/shell-loader-probe.ts b/.cursor-plugin/scripts/src/__tests__/helpers/shell-loader-probe.ts new file mode 100644 index 0000000..8cbc808 --- /dev/null +++ b/.cursor-plugin/scripts/src/__tests__/helpers/shell-loader-probe.ts @@ -0,0 +1,98 @@ +/** + * Helpers for the shell env-loader sandbox tests: seed a temp HOME with a + * fixture ~/.cursor/.fusengine-global/.env, run a REAL loader from scripts/env-shell/ in a + * subprocess, and report which keys it exported. + * + * The child env is built from scratch rather than spread from process.env: + * the developer's own BASH_ENV would otherwise make bash source the real + * ~/.cursor/.fusengine-global/.env and turn a passing filter into a false failure. + */ +import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; + +export const ENV_SHELL_DIR = join(import.meta.dir, "..", "..", "..", "env-shell"); + +/** Fixture .env: FUSE_* to filter, an API key to keep, and quoting edge cases. */ +export const FIXTURE_ENV = `export FUSE_HARNESS_REFS="/some/claude/path" +export FUSE_SOLID_MAX_LINES=100 +export CONTEXT7_API_KEY="ctx7-abc123" +QUOTED_HASH="a#b" +TRAIL=value # comment +SINGLE='single quoted' +SPACED="two words" +# pure comment line +`; + +/** Keys the probe reports on, in order. */ +export const PROBE_KEYS = [ + "FUSE_HARNESS_REFS", + "FUSE_SOLID_MAX_LINES", + "CONTEXT7_API_KEY", + "QUOTED_HASH", + "TRAIL", + "SINGLE", + "SPACED", +] as const; + +/** What every loader must produce from FIXTURE_ENV: no FUSE_*, values exact. */ +export const EXPECTED: Record = { + FUSE_HARNESS_REFS: "", + FUSE_SOLID_MAX_LINES: "", + CONTEXT7_API_KEY: "ctx7-abc123", + QUOTED_HASH: "a#b", + TRAIL: "value", + SINGLE: "single quoted", + SPACED: "two words", +}; + +/** POSIX snippet printing `KEY=value`, or `KEY=` when unset. */ +export const PROBE = PROBE_KEYS.map( + (k) => + `if [ -n "\${${k}+x}" ]; then printf '%s=%s\\n' ${k} "$${k}"; else printf '%s=\\n' ${k}; fi`, +).join("\n"); + +/** Write the fixture .env into /.cursor/.fusengine-global/.env and return its path. */ +export function seedSandboxEnv(home: string): string { + const envPath = join(home, ".cursor", ".env"); + mkdirSync(dirname(envPath), { recursive: true }); + writeFileSync(envPath, FIXTURE_ENV); + return envPath; +} + +/** + * Extract the loader block install-env.sh appends to .bashrc/.zshrc, i.e. the + * text between the quoted heredoc delimiters — exactly what lands on disk. + */ +export function emittedPosixBlock(): string { + const script = readFileSync(join(ENV_SHELL_DIR, "install-env.sh"), "utf8"); + const marker = "<< 'POSIX_LOADER'\n"; + const body = script.slice(script.indexOf(marker) + marker.length); + return body.slice(0, body.indexOf("\nPOSIX_LOADER\n")); +} + +/** Run argv with a pristine env (HOME = sandbox) and parse the probe output. */ +export async function runProbe( + argv: string[], + home: string, + extraEnv: Record = {}, +): Promise> { + const proc = Bun.spawn(argv, { + env: { HOME: home, PATH: process.env.PATH ?? "", ...extraEnv }, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, exitCode] = await Promise.all([ + new Response(proc.stdout).text(), + proc.exited, + ]); + if (exitCode !== 0) { + const stderr = await new Response(proc.stderr).text(); + throw new Error(`probe exited ${exitCode}: ${stderr}`); + } + return Object.fromEntries( + stdout + .split("\n") + .filter(Boolean) + .map((l) => [l.slice(0, l.indexOf("=")), l.slice(l.indexOf("=") + 1)]), + ); +} diff --git a/.cursor-plugin/scripts/src/__tests__/hook-executor-collect.test.ts b/.cursor-plugin/scripts/src/__tests__/hook-executor-collect.test.ts new file mode 100644 index 0000000..d835b14 --- /dev/null +++ b/.cursor-plugin/scripts/src/__tests__/hook-executor-collect.test.ts @@ -0,0 +1,64 @@ +/** + * Tests for executeHooks (parallel run + output collection, shell-free). + * + * `executeHooks` returns a structured `outcome` ({permission, text, updatedInput}) + * rather than a raw JSON string: the loader translates it into Cursor's + * per-event response shape. These tests assert on that outcome. + */ +import { describe, expect, test } from "bun:test"; +import { executeHooks } from "../services/hook-executor"; +import { fixtureHook } from "./helpers/hook-fixture"; + +describe("executeHooks", () => { + test("returns empty result for no hooks", async () => { + const result = await executeHooks([], "{}"); + expect(result.blocked).toBe(false); + expect(result.outcome.text).toBe(""); + }); + + test("collects context text emitted by a hook", async () => { + const result = await executeHooks([fixtureHook("ctx", "extra-info")], "{}"); + expect(result.blocked).toBe(false); + expect(result.outcome.text).toContain("extra-info"); + }); + + test("ignores non-JSON output", async () => { + const result = await executeHooks([fixtureHook("plain")], "{}"); + expect(result.blocked).toBe(false); + expect(result.outcome.text).toBe(""); + }); + + test("detects blocked hook (exit 2)", async () => { + const result = await executeHooks([fixtureHook("block")], "{}"); + expect(result.blocked).toBe(true); + expect(result.stderr).toContain("blocked reason"); + }); + + test("executes multiple hooks in parallel", async () => { + const hooks = [fixtureHook("sleep"), fixtureHook("sleep"), fixtureHook("sleep")]; + const start = Date.now(); + const result = await executeHooks(hooks, "{}"); + const duration = Date.now() - start; + expect(result.blocked).toBe(false); + // Sequential would be 150ms+; parallel ~50ms (generous CI margin). + expect(duration).toBeLessThan(140); + }); + + test("first blocked hook stops with blocked result", async () => { + const hooks = [fixtureHook("stdout"), fixtureHook("block"), fixtureHook("stdout")]; + const result = await executeHooks(hooks, "{}"); + expect(result.blocked).toBe(true); + }); + + test("handles failing command gracefully (exit != 2 not blocked)", async () => { + const result = await executeHooks([fixtureHook("fail")], "{}"); + expect(result.blocked).toBe(false); + }); + + test("merges context text from multiple hooks", async () => { + const hooks = [fixtureHook("ctx", "info1"), fixtureHook("ctx", "info2")]; + const result = await executeHooks(hooks, "{}"); + expect(result.outcome.text).toContain("info1"); + expect(result.outcome.text).toContain("info2"); + }); +}); diff --git a/.cursor-plugin/scripts/src/__tests__/hook-executor-equivalence.test.ts b/.cursor-plugin/scripts/src/__tests__/hook-executor-equivalence.test.ts new file mode 100644 index 0000000..9a48fdd --- /dev/null +++ b/.cursor-plugin/scripts/src/__tests__/hook-executor-equivalence.test.ts @@ -0,0 +1,51 @@ +/** + * Non-regression proof: for representative hook commands, the new shell-free spawn + * must produce the SAME effective exitCode + stdout + stderr as the old `bash -c` path. + * Skips cleanly when bash is absent (e.g. Windows CI) so it never breaks that platform. + */ +import { describe, expect, test } from "bun:test"; +import { executeHook } from "../services/hook-executor"; +import { FIXTURE_PATH } from "./helpers/hook-fixture"; + +const bash = Bun.which("bash"); + +/** Old behaviour: run the command exactly as before, via `bash -c`. */ +async function runViaBash( + command: string, + input: string, +): Promise<{ exitCode: number; stdout: string; stderr: string }> { + const proc = Bun.spawn(["bash", "-c", command], { + stdin: new TextEncoder().encode(input), + stdout: "pipe", + stderr: "pipe", + }); + const exitCode = await proc.exited; + const stdout = await new Response(proc.stdout).text(); + const stderr = await new Response(proc.stderr).text(); + return { exitCode, stdout, stderr }; +} + +/** Representative commands: exit 0/2, `|| true`, and a `--sound`-style multi-arg. */ +const CASES: Array<{ name: string; cmd: string; input?: string }> = [ + { name: "exit 0 (stdout)", cmd: `bun ${FIXTURE_PATH} stdout` }, + { name: "exit 2 (blocked)", cmd: `bun ${FIXTURE_PATH} block` }, + { name: "fail with `|| true`", cmd: `bun ${FIXTURE_PATH} fail || true` }, + { name: "extra --sound arg", cmd: `bun ${FIXTURE_PATH} stdout --sound ping` }, + { name: "stdin passthrough", cmd: `bun ${FIXTURE_PATH} stdin`, input: "EQUIV-INPUT" }, +]; + +describe.skipIf(!bash)("hook-executor equivalence (bash vs shell-free)", () => { + for (const c of CASES) { + test(`identical result: ${c.name}`, async () => { + const input = c.input ?? "{}"; + const old = await runViaBash(c.cmd, input); + const next = await executeHook( + { command: c.cmd, isAsync: false, pluginName: "eq", pluginPath: "/test/plugins/eq" }, + input, + ); + expect(next.exitCode).toBe(old.exitCode); + expect(next.stdout).toBe(old.stdout); + expect(next.stderr).toBe(old.stderr); + }); + } +}); diff --git a/.cursor-plugin/scripts/src/__tests__/hook-executor.test.ts b/.cursor-plugin/scripts/src/__tests__/hook-executor.test.ts new file mode 100644 index 0000000..d03580b --- /dev/null +++ b/.cursor-plugin/scripts/src/__tests__/hook-executor.test.ts @@ -0,0 +1,82 @@ +/** + * Tests for executeHook (shell-free: no bash, Windows-compatible). + * Commands run a deterministic .mjs fixture via `bun `. + */ +import { describe, expect, test } from "bun:test"; +import type { ExecutableHook } from "../interfaces/hooks"; +import { executeHook } from "../services/hook-executor"; +import { FIXTURE_PATH, fixtureHook } from "./helpers/hook-fixture"; + +describe("executeHook", () => { + test("captures stdout and success on exit 0", async () => { + const result = await executeHook(fixtureHook("stdout"), "{}"); + expect(result.success).toBe(true); + expect(result.stdout).toContain("hello-world"); + expect(result.exitCode).toBe(0); + expect(result.blocked).toBe(false); + }); + + test("captures stderr and blocked status on exit 2", async () => { + const result = await executeHook(fixtureHook("block"), "{}"); + expect(result.blocked).toBe(true); + expect(result.exitCode).toBe(2); + expect(result.stderr).toContain("blocked reason"); + }); + + test("non-zero exit without `|| true` is failure but not blocked", async () => { + const result = await executeHook(fixtureHook("fail"), "{}"); + expect(result.success).toBe(false); + expect(result.exitCode).toBe(3); + expect(result.blocked).toBe(false); + }); + + test("`|| true` forces success even when the process exits non-zero", async () => { + const h: ExecutableHook = { + command: `bun ${FIXTURE_PATH} fail || true`, + isAsync: false, + pluginName: "test", + pluginPath: "/test/plugins/test", + }; + const result = await executeHook(h, "{}"); + expect(result.success).toBe(true); + expect(result.exitCode).toBe(0); + expect(result.blocked).toBe(false); + // stderr is still captured (printed before `|| true` swallows the exit code). + expect(result.stderr).toContain("boom"); + }); + + test("`|| true` also neutralises exit 2 (never blocked, matching bash)", async () => { + const h: ExecutableHook = { + command: `bun ${FIXTURE_PATH} block || true`, + isAsync: false, + pluginName: "test", + pluginPath: "/test/plugins/test", + }; + const result = await executeHook(h, "{}"); + expect(result.blocked).toBe(false); + expect(result.exitCode).toBe(0); + }); + + test("transmits stdin to the process", async () => { + const result = await executeHook(fixtureHook("stdin"), "PAYLOAD-123"); + expect(result.stdout).toContain("STDIN:PAYLOAD-123"); + }); + + test("missing executable is a non-blocking failure (ENOENT)", async () => { + const h: ExecutableHook = { + command: "nonexistent-command-xyz", + isAsync: false, + pluginName: "test", + pluginPath: "/test/plugins/test", + }; + const result = await executeHook(h, "{}"); + expect(result.blocked).toBe(false); + expect(result.success).toBe(false); + }); + + test("async hook returns success immediately", async () => { + const result = await executeHook(fixtureHook("stdout", "", true), "{}"); + expect(result.success).toBe(true); + expect(result.exitCode).toBe(0); + }); +}); diff --git a/.cursor-plugin/scripts/src/__tests__/hook-merge.test.ts b/.cursor-plugin/scripts/src/__tests__/hook-merge.test.ts new file mode 100644 index 0000000..543ead5 --- /dev/null +++ b/.cursor-plugin/scripts/src/__tests__/hook-merge.test.ts @@ -0,0 +1,96 @@ +/** + * Tests for hook-merge.mergeHookType and its use by configureHooks: + * conservative, idempotent merge that preserves user-authored hook entries. + * + * Cursor entries are flat (`{command, matcher?}`) and configureHooks writes + * ~/.cursor/hooks.json, so these tests drive a temp file rather than a settings + * object. + */ +import { describe, expect, test } from "bun:test"; +import { mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { mergeHookType } from "../services/hook-merge"; +import { configureHooks } from "../services/settings-manager"; + +const LOADER = "/x/hooks-loader.ts"; +const loaderCmd = (t: string) => `bun ${LOADER} ${t}`; + +/** Fresh hooks.json path in a temp dir, optionally seeded. */ +function tempHooks(seed?: unknown): string { + const file = join(mkdtempSync(join(tmpdir(), "fuse-hooks-")), "hooks.json"); + if (seed !== undefined) writeFileSync(file, JSON.stringify(seed, null, 2)); + return file; +} + +/** Parse the hooks map written by configureHooks. */ +function readHooks(file: string): Record { + return JSON.parse(readFileSync(file, "utf8")).hooks; +} + +describe("mergeHookType", () => { + test("adds the loader entry to an empty (undefined) list", () => { + const result = mergeHookType(undefined, LOADER, "preToolUse"); + expect(result).toEqual([{ command: loaderCmd("preToolUse") }]); + }); + + test("keeps a foreign entry and appends the loader entry", () => { + const foreign = { command: "my-tool" }; + const result = mergeHookType([foreign], LOADER, "postToolUse"); + expect(result[0]).toEqual(foreign); + expect(result).toHaveLength(2); + }); + + test("replaces a stale loader entry instead of duplicating it", () => { + const stale = mergeHookType(undefined, "/old/hooks-loader.ts", "stop"); + const result = mergeHookType(stale, LOADER, "stop"); + expect(result).toHaveLength(1); + expect((result[0] as { command: string }).command).toBe(loaderCmd("stop")); + }); +}); + +describe("configureHooks / merge", () => { + test("writes a version-1 document Cursor can read", () => { + const file = tempHooks(); + configureHooks(LOADER, file); + expect(JSON.parse(readFileSync(file, "utf8")).version).toBe(1); + }); + + test("preserves a foreign entry inside a managed hook type", () => { + const foreign = { command: "my-tool" }; + const file = tempHooks({ version: 1, hooks: { preToolUse: [foreign] } }); + configureHooks(LOADER, file); + const entries = readHooks(file).preToolUse; + expect(entries).toContainEqual(foreign); + expect(entries.at(-1)).toEqual({ command: loaderCmd("preToolUse") }); + }); + + test("is idempotent across re-runs", () => { + const file = tempHooks(); + configureHooks(LOADER, file); + const first = readHooks(file); + configureHooks(LOADER, file); + expect(readHooks(file)).toEqual(first); + }); + + test("is byte-identical on re-run when a user entry is present", () => { + const foreign = { command: "my-tool" }; + const file = tempHooks({ version: 1, hooks: { preToolUse: [foreign] } }); + configureHooks(LOADER, file); + const first = readFileSync(file, "utf8"); + configureHooks(LOADER, file); + // No duplication, no reordering: the foreign entry stays first, loader last. + expect(readFileSync(file, "utf8")).toBe(first); + expect(readHooks(file).preToolUse[0]).toEqual(foreign); + expect(readHooks(file).preToolUse.length).toBe(2); + }); + + test("does not clobber a user command that merely mentions hooks-loader", () => { + const userHook = { command: "bun my-hooks-loader-wrapper.sh" }; + const file = tempHooks({ version: 1, hooks: { stop: [userHook] } }); + configureHooks(LOADER, file); + const entries = readHooks(file).stop; + expect(entries[0]).toEqual(userHook); + expect(entries.length).toBe(2); + }); +}); diff --git a/.cursor-plugin/scripts/src/__tests__/hooks-harness-exclusive.helpers.ts b/.cursor-plugin/scripts/src/__tests__/hooks-harness-exclusive.helpers.ts new file mode 100644 index 0000000..5b4bafd --- /dev/null +++ b/.cursor-plugin/scripts/src/__tests__/hooks-harness-exclusive.helpers.ts @@ -0,0 +1,18 @@ +/** + * Pure helpers for the harness-exclusive anti-regression gate. + * Split out of hooks-harness-exclusive.test.ts to respect the 100-line SOLID limit. + */ +// discoverHooksFiles moved to shared production util; re-exported for the test. +export { discoverHooksFiles } from "../utils/hooks-discovery"; + +export const HARNESS_MARKER = "node_modules/@fusengine/harness/dist/cli/bin.mjs hook cursor"; + +/** True when a command hook delegates to the harness binary (tolerates ` || true`, flags, scope). */ +export function isHarnessCommand(command: string): boolean { + return command.startsWith("bun ") && command.includes(HARNESS_MARKER); +} + +/** Plugin name extracted from a `.../plugins//hooks/hooks.json` path. */ +export function pluginNameOf(filePath: string): string { + return filePath.split("/plugins/")[1]?.split("/")[0] ?? filePath; +} diff --git a/.cursor-plugin/scripts/src/__tests__/hooks-harness-exclusive.test.ts b/.cursor-plugin/scripts/src/__tests__/hooks-harness-exclusive.test.ts new file mode 100644 index 0000000..ece3db7 --- /dev/null +++ b/.cursor-plugin/scripts/src/__tests__/hooks-harness-exclusive.test.ts @@ -0,0 +1,96 @@ +/** + * Anti-regression gate for the "harness exclusif" migration. + * + * Every `type: "command"` hook in every `plugins//hooks/hooks.json` + * must delegate to @fusengine/harness, resolved from the marketplace's own + * shared install under plugins/marketplaces/fusengine-plugins/plugins/ + * node_modules (installDeps' node_modules — reused as-is, no separate + * per-user $HOME/.cursor/node_modules bootstrap anymore). + * Native `type: "prompt"` hooks are allowed and left untouched. + * Pure helpers live in ./hooks-harness-exclusive.helpers (kept separate to + * respect the 100-line SOLID limit on this file). + */ +import { describe, expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { + discoverHooksFiles, + isHarnessCommand, + pluginNameOf, +} from "./hooks-harness-exclusive.helpers"; + +/** Absolute prefix every harness command must resolve through post-migration. */ +const MARKETPLACE_HARNESS_PATH = + "$HOME/.cursor/plugins/local/node_modules/@fusengine/harness"; + +const PLUGINS_DIR = join(__dirname, "../../../plugins"); +const hooksFiles = discoverHooksFiles(PLUGINS_DIR); + +describe("hooks-harness-exclusive (anti-regression gate)", () => { + test("discovers at least one plugins/*/hooks/hooks.json", () => { + expect(hooksFiles.length).toBeGreaterThan(0); + }); + + for (const filePath of hooksFiles) { + const pluginName = pluginNameOf(filePath); + + describe(pluginName, () => { + // Guarded parse: an invalid hooks.json must fail the dedicated test below, + // not crash bun:test collection (a throw in a describe body drops later tests). + let config: { hooks?: Record } | undefined; + let parseError: unknown; + try { + config = JSON.parse(readFileSync(filePath, "utf-8")); + } catch (error) { + parseError = error; + } + + test("hooks.json is valid JSON", () => { + if (parseError) throw parseError; + expect(config).toBeDefined(); + }); + + const events = config?.hooks ?? {}; + + for (const [eventName, blocks] of Object.entries(events)) { + for (const [blockIdx, block] of (blocks as Array>).entries()) { + const hooks = (block.hooks as Array>) ?? []; + + for (const [hookIdx, hook] of hooks.entries()) { + const label = `${eventName}[${blockIdx}].hooks[${hookIdx}]`; + + if (hook.type === "prompt") continue; // native LLM hook, allowed as-is + + test(`${label}: type is "command"`, () => { + if (hook.type !== "command") { + throw new Error( + `${pluginName} / ${label}: unexpected hook type "${hook.type}" — only "command" and "prompt" are allowed`, + ); + } + expect(hook.type).toBe("command"); + }); + + test(`${label}: command delegates to harness on the marketplace shared path`, () => { + const command = String(hook.command); + const harness = isHarnessCommand(command); + + // No more per-user bootstrap: every command must resolve the + // harness binary straight from the marketplace's node_modules. + if (!harness) { + throw new Error( + `${pluginName} / ${label}: command does not delegate to harness: "${command}"`, + ); + } + if (!command.includes(MARKETPLACE_HARNESS_PATH)) { + throw new Error( + `${pluginName} / ${label}: harness command not on marketplace shared path: "${command}"`, + ); + } + expect(harness).toBe(true); + }); + } + } + } + }); + } +}); diff --git a/.cursor-plugin/scripts/src/__tests__/install-hooks-config.test.ts b/.cursor-plugin/scripts/src/__tests__/install-hooks-config.test.ts new file mode 100644 index 0000000..1b52611 --- /dev/null +++ b/.cursor-plugin/scripts/src/__tests__/install-hooks-config.test.ts @@ -0,0 +1,150 @@ +/** + * Tests for install-hooks script: settings.json, .env, shell config paths, backups. + * Split out of install-hooks.test.ts to respect the SOLID file-size ceiling. + * + * Contract (see src/services/settings-manager.ts and src/services/env-file.ts): + * - settings.json lives at ~/.cursor/.fusengine-global/settings.json (Cursor-specific + * preferences store; Cursor itself never reads it — hooks live in ~/.cursor/hooks.json). + * - .env lives directly at ~/.cursor/.env (ENV_FILE = join(HOME, ".cursor", ".env")). + */ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { + existsSync, + mkdirSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { join } from "node:path"; + +const TEST_DIR = "/tmp/fusengine-test-install-config"; +const TEST_HOME = join(TEST_DIR, "home"); + +describe("install-hooks config", () => { + beforeEach(() => { + mkdirSync(join(TEST_HOME, ".cursor"), { recursive: true }); + }); + + afterEach(() => { + rmSync(TEST_DIR, { recursive: true, force: true }); + }); + + describe("settings.json handling", () => { + test("creates settings.json if not exists", () => { + const settingsPath = join(TEST_HOME, ".cursor/.fusengine-global/settings.json"); + expect(existsSync(settingsPath)).toBe(false); + + // settings.json lives in the .fusengine-global subdirectory, which + // must exist before the file can be written. + mkdirSync(join(TEST_HOME, ".cursor/.fusengine-global"), { recursive: true }); + writeFileSync(settingsPath, JSON.stringify({ language: "french" })); + + expect(existsSync(settingsPath)).toBe(true); + }); + + test("preserves existing settings when adding hooks", () => { + const settingsPath = join(TEST_HOME, ".cursor/.fusengine-global/settings.json"); + const existing = { + customSetting: "preserved", + language: "english", + }; + mkdirSync(join(TEST_HOME, ".cursor/.fusengine-global"), { recursive: true }); + writeFileSync(settingsPath, JSON.stringify(existing)); + + // Load and modify + const loaded = JSON.parse(readFileSync(settingsPath, "utf8")); + loaded.hooks = { PreToolUse: [] }; + loaded.language = "french"; + + expect(loaded.customSetting).toBe("preserved"); + }); + }); + + describe("API keys configuration", () => { + test("creates .env file in correct location", () => { + // Real contract: ENV_FILE = join(HOME, ".cursor", ".env") — directly + // under .cursor, NOT under .fusengine-global. + const envPath = join(TEST_HOME, ".cursor/.env"); + const envContent = 'export CONTEXT7_API_KEY="test-key"\n'; + + writeFileSync(envPath, envContent); + + expect(existsSync(envPath)).toBe(true); + const content = readFileSync(envPath, "utf8"); + expect(content).toContain("CONTEXT7_API_KEY"); + }); + + test("parses existing .env correctly", () => { + const envPath = join(TEST_HOME, ".cursor/.env"); + const envContent = `export KEY1="value1" +export KEY2="value2" +export KEY3="value3"`; + + writeFileSync(envPath, envContent); + + const content = readFileSync(envPath, "utf8"); + const env: Record = {}; + + for (const line of content.split("\n")) { + const match = line.match(/^export\s+(\w+)=["']?([^"'\n]*)["']?/); + if (match) { + env[match[1]] = match[2]; + } + } + + expect(Object.keys(env)).toHaveLength(3); + expect(env.KEY1).toBe("value1"); + }); + }); + + describe("shell configuration paths", () => { + test("bash config path is correct", () => { + const bashrc = join(TEST_HOME, ".bashrc"); + expect(bashrc).toContain(".bashrc"); + }); + + test("zsh config path is correct", () => { + const zshrc = join(TEST_HOME, ".zshrc"); + expect(zshrc).toContain(".zshrc"); + }); + + test("fish config path is correct", () => { + const fishConfig = join(TEST_HOME, ".config/fish/conf.d/cursor-env.fish"); + expect(fishConfig).toContain("fish"); + expect(fishConfig).toContain("conf.d"); + }); + + test("powershell config path varies by platform", () => { + const isWindows = process.platform === "win32"; + + if (isWindows) { + const psProfile = join( + TEST_HOME, + "Documents/PowerShell/Microsoft.PowerShell_profile.ps1", + ); + expect(psProfile).toContain("Documents"); + } else { + const psProfile = join( + TEST_HOME, + ".config/powershell/Microsoft.PowerShell_profile.ps1", + ); + expect(psProfile).toContain(".config"); + } + }); + }); + + describe("backup creation", () => { + test("backup filename includes timestamp", () => { + const timestamp = new Date() + .toISOString() + .replace(/[:.]/g, "-") + .slice(0, 19); + + const backupName = `settings.json.backup.${timestamp}`; + + expect(backupName).toMatch( + /settings\.json\.backup\.\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}/, + ); + }); + }); +}); diff --git a/.cursor-plugin/scripts/src/__tests__/install-hooks.test.ts b/.cursor-plugin/scripts/src/__tests__/install-hooks.test.ts new file mode 100644 index 0000000..6e3be04 --- /dev/null +++ b/.cursor-plugin/scripts/src/__tests__/install-hooks.test.ts @@ -0,0 +1,99 @@ +/** + * Tests for install-hooks script + * Integration tests for the main installation flow (marketplace layout + CLAUDE.md sync). + * + * Settings/.env/shell/backup coverage lives in install-hooks-config.test.ts + * (split out to respect the SOLID file-size ceiling). + */ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { + existsSync, + mkdirSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { join } from "node:path"; + +const TEST_DIR = "/tmp/fusengine-test-install"; +const TEST_HOME = join(TEST_DIR, "home"); +const TEST_MARKETPLACE = join( + TEST_HOME, + ".cursor/plugins/local", +); + +describe("install-hooks", () => { + beforeEach(() => { + // Create test directory structure + mkdirSync(join(TEST_MARKETPLACE, "plugins/test-plugin/hooks"), { + recursive: true, + }); + mkdirSync(join(TEST_MARKETPLACE, "scripts"), { recursive: true }); + mkdirSync(join(TEST_HOME, ".cursor"), { recursive: true }); + + // Create a test hooks.json + writeFileSync( + join(TEST_MARKETPLACE, "plugins/test-plugin/hooks/hooks.json"), + JSON.stringify({ + hooks: { + PreToolUse: [ + { + matcher: "Write", + hooks: [{ type: "command", command: "echo test" }], + }, + ], + }, + }), + ); + }); + + afterEach(() => { + rmSync(TEST_DIR, { recursive: true, force: true }); + }); + + describe("directory structure", () => { + test("test marketplace structure exists", () => { + expect(existsSync(TEST_MARKETPLACE)).toBe(true); + expect(existsSync(join(TEST_MARKETPLACE, "plugins"))).toBe(true); + expect(existsSync(join(TEST_MARKETPLACE, "scripts"))).toBe(true); + }); + + test("test plugin has hooks.json", () => { + const hooksPath = join( + TEST_MARKETPLACE, + "plugins/test-plugin/hooks/hooks.json", + ); + expect(existsSync(hooksPath)).toBe(true); + + const content = JSON.parse(readFileSync(hooksPath, "utf8")); + expect(content.hooks).toBeDefined(); + expect(content.hooks.PreToolUse).toBeDefined(); + }); + }); + + describe("CLAUDE.md handling", () => { + test("detects when CLAUDE.md needs to be copied", async () => { + const srcPath = join(TEST_DIR, "CLAUDE.md"); + const destPath = join(TEST_HOME, ".cursor/AGENTS.md"); + + writeFileSync(srcPath, "# Rules\nSome content"); + + // Destination doesn't exist + expect(existsSync(destPath)).toBe(false); + }); + + test("detects when CLAUDE.md is already up to date", async () => { + const srcPath = join(TEST_DIR, "CLAUDE.md"); + const destPath = join(TEST_HOME, ".cursor/AGENTS.md"); + const content = "# Same content"; + + writeFileSync(srcPath, content); + writeFileSync(destPath, content); + + const srcContent = readFileSync(srcPath, "utf8"); + const destContent = readFileSync(destPath, "utf8"); + + expect(srcContent).toBe(destContent); + }); + }); +}); diff --git a/.cursor-plugin/scripts/src/__tests__/loader-self-heal-integration.test.ts b/.cursor-plugin/scripts/src/__tests__/loader-self-heal-integration.test.ts new file mode 100644 index 0000000..dbf0dc6 --- /dev/null +++ b/.cursor-plugin/scripts/src/__tests__/loader-self-heal-integration.test.ts @@ -0,0 +1,78 @@ +/** + * Integration test: proves hooks-loader.ts's exact self-heal composition + * (`ensureDeps(getManagedDepsTargets(scriptsDir, pluginsDir))`, the same call + * `main()` makes as its very first statement) boots and repairs a fully + * wiped scripts/node_modules — without touching the real dev machine's + * marketplace clone or invoking a real network `bun install`. + */ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import type { DepsTarget } from "../interfaces/ensure-deps"; +import { getManagedDepsTargets } from "../services/deps-targets"; +import { ensureDeps } from "../services/ensure-deps"; + +const FAKE_MARKETPLACE = "/tmp/fusengine-test-loader-self-heal"; +const FAKE_SCRIPTS_DIR = join(FAKE_MARKETPLACE, "scripts"); +const FAKE_PLUGINS_DIR = join(FAKE_MARKETPLACE, "plugins"); +const LOCK_DIR = join(tmpdir(), "fusengine-ensure-deps"); + +/** Create a target's marker (directory marker for statusline, file marker otherwise). */ +function createMarker(target: DepsTarget): void { + if (target.marker.endsWith("node_modules")) { + mkdirSync(target.marker, { recursive: true }); + } else { + mkdirSync(dirname(target.marker), { recursive: true }); + writeFileSync(target.marker, "installed"); + } +} + +beforeEach(() => { + rmSync(FAKE_MARKETPLACE, { recursive: true, force: true }); + rmSync(LOCK_DIR, { recursive: true, force: true }); + mkdirSync(FAKE_SCRIPTS_DIR, { recursive: true }); + mkdirSync(FAKE_PLUGINS_DIR, { recursive: true }); + mkdirSync(join(FAKE_PLUGINS_DIR, "core-guards/statusline"), { recursive: true }); + // Simulate a wipe: package.json present, node_modules entirely absent. + writeFileSync(join(FAKE_SCRIPTS_DIR, "package.json"), "{}"); + writeFileSync(join(FAKE_PLUGINS_DIR, "package.json"), "{}"); + writeFileSync(join(FAKE_PLUGINS_DIR, "core-guards/statusline/package.json"), "{}"); +}); + +afterEach(() => { + rmSync(FAKE_MARKETPLACE, { recursive: true, force: true }); + rmSync(LOCK_DIR, { recursive: true, force: true }); +}); + +describe("hooks-loader self-heal (wipe simulation)", () => { + test("boots without throwing and attempts repair for every wiped target", async () => { + const targets = getManagedDepsTargets(FAKE_SCRIPTS_DIR, FAKE_PLUGINS_DIR); + expect(targets).toHaveLength(3); + for (const target of targets) expect(existsSync(target.marker)).toBe(false); + + const repaired: string[] = []; + await ensureDeps(targets, async (dir) => { + repaired.push(dir); + const target = targets.find((t) => t.dir === dir); + if (target) createMarker(target); + }); + + expect(repaired.sort()).toEqual( + [FAKE_SCRIPTS_DIR, FAKE_PLUGINS_DIR, join(FAKE_PLUGINS_DIR, "core-guards/statusline")].sort(), + ); + for (const target of targets) expect(existsSync(target.marker)).toBe(true); + }); + + test("re-running after repair is a pure no-op (all markers now present)", async () => { + const targets = getManagedDepsTargets(FAKE_SCRIPTS_DIR, FAKE_PLUGINS_DIR); + for (const target of targets) createMarker(target); + + let calls = 0; + await ensureDeps(targets, async () => { + calls++; + }); + + expect(calls).toBe(0); + }); +}); diff --git a/.cursor-plugin/scripts/src/__tests__/mcp-catalog.test.ts b/.cursor-plugin/scripts/src/__tests__/mcp-catalog.test.ts new file mode 100644 index 0000000..3a11bb3 --- /dev/null +++ b/.cursor-plugin/scripts/src/__tests__/mcp-catalog.test.ts @@ -0,0 +1,28 @@ +/** + * Tests for MCP Catalog loading + */ +import { describe, expect, test } from "bun:test"; +import { loadMcpCatalog } from "../services/mcp-installer"; + +describe("loadMcpCatalog", () => { + test("loads catalog from mcp.json", async () => { + const catalog = await loadMcpCatalog(); + expect(catalog).toBeDefined(); + expect(catalog.mcpServers).toBeDefined(); + expect(typeof catalog.mcpServers).toBe("object"); + }); + + test("catalog contains expected servers", async () => { + const catalog = await loadMcpCatalog(); + expect(catalog.mcpServers["sequential-thinking"]).toBeDefined(); + expect(catalog.mcpServers.memory).toBeDefined(); + }); + + test("servers have required fields", async () => { + const catalog = await loadMcpCatalog(); + const server = catalog.mcpServers["sequential-thinking"]; + expect(server._description).toBeDefined(); + expect(server.type).toBe("stdio"); + expect(typeof server.requiresApiKey).toBe("boolean"); + }); +}); diff --git a/.cursor-plugin/scripts/src/__tests__/mcp-defaults.test.ts b/.cursor-plugin/scripts/src/__tests__/mcp-defaults.test.ts new file mode 100644 index 0000000..c37b526 --- /dev/null +++ b/.cursor-plugin/scripts/src/__tests__/mcp-defaults.test.ts @@ -0,0 +1,40 @@ +/** + * Tests for MCP Installer - Default selections + */ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { getDefaultSelections } from "../services/mcp-installer"; +import { mockCatalog } from "./mcp-test-fixtures"; + +describe("getDefaultSelections", () => { + const originalEnv = process.env; + + beforeEach(() => { + process.env = { ...originalEnv }; + }); + + afterEach(() => { + process.env = originalEnv; + }); + + test("includes servers without API key requirement", () => { + const defaults = getDefaultSelections(mockCatalog); + expect(defaults).toContain("no-key-server"); + }); + + test("excludes servers with missing API key", () => { + delete process.env.TEST_API_KEY; + const defaults = getDefaultSelections(mockCatalog); + expect(defaults).not.toContain("key-server"); + }); + + test("includes servers with configured API key", () => { + process.env.TEST_API_KEY = "configured"; + const defaults = getDefaultSelections(mockCatalog); + expect(defaults).toContain("key-server"); + }); + + test("excludes entries starting with underscore", () => { + const defaults = getDefaultSelections(mockCatalog); + expect(defaults).not.toContain("_comment"); + }); +}); diff --git a/.cursor-plugin/scripts/src/__tests__/mcp-installer.test.ts b/.cursor-plugin/scripts/src/__tests__/mcp-installer.test.ts new file mode 100644 index 0000000..ec19b1c --- /dev/null +++ b/.cursor-plugin/scripts/src/__tests__/mcp-installer.test.ts @@ -0,0 +1,73 @@ +/** + * Tests for MCP Installer Service - Core functions + */ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { buildMcpOptions, hasApiKey } from "../services/mcp-installer"; +import { mockCatalog } from "./mcp-test-fixtures"; + +describe("hasApiKey", () => { + const originalEnv = process.env; + + beforeEach(() => { + process.env = { ...originalEnv }; + }); + + afterEach(() => { + process.env = originalEnv; + }); + + test("returns true when env var exists and is not empty", () => { + process.env.MY_API_KEY = "test-value"; + expect(hasApiKey("MY_API_KEY")).toBe(true); + }); + + test("returns false when env var does not exist", () => { + delete process.env.NONEXISTENT_KEY; + expect(hasApiKey("NONEXISTENT_KEY")).toBe(false); + }); + + test("returns false when env var is empty string", () => { + process.env.EMPTY_KEY = ""; + expect(hasApiKey("EMPTY_KEY")).toBe(false); + }); +}); + +describe("buildMcpOptions", () => { + const originalEnv = process.env; + + beforeEach(() => { + process.env = { ...originalEnv }; + }); + + afterEach(() => { + process.env = originalEnv; + }); + + test("excludes entries starting with underscore", () => { + const options = buildMcpOptions(mockCatalog); + const names = options.map((o) => o.value); + expect(names).not.toContain("_comment"); + }); + + test("includes servers without API key requirement", () => { + const options = buildMcpOptions(mockCatalog); + const noKeyServer = options.find((o) => o.value === "no-key-server"); + expect(noKeyServer).toBeDefined(); + expect(noKeyServer?.label).toBe("no-key-server"); + expect(noKeyServer?.hint).toBe("Server without API key requirement"); + }); + + test("shows key status for servers requiring API key", () => { + process.env.TEST_API_KEY = "configured"; + const options = buildMcpOptions(mockCatalog); + const keyServer = options.find((o) => o.value === "key-server"); + expect(keyServer?.label).toContain("[✓]"); + }); + + test("shows missing key warning when API key not configured", () => { + delete process.env.TEST_API_KEY; + const options = buildMcpOptions(mockCatalog); + const keyServer = options.find((o) => o.value === "key-server"); + expect(keyServer?.label).toContain("⚠ key missing"); + }); +}); diff --git a/.cursor-plugin/scripts/src/__tests__/mcp-test-fixtures.ts b/.cursor-plugin/scripts/src/__tests__/mcp-test-fixtures.ts new file mode 100644 index 0000000..d2e8486 --- /dev/null +++ b/.cursor-plugin/scripts/src/__tests__/mcp-test-fixtures.ts @@ -0,0 +1,31 @@ +/** + * Test fixtures for MCP tests + */ +import type { McpCatalog } from "../interfaces/mcp"; + +/** Mock MCP catalog for testing */ +export const mockCatalog: McpCatalog = { + mcpServers: { + "no-key-server": { + _description: "Server without API key requirement", + command: "npx", + args: ["-y", "test-server"], + type: "stdio", + requiresApiKey: false, + }, + "key-server": { + _description: "Server with API key requirement", + command: "npx", + args: ["-y", "key-server"], + type: "stdio", + requiresApiKey: true, + apiKeyEnv: "TEST_API_KEY", + apiKeyUrl: "https://example.com/api-keys", + }, + _comment: { + _description: "Should be ignored", + type: "stdio", + requiresApiKey: false, + }, + }, +}; diff --git a/.cursor-plugin/scripts/src/__tests__/plugin-scanner-home-resolution.test.ts b/.cursor-plugin/scripts/src/__tests__/plugin-scanner-home-resolution.test.ts new file mode 100644 index 0000000..b7c0689 --- /dev/null +++ b/.cursor-plugin/scripts/src/__tests__/plugin-scanner-home-resolution.test.ts @@ -0,0 +1,61 @@ +/** + * Tests for the $HOME/${HOME} runtime resolution fallback in extractHooks. + * Split out of plugin-scanner.test.ts (file-size limit) — mirrors the + * existing hook-executor.test.ts / hook-executor-*.test.ts split convention. + */ +import { describe, expect, test } from "bun:test"; +import { extractHooks } from "../services/plugin-scanner"; + +const TEST_DIR = "/tmp/fusengine-test-plugins-home"; +const HOME = process.env.HOME || process.env.USERPROFILE || ""; + +describe("extractHooks — $HOME runtime resolution", () => { + test("resolves literal $HOME in commands (re-checkout runtime fallback)", () => { + const plugins = [ + { + name: "test-plugin", + path: TEST_DIR, + hasHooks: true, + config: { + hooks: { + PreToolUse: [ + { hooks: [{ type: "command", command: "bun $HOME/.cursor/bin.mjs" }] }, + ], + }, + }, + }, + ]; + + const result = extractHooks(plugins, "PreToolUse", "", ""); + + expect(result[0].command).toBe(`bun ${HOME}/.cursor/bin.mjs`); + expect(result[0].command).not.toContain("$HOME"); + }); + + test("resolves the ${HOME} brace form in commands", () => { + const plugins = [ + { + name: "test-plugin", + path: TEST_DIR, + hasHooks: true, + config: { + hooks: { + PreToolUse: [ + { + hooks: [ + // biome-ignore lint/suspicious/noTemplateCurlyInString: shell env var syntax + { type: "command", command: "bun ${HOME}/.cursor/bin.mjs" }, + ], + }, + ], + }, + }, + }, + ]; + + const result = extractHooks(plugins, "PreToolUse", "", ""); + + expect(result[0].command).toBe(`bun ${HOME}/.cursor/bin.mjs`); + expect(result[0].command).not.toContain("${HOME}"); + }); +}); diff --git a/.cursor-plugin/scripts/src/__tests__/plugin-scanner.test.ts b/.cursor-plugin/scripts/src/__tests__/plugin-scanner.test.ts new file mode 100644 index 0000000..48c2547 --- /dev/null +++ b/.cursor-plugin/scripts/src/__tests__/plugin-scanner.test.ts @@ -0,0 +1,261 @@ +/** + * Tests for plugin-scanner service + */ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { extractHooks, scanPlugins } from "../services/plugin-scanner"; + +const TEST_DIR = "/tmp/fusengine-test-plugins"; + +describe("plugin-scanner", () => { + beforeEach(() => { + mkdirSync(TEST_DIR, { recursive: true }); + }); + + afterEach(() => { + rmSync(TEST_DIR, { recursive: true, force: true }); + }); + + describe("scanPlugins", () => { + test("returns empty array when no plugins exist", () => { + const result = scanPlugins({ pluginsDir: TEST_DIR }); + expect(result).toEqual([]); + }); + + test("detects plugin with hooks.json", () => { + const pluginDir = join(TEST_DIR, "my-plugin", "hooks"); + mkdirSync(pluginDir, { recursive: true }); + writeFileSync( + join(pluginDir, "hooks.json"), + JSON.stringify({ hooks: { PreToolUse: [] } }), + ); + + const result = scanPlugins({ pluginsDir: TEST_DIR }); + + expect(result).toHaveLength(1); + expect(result[0].name).toBe("my-plugin"); + expect(result[0].hasHooks).toBe(true); + expect(result[0].config).toBeDefined(); + }); + + test("marks plugin without hooks.json as hasHooks=false", () => { + const pluginDir = join(TEST_DIR, "no-hooks-plugin"); + mkdirSync(pluginDir, { recursive: true }); + writeFileSync(join(pluginDir, "README.md"), "# Plugin"); + + const result = scanPlugins({ pluginsDir: TEST_DIR }); + + // Plugin is still detected, but hasHooks is false + expect(result).toHaveLength(1); + expect(result[0].hasHooks).toBe(false); + expect(result[0].config).toBeUndefined(); + }); + + test("scans multiple plugins", () => { + // Plugin 1 with hooks + const plugin1 = join(TEST_DIR, "plugin-a", "hooks"); + mkdirSync(plugin1, { recursive: true }); + writeFileSync(join(plugin1, "hooks.json"), JSON.stringify({ hooks: {} })); + + // Plugin 2 with hooks + const plugin2 = join(TEST_DIR, "plugin-b", "hooks"); + mkdirSync(plugin2, { recursive: true }); + writeFileSync(join(plugin2, "hooks.json"), JSON.stringify({ hooks: {} })); + + const result = scanPlugins({ pluginsDir: TEST_DIR }); + + expect(result).toHaveLength(2); + expect(result.filter((p) => p.hasHooks)).toHaveLength(2); + }); + + test("handles invalid JSON gracefully", () => { + const pluginDir = join(TEST_DIR, "bad-json", "hooks"); + mkdirSync(pluginDir, { recursive: true }); + writeFileSync(join(pluginDir, "hooks.json"), "{ invalid json }"); + + const result = scanPlugins({ pluginsDir: TEST_DIR }); + + expect(result).toHaveLength(1); + expect(result[0].hasHooks).toBe(true); + expect(result[0].config).toBeUndefined(); // Parsing failed + }); + + test("returns empty when pluginsDir does not exist", () => { + const result = scanPlugins({ pluginsDir: "/nonexistent/path" }); + expect(result).toEqual([]); + }); + }); + + describe("extractHooks", () => { + test("extracts hooks for matching hook type", () => { + const plugins = [ + { + name: "test-plugin", + path: join(TEST_DIR, "test-plugin"), + hasHooks: true, + config: { + hooks: { + PreToolUse: [ + { + matcher: "Write", + hooks: [{ type: "command", command: "echo test" }], + }, + ], + }, + }, + }, + ]; + + const result = extractHooks(plugins, "PreToolUse", "Write", ""); + + expect(result).toHaveLength(1); + expect(result[0].command).toContain("echo test"); + expect(result[0].pluginName).toBe("test-plugin"); + }); + + test("returns empty array for non-matching hook type", () => { + const plugins = [ + { + name: "test-plugin", + path: TEST_DIR, + hasHooks: true, + config: { + hooks: { + PreToolUse: [{ hooks: [{ type: "command", command: "echo" }] }], + }, + }, + }, + ]; + + const result = extractHooks(plugins, "PostToolUse", "", ""); + + expect(result).toEqual([]); + }); + + test("filters by tool name matcher", () => { + const plugins = [ + { + name: "test-plugin", + path: TEST_DIR, + hasHooks: true, + config: { + hooks: { + PreToolUse: [ + { + matcher: "Write|Edit", + hooks: [{ type: "command", command: "echo write-edit" }], + }, + { + matcher: "Bash", + hooks: [{ type: "command", command: "echo bash" }], + }, + ], + }, + }, + }, + ]; + + const writeHooks = extractHooks(plugins, "PreToolUse", "Write", ""); + const editHooks = extractHooks(plugins, "PreToolUse", "Edit", ""); + const bashHooks = extractHooks(plugins, "PreToolUse", "Bash", ""); + const readHooks = extractHooks(plugins, "PreToolUse", "Read", ""); + + expect(writeHooks).toHaveLength(1); + expect(editHooks).toHaveLength(1); + expect(bashHooks).toHaveLength(1); + expect(readHooks).toHaveLength(0); + }); + + test("matches all when no matcher specified", () => { + const plugins = [ + { + name: "test-plugin", + path: TEST_DIR, + hasHooks: true, + config: { + hooks: { + PreToolUse: [ + { + // No matcher = match all + hooks: [{ type: "command", command: "echo all" }], + }, + ], + }, + }, + }, + ]; + + const anyHooks = extractHooks(plugins, "PreToolUse", "AnyTool", ""); + + expect(anyHooks).toHaveLength(1); + }); + + test("replaces CURSOR_PLUGIN_ROOT in commands", () => { + const plugins = [ + { + name: "test-plugin", + path: "/path/to/plugin", + hasHooks: true, + config: { + hooks: { + PreToolUse: [ + { + hooks: [ + { + type: "command", + // biome-ignore lint/suspicious/noTemplateCurlyInString: shell env var syntax + command: "bash ${CURSOR_PLUGIN_ROOT}/scripts/test.sh", + }, + ], + }, + ], + }, + }, + }, + ]; + + const result = extractHooks(plugins, "PreToolUse", "", ""); + + expect(result[0].command).toBe("bash /path/to/plugin/scripts/test.sh"); + }); + + test("marks afplay commands as async", () => { + const plugins = [ + { + name: "test-plugin", + path: TEST_DIR, + hasHooks: true, + config: { + hooks: { + Stop: [ + { + hooks: [{ type: "command", command: "afplay /sound.wav" }], + }, + ], + }, + }, + }, + ]; + + const result = extractHooks(plugins, "Stop", "", ""); + + expect(result[0].isAsync).toBe(true); + }); + + test("skips plugins without config", () => { + const plugins = [ + { + name: "empty-plugin", + path: TEST_DIR, + hasHooks: false, + // No config + }, + ]; + + const result = extractHooks(plugins, "PreToolUse", "", ""); + + expect(result).toEqual([]); + }); + }); +}); diff --git a/.cursor-plugin/scripts/src/__tests__/settings-env-purge-hooks-sandbox.test.ts b/.cursor-plugin/scripts/src/__tests__/settings-env-purge-hooks-sandbox.test.ts new file mode 100644 index 0000000..0499340 --- /dev/null +++ b/.cursor-plugin/scripts/src/__tests__/settings-env-purge-hooks-sandbox.test.ts @@ -0,0 +1,92 @@ +/** + * E2E sandbox test for the installer's settings.json side, exercising the + * REAL exported functions (no mocks) against a temp HOME: + * - purgeFuseEnvVars strips residual FUSE_* from settings.env, keeps + * CLAUDE_CODE_* and _FUSENGINE_PERF_ASKED. + * - configureHooks never wires a PostCompact dispatcher (removed from + * HOOK_TYPES) but still wires PreCompact/SessionStart/Stop/etc. + * - Both are idempotent on re-run (no residual FUSE_* or duplicate hooks). + * The real ~/.cursor is never touched: settings.json lives under a + * mkdtemp() sandbox removed in afterEach. See env-file-writer-sandbox.test.ts + * for the companion ~/.cursor/.fusengine-global/.env writer coverage. + */ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { HOOK_TYPES } from "../interfaces/hooks"; +import { configureHooks, loadSettings, saveSettings } from "../services/settings-manager"; +import { purgeFuseEnvVars } from "../services/settings-env-purge"; + +const FAKE_LOADER = "/fake/hooks-loader.ts"; + +let tmpHome: string; +let settingsPath: string; + +beforeEach(() => { + tmpHome = mkdtempSync(join(tmpdir(), "fusengine-settings-sandbox-")); + settingsPath = join(tmpHome, ".cursor", "settings.json"); +}); + +afterEach(() => { + rmSync(tmpHome, { recursive: true, force: true }); +}); + +describe("installer sandbox: FUSE_* purge + hooks wiring (real code, temp HOME)", () => { + test("purge strips FUSE_* from settings.env, keeps CLAUDE_CODE_* and _FUSENGINE_PERF_ASKED", async () => { + await saveSettings(settingsPath, { + env: { + FUSE_SOLID_MAX_LINES: "999", + CLAUDE_CODE_FORK_SUBAGENT: "1", + _FUSENGINE_PERF_ASKED: "1", + }, + }); + + let settings = await loadSettings(settingsPath); + settings = purgeFuseEnvVars(settings); + + const env = settings.env as Record; + expect(Object.keys(env).some((k) => k.startsWith("FUSE_"))).toBe(false); + expect(env.CLAUDE_CODE_FORK_SUBAGENT).toBe("1"); + expect(env._FUSENGINE_PERF_ASKED).toBe("1"); + }); + + test("configureHooks wires no postCompact but wires preCompact/sessionStart/stop/postToolUse", () => { + const hooksPath = join(dirname(settingsPath), "hooks.json"); + configureHooks(FAKE_LOADER, hooksPath); + const hookKeys = Object.keys(JSON.parse(readFileSync(hooksPath, "utf8")).hooks); + + expect(hookKeys).not.toContain("postCompact"); + for (const type of ["preCompact", "sessionStart", "stop", "postToolUse"]) { + expect(hookKeys).toContain(type); + } + expect(hookKeys.sort()).toEqual([...HOOK_TYPES].sort()); + }); + + test("idempotence: purge + configureHooks re-run twice yields byte-identical files", async () => { + const hooksPath = join(dirname(settingsPath), "hooks.json"); + await saveSettings(settingsPath, { + env: { + FUSE_SOLID_MAX_LINES: "999", + CLAUDE_CODE_FORK_SUBAGENT: "1", + _FUSENGINE_PERF_ASKED: "1", + }, + }); + + let settings = await loadSettings(settingsPath); + settings = purgeFuseEnvVars(settings); + configureHooks(FAKE_LOADER, hooksPath); + await saveSettings(settingsPath, settings); + const firstSettings = readFileSync(settingsPath, "utf8"); + const firstHooks = readFileSync(hooksPath, "utf8"); + + let second = await loadSettings(settingsPath); + second = purgeFuseEnvVars(second); + configureHooks(FAKE_LOADER, hooksPath); + await saveSettings(settingsPath, second); + + expect(readFileSync(settingsPath, "utf8")).toBe(firstSettings); + expect(readFileSync(hooksPath, "utf8")).toBe(firstHooks); + expect(Object.keys(JSON.parse(firstHooks).hooks)).not.toContain("postCompact"); + }); +}); diff --git a/.cursor-plugin/scripts/src/__tests__/settings-manager.configure.test.ts b/.cursor-plugin/scripts/src/__tests__/settings-manager.configure.test.ts new file mode 100644 index 0000000..a2e94e5 --- /dev/null +++ b/.cursor-plugin/scripts/src/__tests__/settings-manager.configure.test.ts @@ -0,0 +1,103 @@ +/** + * Tests for settings-manager: configureHooks, configureDefaults, configureStatusLine + */ +import { describe, expect, test } from "bun:test"; +import { mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { HOOK_TYPES } from "../interfaces/hooks"; +import { + configureDefaults, + configureHooks, + configureStatusLine, +} from "../services/settings-manager"; + +/** Fresh hooks.json path in a temp dir, optionally seeded. */ +function tempHooks(seed?: unknown): string { + const file = join(mkdtempSync(join(tmpdir(), "fuse-cfg-")), "hooks.json"); + if (seed !== undefined) writeFileSync(file, JSON.stringify(seed, null, 2)); + return file; +} + +/** Parse the hooks map written to ~/.cursor/hooks.json. */ +function readHooks(file: string): Record { + return JSON.parse(readFileSync(file, "utf8")).hooks; +} + +describe("settings-manager / configure", () => { + describe("configureHooks", () => { + test("configures all hook types", () => { + const file = tempHooks(); + configureHooks("/path/to/loader.ts", file); + const hooks = readHooks(file); + for (const hookType of HOOK_TYPES) { + expect(hooks[hookType]).toBeDefined(); + } + }); + + test("sets correct command format", () => { + const loaderPath = "/test/hooks-loader.ts"; + const file = tempHooks(); + configureHooks(loaderPath, file); + const preToolUse = readHooks(file).preToolUse as Array<{ command: string }>; + expect(preToolUse[0].command).toBe(`bun ${loaderPath} preToolUse`); + }); + + test("preserves user hook types not managed by fusengine", () => { + const file = tempHooks({ version: 1, hooks: { customHook: [{ old: "config" }] } }); + configureHooks("/x/hooks-loader.ts", file); + const hooks = readHooks(file); + expect(hooks.customHook).toEqual([{ old: "config" }]); + expect(hooks.preToolUse).toBeDefined(); + }); + }); + + describe("configureDefaults", () => { + test("sets language to english by default", () => { + const result = configureDefaults({}); + expect(result.language).toBe("english"); + }); + + test("uses provided language when specified", () => { + const result = configureDefaults({}, "french"); + expect(result.language).toBe("french"); + }); + + test("preserves existing language when none provided", () => { + const result = configureDefaults({ language: "german" }); + expect(result.language).toBe("german"); + }); + + test("sets empty attribution", () => { + const result = configureDefaults({}); + expect(result.attribution).toEqual({ commit: "", pr: "" }); + }); + + test("preserves existing settings", () => { + const settings = { custom: "value", hooks: {} }; + const result = configureDefaults(settings); + expect(result.custom).toBe("value"); + expect(result.hooks).toEqual({}); + }); + }); + + describe("configureStatusLine", () => { + test("adds statusLine if not present", () => { + const result = configureStatusLine({}, "/path/to/statusline"); + expect(result.statusLine).toBeDefined(); + expect(result.statusLine?.type).toBe("command"); + expect(result.statusLine?.command).toContain("/path/to/statusline"); + expect(result.statusLine?.padding).toBe(0); + }); + + test("does not override existing statusLine", () => { + const settings = { + statusLine: { type: "custom", command: "custom-command", padding: 5 }, + }; + const result = configureStatusLine(settings, "/new/path"); + expect(result.statusLine?.type).toBe("custom"); + expect(result.statusLine?.command).toBe("custom-command"); + expect(result.statusLine?.padding).toBe(5); + }); + }); +}); diff --git a/.cursor-plugin/scripts/src/__tests__/settings-manager.load-save.test.ts b/.cursor-plugin/scripts/src/__tests__/settings-manager.load-save.test.ts new file mode 100644 index 0000000..e89ac91 --- /dev/null +++ b/.cursor-plugin/scripts/src/__tests__/settings-manager.load-save.test.ts @@ -0,0 +1,110 @@ +/** + * Tests for settings-manager: loadSettings, saveSettings, backupSettings + */ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { + existsSync, + mkdirSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { join } from "node:path"; +import { + backupSettings, + loadSettings, + saveSettings, +} from "../services/settings-manager"; + +const TEST_DIR = "/tmp/fusengine-test-settings-io"; +const TEST_SETTINGS = join(TEST_DIR, "settings.json"); + +describe("settings-manager / IO", () => { + beforeEach(() => mkdirSync(TEST_DIR, { recursive: true })); + afterEach(() => rmSync(TEST_DIR, { recursive: true, force: true })); + + describe("loadSettings", () => { + test("returns empty object when file does not exist", async () => { + expect(await loadSettings(join(TEST_DIR, "nonexistent.json"))).toEqual( + {}, + ); + }); + + test("loads existing settings file", async () => { + writeFileSync( + TEST_SETTINGS, + JSON.stringify({ language: "french", custom: "value" }), + ); + const result = await loadSettings(TEST_SETTINGS); + expect(result.language).toBe("french"); + expect(result.custom).toBe("value"); + }); + + test("parses complex settings structure", async () => { + writeFileSync( + TEST_SETTINGS, + JSON.stringify({ + hooks: { PreToolUse: [{ matcher: "Write" }] }, + statusLine: { type: "command", command: "test" }, + }), + ); + const result = await loadSettings(TEST_SETTINGS); + expect(result.hooks).toBeDefined(); + expect(result.statusLine).toBeDefined(); + }); + }); + + describe("saveSettings", () => { + test("creates directory if not exists", async () => { + const nestedPath = join(TEST_DIR, "nested/deep/settings.json"); + await saveSettings(nestedPath, { test: true }); + expect(existsSync(nestedPath)).toBe(true); + }); + + test("saves settings as formatted JSON with newlines", async () => { + await saveSettings(TEST_SETTINGS, { language: "french", value: 42 }); + const content = readFileSync(TEST_SETTINGS, "utf8"); + expect(content).toContain('"language": "french"'); + expect(content).toContain("\n"); + }); + + test("overwrites existing settings", async () => { + writeFileSync(TEST_SETTINGS, JSON.stringify({ old: "value" })); + await saveSettings(TEST_SETTINGS, { new: "value" }); + const content = readFileSync(TEST_SETTINGS, "utf8"); + expect(content).not.toContain("old"); + expect(content).toContain("new"); + }); + }); + + describe("backupSettings", () => { + test("does nothing if file does not exist", () => { + backupSettings(join(TEST_DIR, "nonexistent.json")); + }); + + test("creates backup with timestamp prefix", () => { + writeFileSync(TEST_SETTINGS, JSON.stringify({ test: true })); + backupSettings(TEST_SETTINGS); + const files = require("node:fs").readdirSync(TEST_DIR) as string[]; + expect( + files.find((f) => f.startsWith("settings.json.backup.")), + ).toBeDefined(); + }); + + test("backup contains original content", () => { + writeFileSync( + TEST_SETTINGS, + JSON.stringify({ important: "data", value: 123 }), + ); + backupSettings(TEST_SETTINGS); + const files = require("node:fs").readdirSync(TEST_DIR) as string[]; + const backupFile = files.find((f) => + f.startsWith("settings.json.backup."), + ) as string; + const content = JSON.parse( + readFileSync(join(TEST_DIR, backupFile), "utf8"), + ); + expect(content.important).toBe("data"); + }); + }); +}); diff --git a/.cursor-plugin/scripts/src/__tests__/settings-manager.test.ts b/.cursor-plugin/scripts/src/__tests__/settings-manager.test.ts new file mode 100644 index 0000000..7d1f570 --- /dev/null +++ b/.cursor-plugin/scripts/src/__tests__/settings-manager.test.ts @@ -0,0 +1,11 @@ +/** + * Settings manager test suite - split per SOLID SRP + * IO tests: settings-manager.load-save.test.ts + * Config tests: settings-manager.configure.test.ts + */ + +// Re-export split suites so `bun test` picks them up individually. +// This file intentionally left as an index; actual tests live in: +// - settings-manager.load-save.test.ts (loadSettings, saveSettings, backupSettings) +// - settings-manager.configure.test.ts (configureHooks, configureDefaults, configureStatusLine) +export {}; diff --git a/.cursor-plugin/scripts/src/__tests__/single-flight-lock.test.ts b/.cursor-plugin/scripts/src/__tests__/single-flight-lock.test.ts new file mode 100644 index 0000000..295e52c --- /dev/null +++ b/.cursor-plugin/scripts/src/__tests__/single-flight-lock.test.ts @@ -0,0 +1,68 @@ +/** + * Tests for the single-flight file lock primitive. + */ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { tryAcquireLock } from "../utils/single-flight-lock"; + +const LOCK_DIR = join(tmpdir(), "fusengine-ensure-deps"); +const KEY = "/tmp/fusengine-test-lock-target"; + +describe("tryAcquireLock", () => { + beforeEach(() => { + mkdirSync(LOCK_DIR, { recursive: true }); + }); + + afterEach(() => { + rmSync(LOCK_DIR, { recursive: true, force: true }); + }); + + test("acquires a free lock and returns a release function", () => { + const release = tryAcquireLock(KEY); + + expect(typeof release).toBe("function"); + release?.(); + }); + + test("a second acquire on the same key fails while the first holds it", () => { + const release = tryAcquireLock(KEY); + expect(release).not.toBeNull(); + + const second = tryAcquireLock(KEY); + expect(second).toBeNull(); + + release?.(); + }); + + test("acquiring again after release succeeds", () => { + const release = tryAcquireLock(KEY); + release?.(); + + const reacquired = tryAcquireLock(KEY); + expect(reacquired).not.toBeNull(); + reacquired?.(); + }); + + test("reclaims a stale lock (older than staleMs) exactly once", () => { + const slug = KEY.replace(/[^a-zA-Z0-9]+/g, "_"); + const lockPath = join(LOCK_DIR, `${slug}.lock`); + writeFileSync(lockPath, JSON.stringify({ pid: 999999, ts: Date.now() - 1000 })); + + const release = tryAcquireLock(KEY, 500); // staleMs=500, lock is 1000ms old + + expect(release).not.toBeNull(); + release?.(); + }); + + test("does not reclaim a fresh lock", () => { + const release = tryAcquireLock(KEY, 120_000); + expect(release).not.toBeNull(); + + const second = tryAcquireLock(KEY, 120_000); + expect(second).toBeNull(); + + release?.(); + }); +}); diff --git a/.cursor-plugin/scripts/src/config/api-keys.ts b/.cursor-plugin/scripts/src/config/api-keys.ts new file mode 100644 index 0000000..6aa4a71 --- /dev/null +++ b/.cursor-plugin/scripts/src/config/api-keys.ts @@ -0,0 +1,72 @@ +/** + * API keys configuration for MCP servers + */ +import type { EnvKey } from "../interfaces/env"; + +export const API_KEYS: EnvKey[] = [ + // No API key required (free): + // sequential-thinking, memory, filesystem, playwright, postgres, shadcn, + // next-devtools, XcodeBuildMCP, apple-docs + + // API key required + { + name: "CONTEXT7_API_KEY", + description: "Context7 - Documentation lookup", + url: "https://context7.com", + }, + { + name: "EXA_API_KEY", + description: "Exa - AI web search & research", + url: "https://exa.ai", + }, + { + name: "MAGIC_API_KEY", + description: "Magic 21st.dev - UI generation", + url: "https://21st.dev", + }, + { + name: "GEMINI_DESIGN_API_KEY", + description: "Gemini Design - AI frontend", + url: "https://gemini-design-mcp.com", + }, + { + name: "GITHUB_TOKEN", + description: "GitHub - repos, PRs, issues", + url: "https://github.com/settings/tokens", + }, + { + name: "SUPABASE_ACCESS_TOKEN", + description: "Supabase - database & auth", + url: "https://supabase.com/dashboard/account/tokens", + }, + { + name: "SLACK_TOKEN", + description: "Slack - messages & workspace", + url: "https://api.slack.com/apps", + }, + { + name: "SENTRY_AUTH_TOKEN", + description: "Sentry - error tracking", + url: "https://sentry.io/settings/auth-tokens", + }, + { + name: "BRAVE_API_KEY", + description: "Brave Search - private search", + url: "https://brave.com/search/api", + }, + { + name: "STRIPE_SECRET_KEY", + description: "Stripe - payments & billing", + url: "https://dashboard.stripe.com/apikeys", + }, + { + name: "NOTION_TOKEN", + description: "Notion - pages & databases", + url: "https://www.notion.so/my-integrations", + }, + { + name: "REPLICATE_API_TOKEN", + description: "Replicate - 1000+ AI models", + url: "https://replicate.com/account/api-tokens", + }, +]; diff --git a/.cursor-plugin/scripts/src/interfaces/ensure-deps.ts b/.cursor-plugin/scripts/src/interfaces/ensure-deps.ts new file mode 100644 index 0000000..4023098 --- /dev/null +++ b/.cursor-plugin/scripts/src/interfaces/ensure-deps.ts @@ -0,0 +1,17 @@ +/** + * Self-heal deps interfaces. + * @description SRP: types for the ensure-deps self-heal feature only. + */ + +/** One node_modules-backed directory the self-heal loop can reinstall. */ +export interface DepsTarget { + /** Absolute directory containing the package.json to `bun install`. */ + dir: string; + /** Absolute path whose existence proves deps are already installed. */ + marker: string; + /** Human label used in fail-open error logs. */ + label: string; +} + +/** Injectable installer signature — lets tests fake `bun install` (no network). */ +export type InstallFn = (dir: string, timeoutMs: number) => Promise; diff --git a/.cursor-plugin/scripts/src/interfaces/env.ts b/.cursor-plugin/scripts/src/interfaces/env.ts new file mode 100644 index 0000000..055b0eb --- /dev/null +++ b/.cursor-plugin/scripts/src/interfaces/env.ts @@ -0,0 +1,8 @@ +/** + * Interface for environment variable configuration + */ +export interface EnvKey { + name: string; + description: string; + url?: string; +} diff --git a/.cursor-plugin/scripts/src/interfaces/global-install.ts b/.cursor-plugin/scripts/src/interfaces/global-install.ts new file mode 100644 index 0000000..2dbe562 --- /dev/null +++ b/.cursor-plugin/scripts/src/interfaces/global-install.ts @@ -0,0 +1,80 @@ +/** + * Global installer interfaces. + * @description SRP: types for the user-global Cursor plugin installer only + * (global-install / global-artifacts / global-transaction services). + */ + +/** Parsed CLI flags for the global installer. */ +export interface GlobalOptions { + dryRun: boolean; + uninstall: boolean; +} + +/** Every filesystem path the global installer reads or writes, resolved once at startup. */ +export interface GlobalPaths { + /** Root of this repository (or the test-injected source root override). */ + sourceRoot: string; + /** Resolved real path of the user's home directory. */ + homeRoot: string; + /** `~/.cursor`. */ + cursorRoot: string; + /** `~/.cursor/plugins`. */ + pluginsRoot: string; + /** `~/.cursor/plugins/local`. */ + localRoot: string; + /** `~/.cursor/rules`. */ + rulesRoot: string; + /** `~/.cursor/rules/fuse-global.mdc`. */ + rulePath: string; + /** `~/.cursor/.fusengine-global`. */ + controlRoot: string; + /** `~/.cursor/.fusengine-global/receipt.json`. */ + receiptPath: string; + /** `~/.cursor/.fusengine-global/.managed-by-fusengine`. */ + controlMarker: string; +} + +/** One marketplace plugin resolved to its real on-disk source directory. */ +export interface GlobalPluginSource { + name: string; + source: string; +} + +/** Shape of `receipt.json`: per-plugin content hashes plus the installed rule's hash. */ +export interface GlobalReceipt { + version: 1; + plugins: Record; + ruleHash: string; +} + +/** One file the transaction commits or removes as part of finalizing a snapshot swap. */ +export interface GlobalFinalFile { + path: string; + content: Buffer | string | null; +} + +/** Arguments to atomically swap the local-plugin snapshot and finalize global files. */ +export interface CommitGlobalSnapshotArgs { + cursorRoot: string; + stageRoot: string; + finalFiles: GlobalFinalFile[]; +} + +/** Lifecycle phase of an in-flight global install/uninstall transaction. */ +export type GlobalTransactionPhase = "prepared" | "old-moved" | "new-moved" | "finalized"; + +/** Journal persisted to disk so a crash mid-swap can be recovered on the next run. */ +export interface GlobalTransactionJournal { + version: 1; + nonce: string; + phase: GlobalTransactionPhase; + hadLocal: boolean; + snapshot: Record; +} + +/** The three roots a transaction nonce maps to inside `~/.cursor/plugins`. */ +export interface GlobalTransactionRoots { + backup: string; + local: string; + stage: string; +} diff --git a/.cursor-plugin/scripts/src/interfaces/hook-types.ts b/.cursor-plugin/scripts/src/interfaces/hook-types.ts new file mode 100644 index 0000000..669fa06 --- /dev/null +++ b/.cursor-plugin/scripts/src/interfaces/hook-types.ts @@ -0,0 +1,55 @@ +/** + * Hook Types - Supported Cursor hook event types + * + * @description SRP: Hook type definitions and registry only + * @see https://cursor.com/docs/hooks + */ + +/** Cursor hook events (camelCase, unlike Claude Code's PascalCase). */ +export type HookType = + | "sessionStart" + | "sessionEnd" + | "beforeSubmitPrompt" + | "preToolUse" + | "postToolUse" + | "postToolUseFailure" + | "subagentStart" + | "subagentStop" + | "beforeShellExecution" + | "afterShellExecution" + | "beforeMCPExecution" + | "afterMCPExecution" + | "beforeReadFile" + | "afterFileEdit" + | "preCompact" + | "stop" + | "afterAgentResponse" + | "afterAgentThought"; + +/** + * Registry of the hook types the installer wires to the loader. + * + * Tab events (`beforeTabFileRead`, `afterTabFileEdit`) and `workspaceOpen` are + * deliberately absent: they fire outside an agent session, where the loader has + * no conversation context to act on. + */ +export const HOOK_TYPES: HookType[] = [ + "sessionStart", + "sessionEnd", + "beforeSubmitPrompt", + "preToolUse", + "postToolUse", + "postToolUseFailure", + "subagentStart", + "subagentStop", + "beforeShellExecution", + "afterShellExecution", + "beforeMCPExecution", + "afterMCPExecution", + "beforeReadFile", + "afterFileEdit", + "preCompact", + "stop", + "afterAgentResponse", + "afterAgentThought", +]; diff --git a/.cursor-plugin/scripts/src/interfaces/hooks.ts b/.cursor-plugin/scripts/src/interfaces/hooks.ts new file mode 100644 index 0000000..390b476 --- /dev/null +++ b/.cursor-plugin/scripts/src/interfaces/hooks.ts @@ -0,0 +1,72 @@ +/** + * Hook Interfaces - Type definitions for the hook system + * + * @description SRP: Hook interfaces only (types in hook-types.ts) + */ + +export type { HookType } from "./hook-types"; +export { HOOK_TYPES } from "./hook-types"; + +/** Configuration d'un hook individuel */ +export interface HookCommand { + type: string; + command: string; +} + +/** Entrée de hook avec matcher */ +export interface HookEntry { + matcher?: string; + hooks: HookCommand[]; +} + +/** Configuration complète des hooks d'un plugin */ +export interface HooksConfig { + hooks: Record; +} + +/** Commande à exécuter avec métadonnées */ +export interface ExecutableHook { + command: string; + isAsync: boolean; + pluginName: string; + pluginPath: string; +} + +/** Résultat du parsing d'une command de hook en argv shell-free */ +export interface ParsedHookCommand { + /** Tokens word-split ; argv[0] est le nom littéral du programme (ex. "bun"). */ + argv: string[]; + /** True si la command finissait par `|| true` (bash avale tout exit≠0, y compris 2). */ + ignoreExit: boolean; +} + +/** Résultat d'exécution d'un hook */ +export interface HookResult { + success: boolean; + exitCode: number; + stdout: string; + stderr: string; + blocked: boolean; +} + +/** Input JSON reçu de Claude */ +export interface HookInput { + tool_name?: string; + tool_input?: Record; + type?: string; + notification_type?: string; + agent_type?: string; +} + +/** Configuration du scanner de plugins */ +export interface ScannerConfig { + pluginsDir: string; +} + +/** Informations sur un plugin scanné */ +export interface PluginInfo { + name: string; + path: string; + hasHooks: boolean; + config?: HooksConfig; +} diff --git a/.cursor-plugin/scripts/src/interfaces/marketplace.ts b/.cursor-plugin/scripts/src/interfaces/marketplace.ts new file mode 100644 index 0000000..b8a16da --- /dev/null +++ b/.cursor-plugin/scripts/src/interfaces/marketplace.ts @@ -0,0 +1,39 @@ +/** + * Marketplace and managed-plugin type definitions + * @description SRP: shared shapes for marketplace.json, plugin.json, and the project-local + * loader's I/O. Consumed by marketplace-sources.ts, plugin-inventory.ts, and + * load-project-plugins.ts. + */ + +/** Marketplace manifest shape (`marketplace.json`), as read from disk — untrusted until validated. */ +export interface MarketplaceManifest { + plugins?: unknown; +} + +/** Raw marketplace plugin entry, as read from JSON — untrusted until validated. */ +export interface MarketplaceEntryInput { + name?: unknown; + source?: unknown; +} + +/** Validated identity of a marketplace plugin: a safe name and its declared relative source. */ +export interface MarketplacePluginIdentity { + name: string; + source: string; +} + +/** A marketplace plugin resolved to its physical, verified source directory. */ +export interface ResolvedMarketplacePlugin { + name: string; + source: string; +} + +/** Plugin manifest shape (`.cursor-plugin/plugin.json`), as read from disk — untrusted until validated. */ +export interface PluginManifest { + name?: unknown; +} + +/** stdout contract printed by the project-local loader (consumed by a Cursor `workspaceOpen` hook). */ +export interface ProjectLoaderResponse { + pluginPaths: string[]; +} diff --git a/.cursor-plugin/scripts/src/interfaces/mcp.ts b/.cursor-plugin/scripts/src/interfaces/mcp.ts new file mode 100644 index 0000000..642bc30 --- /dev/null +++ b/.cursor-plugin/scripts/src/interfaces/mcp.ts @@ -0,0 +1,57 @@ +/** + * MCP Server interfaces for installer + */ + +/** MCP Server configuration from mcp.json */ +export interface McpServerConfig { + _description: string; + command?: string; + args?: string[]; + env?: Record; + type: "stdio" | "http"; + url?: string; + headers?: Record; + requiresApiKey: boolean; + apiKeyEnv?: string; + apiKeyUrl?: string; + default?: boolean; +} + +/** MCP catalog from mcp.json */ +export interface McpCatalog { + mcpServers: Record; +} + +/** A Cursor mcp.json document, as written to ~/.cursor/mcp.json. */ +export interface McpDocument { + mcpServers?: Record; + [key: string]: unknown; +} + +/** Outcome of merging catalog servers into a user's mcp.json. */ +export interface McpMergeResult { + doc: McpDocument; + added: string[]; + kept: string[]; +} + +/** MCP server with resolved status */ +export interface McpServerStatus { + name: string; + config: McpServerConfig; + hasApiKey: boolean; + isInstalled: boolean; +} + +/** Group of MCP servers for display */ +export interface McpServerGroup { + label: string; + servers: McpServerStatus[]; +} + +/** Selection option for @clack/prompts */ +export interface McpSelectOption { + value: string; + label: string; + hint?: string; +} diff --git a/.cursor-plugin/scripts/src/interfaces/project-install.ts b/.cursor-plugin/scripts/src/interfaces/project-install.ts new file mode 100644 index 0000000..384b856 --- /dev/null +++ b/.cursor-plugin/scripts/src/interfaces/project-install.ts @@ -0,0 +1,116 @@ +/** + * Project installer interfaces. + * @description SRP: shared types for the project-local Cursor installer + * (project-install / project-files / project-transaction / project-uninstall + * / project-hooks services). No runtime code here. Plugin/marketplace shapes + * are NOT redefined here — reused from `./marketplace` (owned by the + * marketplace-sources/plugin-inventory port) to avoid duplicate types. + */ +import type { MarketplaceManifest, ResolvedMarketplacePlugin } from "./marketplace"; + +export type { MarketplaceManifest }; + +/** CLI options parsed from `process.argv` for the project installer. */ +export interface InstallOptions { + dryRun: boolean; + uninstall: boolean; + project: string; +} + +/** One `hooks.json` `workspaceOpen` (or other) hook entry. */ +export interface HookEntry { + command: string; + [key: string]: unknown; +} + +/** The `hooks` object of a normalized `hooks.json` document. */ +export interface HookGroups { + workspaceOpen: unknown[]; + [key: string]: unknown; +} + +/** + * Normalized `hooks.json` shape guaranteed by `hookDocument()`: `version` is 1 + * and `hooks.workspaceOpen` is always an array. + */ +export interface HookDocument { + version: number; + hooks: HookGroups; + [key: string]: unknown; +} + +/** + * Raw `hooks.json` as parsed straight off disk, before normalization — used on + * the uninstall path, which never runs the document through `hookDocument()`. + */ +export interface RawHookDocument { + version?: number; + hooks?: { + workspaceOpen?: unknown[]; + [key: string]: unknown; + }; + [key: string]: unknown; +} + +/** Persisted install receipt: what the installer added, so uninstall reverses only that. */ +export interface InstallReceipt { + hooksFileExisted?: boolean; + ruleFileExisted?: boolean; + hookAdded?: boolean; + hookIndex?: number; + hookToken?: string | null; + ruleSha256?: string; +} + +/** Result of merging (or reusing) the owned loader hook into a hooks document. */ +export interface HookMergeResult { + document: HookDocument; + hookAdded: boolean; + hookCommand: string; + hookIndex: number; + hookToken: string | null; +} + +/** Absolute paths the uninstaller needs to plan and apply removal. */ +export interface UninstallPaths { + cursorRoot: string; + managedRoot: string; + hooksPath: string; + rulePath: string; + receiptPath: string; +} + +/** One planned mutation for a single owned file during uninstall. */ +export type FileAction = + | { type: "none" } + | { type: "remove" } + | { type: "write"; content: Buffer } + | { type: "preserve" }; + +/** Full uninstall plan computed before any filesystem mutation (preflight). */ +export interface UninstallPlan { + hooksAction: FileAction; + originalHooks: Buffer | null; + originalRule: Buffer | null; + receipt: InstallReceipt; + ruleAction: FileAction; +} + +/** Inputs to build one staged managed-root directory before it is swapped in. */ +export interface StageBuildInput { + stageRoot: string; + sourceRoot: string; + plugins: ResolvedMarketplacePlugin[]; + receipt: InstallReceipt; +} + +/** Inputs to atomically replace the managed root, with rollback around finalize. */ +export interface ReplaceManagedRootInput { + cursorRoot: string; + managedRoot: string; + sourceRoot: string; + plugins: ResolvedMarketplacePlugin[]; + receipt: InstallReceipt; + finalize: () => void; + rollbackFinalize: () => void; +} diff --git a/.cursor-plugin/scripts/src/interfaces/setup.ts b/.cursor-plugin/scripts/src/interfaces/setup.ts new file mode 100644 index 0000000..919a4bd --- /dev/null +++ b/.cursor-plugin/scripts/src/interfaces/setup.ts @@ -0,0 +1,18 @@ +/** + * Setup interfaces + */ + +/** Paths configuration for setup process */ +export interface SetupPaths { + settings: string; + marketplace: string; + loaderSrc: string; + cursorMdSrc: string; + cursorMdDest: string; + /** + * Directory holding the deployed plugins. Explicit rather than derived from + * `marketplace`: Cursor lays them out flat under `~/.cursor/plugins/local`, + * with no intermediate `plugins/` level like Claude Code's marketplace. + */ + pluginsDir: string; +} diff --git a/.cursor-plugin/scripts/src/interfaces/verify.ts b/.cursor-plugin/scripts/src/interfaces/verify.ts new file mode 100644 index 0000000..e8cbdac --- /dev/null +++ b/.cursor-plugin/scripts/src/interfaces/verify.ts @@ -0,0 +1,23 @@ +/** + * Project verification I/O shapes + * @description SRP: read-only diagnostic types for project-verify's on-disk contracts + * (hooks.json, the installed marketplace.json, and the loader's stdout response). + */ + +/** `hooks.json` shape, as read from an installed project — untrusted until checked. */ +export interface HooksFile { + version?: unknown; + hooks?: { + workspaceOpen?: Array<{ command?: unknown }>; + }; +} + +/** Installed marketplace inventory (`marketplace.json`) shape, as read from disk. */ +export interface InstalledMarketplace { + plugins: unknown[]; +} + +/** stdout contract of the project-local loader (`{"pluginPaths":[...]}`). */ +export interface LoaderCliResponse { + pluginPaths?: string[]; +} diff --git a/.cursor-plugin/scripts/src/services/browser-binary.ts b/.cursor-plugin/scripts/src/services/browser-binary.ts new file mode 100644 index 0000000..c3fff62 --- /dev/null +++ b/.cursor-plugin/scripts/src/services/browser-binary.ts @@ -0,0 +1,37 @@ +/** + * Browser binary service + * Single Responsibility: ensure the managed Chromium binary for fuse-browser is present. + */ +import * as p from "@clack/prompts"; + +/** True if the Chromium download is disabled via env. */ +export function isBrowserDownloadSkipped(): boolean { + return Boolean( + process.env.PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD || + process.env.FUSE_SKIP_BROWSER_DOWNLOAD, + ); +} + +/** + * Install the patchright Chromium build matching the @latest browser-mcp. + * Non-fatal + idempotent; respects the skip env vars. + * @returns true if the install ran successfully, false if skipped/failed. + */ +export async function installBrowserBinary(): Promise { + if (isBrowserDownloadSkipped()) { + p.log.info("fuse-browser: Chromium download skipped (env)"); + return false; + } + const s = p.spinner(); + s.start("fuse-browser: installing Chromium (version-matched)..."); + const proc = Bun.spawnSync( + ["npx", "-y", "--package=@fusengine/browser-mcp@latest", "patchright", "install", "chromium"], + { stdout: "inherit", stderr: "inherit" }, + ); + if (proc.exitCode === 0) { + s.stop("fuse-browser: Chromium ready"); + return true; + } + s.stop("fuse-browser: Chromium install skipped/failed (non-fatal). Manual: npx patchright install chromium"); + return false; +} diff --git a/.cursor-plugin/scripts/src/services/deps-targets.ts b/.cursor-plugin/scripts/src/services/deps-targets.ts new file mode 100644 index 0000000..e190a33 --- /dev/null +++ b/.cursor-plugin/scripts/src/services/deps-targets.ts @@ -0,0 +1,33 @@ +/** + * Resolve the 3 self-heal targets from the marketplace layout. + * Built-ins only — importable before node_modules exists. + * @description SRP: compute {dir, marker, label} triples only, no I/O side effects. + */ +import { join } from "node:path"; +import type { DepsTarget } from "../interfaces/ensure-deps"; + +/** + * @param scriptsDir - Absolute dir of the running hooks-loader.ts (its own `import.meta.dir`) + * @param pluginsDir - Absolute deployed `/plugins` dir + * @returns the 3 managed deps targets: scripts/, plugins/ (shared harness), plugins/core-guards/statusline + */ +export function getManagedDepsTargets(scriptsDir: string, pluginsDir: string): DepsTarget[] { + const statuslineDir = join(pluginsDir, "core-guards/statusline"); + return [ + { + dir: scriptsDir, + marker: join(scriptsDir, "node_modules/@clack/prompts/package.json"), + label: "scripts", + }, + { + dir: pluginsDir, + marker: join(pluginsDir, "node_modules/@fusengine/harness/dist/cli/bin.mjs"), + label: "plugins (shared harness)", + }, + { + dir: statuslineDir, + marker: join(statuslineDir, "node_modules"), + label: "core-guards/statusline", + }, + ]; +} diff --git a/.cursor-plugin/scripts/src/services/enforce-ttl.ts b/.cursor-plugin/scripts/src/services/enforce-ttl.ts new file mode 100644 index 0000000..7345bf8 --- /dev/null +++ b/.cursor-plugin/scripts/src/services/enforce-ttl.ts @@ -0,0 +1,31 @@ +/** + * Enforcement TTL service + * Single Responsibility: let the user set FUSE_ENFORCE_TTL_SEC in ~/.cursor/.env. + */ +import * as p from "@clack/prompts"; +import { upsertEnvVar } from "./env-file"; +import type { Settings } from "./settings-manager"; + +const TTL_KEY = "FUSE_ENFORCE_TTL_SEC"; +const TTL_OPTIONS = [ + { value: "120", label: "2 min (default)" }, + { value: "240", label: "4 min" }, + { value: "480", label: "8 min" }, + { value: "600", label: "10 min" }, +] as const; + +/** + * Prompt for the APEX/SOLID enforcement TTL and persist it to ~/.cursor/.env. + * @param settings - current settings object (returned unchanged, kept for chaining) + */ +export async function promptEnforceTtl(settings: Settings): Promise { + const choice = await p.select({ + message: "APEX/SOLID enforcement TTL (freshness window for hooks)?", + options: TTL_OPTIONS.map((o) => ({ value: o.value, label: o.label })), + initialValue: "120", + }); + if (p.isCancel(choice)) return settings; + upsertEnvVar(TTL_KEY, choice as string); + p.log.success(`Enforcement TTL set to ${Number(choice) / 60}min (${TTL_KEY}=${choice})`); + return settings; +} diff --git a/.cursor-plugin/scripts/src/services/ensure-deps.ts b/.cursor-plugin/scripts/src/services/ensure-deps.ts new file mode 100644 index 0000000..356ec26 --- /dev/null +++ b/.cursor-plugin/scripts/src/services/ensure-deps.ts @@ -0,0 +1,48 @@ +/** + * Self-heal: reinstall missing node_modules for the loader's managed dirs. + * Built-ins only (zero third-party import) — must survive running before + * node_modules exists at all, since it is what repairs it. + * @description SRP: orchestrate check → single-flight lock → fail-open install. + */ +import { existsSync } from "node:fs"; +import { join } from "node:path"; +import type { DepsTarget, InstallFn } from "../interfaces/ensure-deps"; +import { runBunInstall } from "../utils/bun-install-runner"; +import { tryAcquireLock } from "../utils/single-flight-lock"; + +const INSTALL_TIMEOUT_MS = 60_000; + +/** + * Ensure every managed target's node_modules marker exists, reinstalling + * in-place (single-flight, fail-open) when missing. Never throws and never + * blocks a caller longer than `INSTALL_TIMEOUT_MS` per target — a skipped or + * failed install just leaves the marker missing for the next invocation. + * + * @param targets - Directories to check/install, each with its presence marker + * @param install - Installer to invoke (defaults to real `bun install`; tests inject a fake) + */ +export async function ensureDeps( + targets: DepsTarget[], + install: InstallFn = runBunInstall, +): Promise { + for (const target of targets) { + if (existsSync(target.marker)) continue; + await healOne(target, install); + } +} + +/** Single-flight-locked repair of one target; a lost race or a bare dir is a no-op. */ +async function healOne(target: DepsTarget, install: InstallFn): Promise { + const release = tryAcquireLock(target.dir); + if (!release) return; // another process is already installing — skip, fail-open + + try { + if (existsSync(target.marker)) return; // installed while we waited for the lock + if (!existsSync(join(target.dir, "package.json"))) return; // nothing to install + await install(target.dir, INSTALL_TIMEOUT_MS); + } catch (error) { + console.error(`[ensure-deps] install failed for ${target.label}:`, error); + } finally { + release(); + } +} diff --git a/.cursor-plugin/scripts/src/services/env-file.ts b/.cursor-plugin/scripts/src/services/env-file.ts new file mode 100644 index 0000000..dbe6e80 --- /dev/null +++ b/.cursor-plugin/scripts/src/services/env-file.ts @@ -0,0 +1,96 @@ +/** + * Environment file read/write service + * Single Responsibility: Read and write .env file + */ +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; + +const HOME = process.env.HOME || process.env.USERPROFILE || ""; +export const ENV_FILE = join(HOME, ".cursor", ".env"); + +/** Matches a single `export KEY=value` line, as written by saveEnvFile. */ +const KV_LINE = /^export\s+(\w+)=["']?([^"'\n]*)["']?/; + +/** + * Read existing variables from .env file + */ +export function loadEnvFile(): Record { + if (!existsSync(ENV_FILE)) return {}; + + const content = readFileSync(ENV_FILE, "utf8"); + const env: Record = {}; + + for (const line of content.split("\n")) { + const match = line.match(KV_LINE); + if (match) { + env[match[1]] = match[2]; + } + } + + return env; +} + +/** Save variables to .env file, preserving comments/blank/non-export lines verbatim. */ +export function saveEnvFile(env: Record): void { + mkdirSync(dirname(ENV_FILE), { recursive: true }); + + if (!existsSync(ENV_FILE)) { + const lines = Object.entries(env) + .filter(([_, value]) => value) + .map(([key, value]) => `export ${key}="${value}"`); + writeFileSync(ENV_FILE, `${lines.join("\n")}\n`); + return; + } + + const content = readFileSync(ENV_FILE, "utf8"); + const matchedKeys = new Set(); + const lines: string[] = []; + // Strip a trailing-newline artifact: the write below always re-appends `\n`. + const stripped = content.endsWith("\n") ? content.slice(0, -1) : content; + const sourceLines = stripped === "" ? [] : stripped.split("\n"); + + for (const line of sourceLines) { + const match = line.match(KV_LINE); + if (!match) { + lines.push(line); + continue; + } + const key = match[1]; + matchedKeys.add(key); + // Object.hasOwn guards against inherited/prototype keys (e.g. "__proto__"), + // matching the previous writer's Object.entries()-only semantics. + if (Object.hasOwn(env, key) && env[key]) { + lines.push(`export ${key}="${env[key]}"`); + } + } + + for (const [key, value] of Object.entries(env)) { + if (value && !matchedKeys.has(key)) { + lines.push(`export ${key}="${value}"`); + } + } + + writeFileSync(ENV_FILE, `${lines.join("\n")}\n`); +} + +/** + * Idempotently write/update a single variable in .env file. + * Replaces the existing value if the key is already present, else appends it. + * @param name - Variable name (e.g. "FUSE_HARNESS_REFS") + * @param value - Variable value to persist + */ +export function upsertEnvVar(name: string, value: string): void { + const env = loadEnvFile(); + env[name] = value; + saveEnvFile(env); +} + +/** + * Remove a single variable from .env file, if present. + * @param name - Variable name to remove (e.g. "FUSE_ENFORCE_GEMINI_MCP") + */ +export function removeEnvVar(name: string): void { + const env = loadEnvFile(); + delete env[name]; + saveEnvFile(env); +} diff --git a/.cursor-plugin/scripts/src/services/env-manager.ts b/.cursor-plugin/scripts/src/services/env-manager.ts new file mode 100644 index 0000000..f63932c --- /dev/null +++ b/.cursor-plugin/scripts/src/services/env-manager.ts @@ -0,0 +1,10 @@ +/** + * Environment manager - Re-exports all env services + * Single Responsibility: Module aggregator for env configuration + */ + +// Re-export env file operations +export { ENV_FILE, loadEnvFile, saveEnvFile } from "./env-file"; + +// Re-export shell configuration +export { configureShell } from "./shell-config"; diff --git a/.cursor-plugin/scripts/src/services/env-route.ts b/.cursor-plugin/scripts/src/services/env-route.ts new file mode 100644 index 0000000..aa3cf46 --- /dev/null +++ b/.cursor-plugin/scripts/src/services/env-route.ts @@ -0,0 +1,35 @@ +/** + * Env routing helper + * Single Responsibility: route a settings key to ~/.cursor/.env (FUSE_-prefixed + * keys, loaded directly by the harness) or the settings.json env block + * (everything else). Shared by the harness gates/tuning prompts, which mix + * both kinds of keys under one interactive flow. + */ +import { removeEnvVar, upsertEnvVar } from "./env-file"; +import type { Settings } from "./settings-manager"; + +/** Read a key's current value from ~/.cursor/.env (FUSE_*) or settings.env. */ +export function readRoutedVar( + settings: Settings, + envFile: Record, + key: string, +): string | undefined { + if (key.startsWith("FUSE_")) return envFile[key]; + return (settings.env as Record | undefined)?.[key]; +} + +/** + * Persist a key's value to ~/.cursor/.env (FUSE_*) or settings.env. + * `undefined` removes the key instead of writing it. + */ +export function writeRoutedVar(settings: Settings, key: string, value: string | undefined): void { + if (key.startsWith("FUSE_")) { + if (value === undefined) removeEnvVar(key); + else upsertEnvVar(key, value); + return; + } + const env = (settings.env as Record) || {}; + if (value === undefined) delete env[key]; + else env[key] = value; + settings.env = env; +} diff --git a/.cursor-plugin/scripts/src/services/global-artifacts.ts b/.cursor-plugin/scripts/src/services/global-artifacts.ts new file mode 100644 index 0000000..653efe5 --- /dev/null +++ b/.cursor-plugin/scripts/src/services/global-artifacts.ts @@ -0,0 +1,49 @@ +/** + * Global artifact hashing. + * @description SRP: deterministic content hashing for owned global artifacts + * (plugin directories and the installed rule file) only. + */ +import crypto from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; + +/** Recursively hash one directory's structure and file contents in stable name order. */ +function visitDirectory(root: string, relative: string, hash: crypto.Hash): void { + const directory = path.join(root, relative); + const entries = fs + .readdirSync(directory, { withFileTypes: true }) + .sort((a, b) => a.name.localeCompare(b.name)); + for (const entry of entries) { + const childRelative = path.join(relative, entry.name); + const child = path.join(root, childRelative); + if (entry.isSymbolicLink()) throw new Error(`owned artifact contains a symlink: ${child}`); + hash.update(`${childRelative}\0${entry.isDirectory() ? "d" : "f"}\0`); + if (entry.isDirectory()) visitDirectory(root, childRelative, hash); + else if (entry.isFile()) hash.update(fs.readFileSync(child)); + else throw new Error(`unsupported owned artifact type: ${child}`); + } +} + +/** + * Hash a real directory tree deterministically without following symlinks. + * @param root - Absolute path of the directory to hash. + * @returns Hex-encoded SHA-256 digest of the tree's names, kinds and contents. + */ +export function hashDirectory(root: string): string { + const stat = fs.lstatSync(root); + if (stat.isSymbolicLink() || !stat.isDirectory()) throw new Error(`expected real directory: ${root}`); + const hash = crypto.createHash("sha256"); + visitDirectory(root, "", hash); + return hash.digest("hex"); +} + +/** + * Hash a real regular file without following symlinks. + * @param file - Absolute path of the file to hash. + * @returns Hex-encoded SHA-256 digest of the file's contents. + */ +export function hashFile(file: string): string { + const stat = fs.lstatSync(file); + if (stat.isSymbolicLink() || !stat.isFile()) throw new Error(`expected real file: ${file}`); + return crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex"); +} diff --git a/.cursor-plugin/scripts/src/services/global-install.ts b/.cursor-plugin/scripts/src/services/global-install.ts new file mode 100644 index 0000000..c4ae25d --- /dev/null +++ b/.cursor-plugin/scripts/src/services/global-install.ts @@ -0,0 +1,162 @@ +/** + * User-global Cursor plugin installer. + * @description SRP: orchestrate install/uninstall of the shared + * `~/.cursor/plugins/local` snapshot and its sibling rule/receipt files. + * All writes stay below the resolved user home. Running this module (as the + * former `global-install.mjs` did) parses `process.argv` and executes the + * install or uninstall flow immediately. + */ +import fs from "node:fs"; +import path from "node:path"; +import { assertNotSymlink, withProjectLock } from "./project-files"; +import { hashDirectory, hashFile } from "./global-artifacts"; +import { inventory } from "./global-inventory"; +import { resolveGlobalPaths } from "./global-paths"; +import { GLOBAL_CONTROL_MARKER_CONTENT, preflightOwned, readGlobalReceipt } from "./global-receipt"; +import { commitGlobalSnapshot, recoverGlobalTransaction } from "./global-transaction"; +import type { GlobalOptions, GlobalPaths, GlobalReceipt } from "../interfaces/global-install"; + +const paths: GlobalPaths = resolveGlobalPaths(import.meta.dir); + +/** Parse `--dry-run` / `--uninstall` / `--help` from the given CLI arguments. */ +export function parseGlobalOptions(argv: string[]): GlobalOptions { + const parsed: GlobalOptions = { dryRun: false, uninstall: false }; + for (const argument of argv) { + if (argument === "--dry-run") parsed.dryRun = true; + else if (argument === "--uninstall") parsed.uninstall = true; + else if (argument === "--help" || argument === "-h") { + process.stdout.write( + "Usage: install.sh [--dry-run] [--uninstall]\n install.sh --project [--dry-run] [--uninstall]\n", + ); + process.exit(0); + } else throw new Error(`unknown global option: ${argument}`); + } + return parsed; +} + +const options: GlobalOptions = parseGlobalOptions(process.argv.slice(2)); + +/** Reject a symlink anywhere along the global control paths, or under one plugin name. */ +function validatePathChain(pluginNames: string[] = []): void { + const targets = [ + paths.cursorRoot, + paths.pluginsRoot, + paths.localRoot, + paths.rulesRoot, + paths.rulePath, + paths.controlRoot, + paths.receiptPath, + paths.controlMarker, + ]; + for (const target of targets) assertNotSymlink(target); + for (const name of pluginNames) assertNotSymlink(path.join(paths.localRoot, name)); +} + +/** Snapshot `plugins/local` (or an empty directory) into a fresh stage for this nonce. */ +function stageLocal(nonce: string): string { + const stage = path.join(paths.pluginsRoot, `.fusengine-local-stage-${nonce}`); + fs.mkdirSync(paths.pluginsRoot, { recursive: true }); + if (fs.existsSync(paths.localRoot)) fs.cpSync(paths.localRoot, stage, { recursive: true }); + else fs.mkdirSync(stage); + return stage; +} + +/** Install (or dry-run preview) the 24 marketplace plugins and the global rule. */ +function installGlobal(): void { + recoverGlobalTransaction(paths.cursorRoot); + const plugins = inventory(paths.sourceRoot); + const rulesPlugin = plugins.find((plugin) => plugin.name === "fuse-rules"); + if (!rulesPlugin) throw new Error("global install requires fuse-rules"); + const ruleSource = path.join(rulesPlugin.source, "user-rules", "fuse-global.mdc"); + validatePathChain(plugins.map((plugin) => plugin.name)); + const previous = readGlobalReceipt(paths); + preflightOwned(paths, previous, plugins); + const rule = fs.readFileSync(ruleSource); + if (options.dryRun) { + process.stdout.write(`Would install 24 plugins under ${paths.localRoot}\nWould write ${paths.rulePath}\nDry run: nothing written.\n`); + return; + } + const nonce = `${process.pid}-${Date.now()}`; + const stage = stageLocal(nonce); + try { + const hashes: Record = {}; + for (const plugin of plugins) { + const target = path.join(stage, plugin.name); + if (fs.existsSync(target)) fs.rmSync(target, { recursive: true }); + fs.cpSync(plugin.source, target, { recursive: true }); + hashes[plugin.name] = hashDirectory(target); + } + const receipt: GlobalReceipt = { version: 1, plugins: hashes, ruleHash: hashFile(ruleSource) }; + const receiptBuffer = Buffer.from(`${JSON.stringify(receipt, null, 2)}\n`); + commitGlobalSnapshot({ + cursorRoot: paths.cursorRoot, + stageRoot: stage, + finalFiles: [ + { path: paths.rulePath, content: rule }, + { path: paths.receiptPath, content: receiptBuffer }, + { path: paths.controlMarker, content: GLOBAL_CONTROL_MARKER_CONTENT }, + ], + }); + } catch (error) { + if (fs.existsSync(stage)) fs.rmSync(stage, { recursive: true }); + throw error; + } + process.stdout.write(`Installed 24 user-global plugins in ${paths.localRoot}\nReload the Cursor window to activate them.\n`); +} + +/** Uninstall (or dry-run preview) only the artifacts still matching the receipt. */ +function uninstallGlobal(): void { + recoverGlobalTransaction(paths.cursorRoot); + validatePathChain(); + const receipt = readGlobalReceipt(paths); + if (!receipt) throw new Error(`no managed global installation at ${paths.controlRoot}`); + if (options.dryRun) { + process.stdout.write(`Would remove only intact Fusengine global plugins and rule under ${paths.cursorRoot}\nDry run: nothing written.\n`); + return; + } + const nonce = `${process.pid}-${Date.now()}`; + const stage = stageLocal(nonce); + try { + for (const [name, expectedHash] of Object.entries(receipt.plugins)) { + if (path.basename(name) !== name) throw new Error(`unsafe receipt plugin name: ${name}`); + const active = path.join(paths.localRoot, name); + if (!fs.existsSync(active)) continue; + if (hashDirectory(active) === expectedHash) fs.rmSync(path.join(stage, name), { recursive: true }); + else process.stderr.write(`warning: preserved modified global plugin ${active}\n`); + } + const removeRule = fs.existsSync(paths.rulePath) && hashFile(paths.rulePath) === receipt.ruleHash; + if (fs.existsSync(paths.rulePath) && !removeRule) { + process.stderr.write(`warning: preserved modified global rule ${paths.rulePath}\n`); + } + const finalFiles = [ + { path: paths.receiptPath, content: null }, + { path: paths.controlMarker, content: null }, + ]; + if (removeRule) finalFiles.push({ path: paths.rulePath, content: null }); + commitGlobalSnapshot({ cursorRoot: paths.cursorRoot, stageRoot: stage, finalFiles }); + // The control root is ours in full: readGlobalReceipt refused to go any + // further unless the ownership marker matched, and every install rewrites + // the whole tree anyway (copyLoaderTree removes it first). So take it down + // entirely — a non-recursive rmdir with a swallowed error left the deployed + // loader, its src/ tree and the harness node_modules the wrapper installs + // there behind forever, with no code path able to reclaim them. + const stat = fs.lstatSync(paths.controlRoot); + if (stat.isSymbolicLink() || !stat.isDirectory()) { + throw new Error(`expected real directory: ${paths.controlRoot}`); + } + fs.rmSync(paths.controlRoot, { recursive: true, force: true }); + } catch (error) { + if (fs.existsSync(stage)) fs.rmSync(stage, { recursive: true }); + throw error; + } + process.stdout.write(`Uninstalled intact user-global Fusengine artifacts from ${paths.cursorRoot}\n`); +} + +try { + validatePathChain(); + if (options.dryRun) (options.uninstall ? uninstallGlobal : installGlobal)(); + else withProjectLock(paths.cursorRoot, options.uninstall ? uninstallGlobal : installGlobal); +} catch (error) { + process.stderr.write(`error: ${(error as Error).message}\n`); + process.exit(1); +} diff --git a/.cursor-plugin/scripts/src/services/global-inventory.ts b/.cursor-plugin/scripts/src/services/global-inventory.ts new file mode 100644 index 0000000..f7f60c8 --- /dev/null +++ b/.cursor-plugin/scripts/src/services/global-inventory.ts @@ -0,0 +1,27 @@ +/** + * Global installer inventory. + * @description SRP: read `marketplace.json` and resolve it to real plugin + * source directories, only. + */ +import path from "node:path"; +import { resolveMarketplacePlugins } from "./marketplace-sources"; +import { readGlobalJson } from "./global-paths"; +import type { GlobalPluginSource } from "../interfaces/global-install"; + +/** Marketplace manifest shape read from `.cursor-plugin/marketplace.json`. */ +interface MarketplaceManifest { + plugins: unknown[]; +} + +/** + * Read the marketplace manifest and resolve its declared plugins to their + * real on-disk source directories. + * @param sourceRoot - Repository root (or the test-injected source root override). + */ +export function inventory(sourceRoot: string): GlobalPluginSource[] { + const market = readGlobalJson(path.join(sourceRoot, ".cursor-plugin", "marketplace.json")); + if (!Array.isArray(market.plugins) || market.plugins.length !== 24) { + throw new Error("global install requires exactly 24 marketplace plugins"); + } + return resolveMarketplacePlugins(sourceRoot, market); +} diff --git a/.cursor-plugin/scripts/src/services/global-paths.ts b/.cursor-plugin/scripts/src/services/global-paths.ts new file mode 100644 index 0000000..7e23099 --- /dev/null +++ b/.cursor-plugin/scripts/src/services/global-paths.ts @@ -0,0 +1,52 @@ +/** + * Global installer path resolution. + * @description SRP: resolve every filesystem path the global installer reads + * or writes, and the tiny shared JSON-read helper, only. + */ +import fs from "node:fs"; +import path from "node:path"; +import type { GlobalPaths } from "../interfaces/global-install"; + +/** + * Resolve every path the global installer operates on, rooted at the real + * user home directory (or the test-injected source root override). + * @param scriptDir - Directory of the calling module (`import.meta.dir`), + * four levels below the repository root (`scripts/src/services`). + */ +export function resolveGlobalPaths(scriptDir: string): GlobalPaths { + const repositoryRoot = path.resolve(scriptDir, "../../../.."); + const sourceRoot = + process.env.NODE_ENV === "test" && process.env.FUSE_INSTALL_TEST_SOURCE_ROOT + ? path.resolve(process.env.FUSE_INSTALL_TEST_SOURCE_ROOT) + : repositoryRoot; + const homeInput = process.env.HOME || process.env.USERPROFILE; + if (!homeInput || !fs.existsSync(homeInput)) { + throw new Error("HOME or USERPROFILE must reference an existing directory"); + } + const homeRoot = fs.realpathSync(homeInput); + const cursorRoot = path.join(homeRoot, ".cursor"); + const pluginsRoot = path.join(cursorRoot, "plugins"); + const localRoot = path.join(pluginsRoot, "local"); + const rulesRoot = path.join(cursorRoot, "rules"); + const controlRoot = path.join(cursorRoot, ".fusengine-global"); + return { + sourceRoot, + homeRoot, + cursorRoot, + pluginsRoot, + localRoot, + rulesRoot, + rulePath: path.join(rulesRoot, "fuse-global.mdc"), + controlRoot, + receiptPath: path.join(controlRoot, "receipt.json"), + controlMarker: path.join(controlRoot, ".managed-by-fusengine"), + }; +} + +/** + * Read and parse one JSON file. + * @param file - Absolute path to the JSON file. + */ +export function readGlobalJson(file: string): T { + return JSON.parse(fs.readFileSync(file, "utf8")) as T; +} diff --git a/.cursor-plugin/scripts/src/services/global-receipt.ts b/.cursor-plugin/scripts/src/services/global-receipt.ts new file mode 100644 index 0000000..46b29c4 --- /dev/null +++ b/.cursor-plugin/scripts/src/services/global-receipt.ts @@ -0,0 +1,61 @@ +/** + * Global installer receipt. + * @description SRP: read/validate the global ownership receipt, and refuse to + * overwrite a plugin or rule the user has modified since install, only. + */ +import fs from "node:fs"; +import path from "node:path"; +import { hashDirectory, hashFile } from "./global-artifacts"; +import { readGlobalJson } from "./global-paths"; +import type { GlobalPaths, GlobalPluginSource, GlobalReceipt } from "../interfaces/global-install"; + +/** Content written to the control marker file that proves Fusengine owns `controlRoot`. */ +export const GLOBAL_CONTROL_MARKER_CONTENT = "fusengine cursor global installation\n"; + +/** + * Read and validate the global receipt, or `null` when nothing is installed yet. + * @param paths - Resolved global installer paths. + */ +export function readGlobalReceipt(paths: GlobalPaths): GlobalReceipt | null { + if (!fs.existsSync(paths.controlRoot)) return null; + if (!fs.existsSync(paths.receiptPath) || !fs.existsSync(paths.controlMarker)) { + throw new Error(`refusing unowned global control directory: ${paths.controlRoot}`); + } + if (fs.readFileSync(paths.controlMarker, "utf8") !== GLOBAL_CONTROL_MARKER_CONTENT) { + throw new Error(`invalid global ownership marker: ${paths.controlMarker}`); + } + const receipt = readGlobalJson(paths.receiptPath); + if (receipt.version !== 1 || !receipt.plugins || typeof receipt.plugins !== "object") { + throw new Error(`invalid global receipt: ${paths.receiptPath}`); + } + return receipt; +} + +/** + * Refuse to install over a plugin directory or rule file the receipt does not + * already own with a matching content hash. + * @param paths - Resolved global installer paths. + * @param receipt - Prior receipt, or `null` on a first install. + * @param plugins - Marketplace plugins about to be installed. + */ +export function preflightOwned( + paths: GlobalPaths, + receipt: GlobalReceipt | null, + plugins: GlobalPluginSource[], +): void { + for (const plugin of plugins) { + const target = path.join(paths.localRoot, plugin.name); + if (!fs.existsSync(target)) continue; + const priorHash = receipt?.plugins?.[plugin.name]; + if (!priorHash) throw new Error(`refusing pre-existing global plugin: ${target}`); + if (hashDirectory(target) !== priorHash) { + throw new Error(`refusing to overwrite modified owned plugin: ${target}`); + } + } + if (fs.existsSync(paths.rulePath)) { + if (!receipt?.ruleHash) throw new Error(`refusing pre-existing global rule: ${paths.rulePath}`); + if (hashFile(paths.rulePath) !== receipt.ruleHash) { + throw new Error(`refusing to overwrite modified owned rule: ${paths.rulePath}`); + } + } +} diff --git a/.cursor-plugin/scripts/src/services/global-transaction.ts b/.cursor-plugin/scripts/src/services/global-transaction.ts new file mode 100644 index 0000000..81da672 --- /dev/null +++ b/.cursor-plugin/scripts/src/services/global-transaction.ts @@ -0,0 +1,167 @@ +/** + * Global install/uninstall transaction. + * @description SRP: atomically swap the shared `~/.cursor/plugins/local` + * snapshot and finalize the sibling global files (rule + receipt + marker), + * with journal-based crash recovery only. + */ +import fs from "node:fs"; +import path from "node:path"; +import { restoreFile, writeFileAtomic } from "./project-files"; +import type { + CommitGlobalSnapshotArgs, + GlobalTransactionJournal, + GlobalTransactionRoots, +} from "../interfaces/global-install"; + +const journalName = ".fusengine-global-transaction.json"; +const snapshotFiles = [ + "rules/fuse-global.mdc", + ".fusengine-global/receipt.json", + ".fusengine-global/.managed-by-fusengine", +] as const; + +/** The backup/local/stage directory triplet for one transaction nonce. */ +function transactionPaths(cursorRoot: string, nonce: string): GlobalTransactionRoots { + const pluginsRoot = path.join(cursorRoot, "plugins"); + return { + backup: path.join(pluginsRoot, `.fusengine-local-backup-${nonce}`), + local: path.join(pluginsRoot, "local"), + stage: path.join(pluginsRoot, `.fusengine-local-stage-${nonce}`), + }; +} + +/** Base64-snapshot the small global files the transaction also mutates. */ +function snapshot(cursorRoot: string): Record { + return Object.fromEntries( + snapshotFiles.map((relative) => { + const file = path.join(cursorRoot, relative); + return [relative, fs.existsSync(file) ? fs.readFileSync(file).toString("base64") : null]; + }), + ); +} + +/** Restore the small global files from a prior base64 snapshot. */ +function restoreSnapshot(cursorRoot: string, values: Record): void { + for (const relative of snapshotFiles) { + const encoded = values[relative]; + restoreFile(path.join(cursorRoot, relative), encoded === null ? null : Buffer.from(encoded, "base64")); + } +} + +function journalFile(cursorRoot: string): string { + return path.join(cursorRoot, journalName); +} + +function writeJournal(cursorRoot: string, journal: GlobalTransactionJournal): void { + writeFileAtomic(journalFile(cursorRoot), `${JSON.stringify(journal, null, 2)}\n`); +} + +function removeTree(target: string): void { + if (fs.existsSync(target)) fs.rmSync(target, { recursive: true }); +} + +/** + * Recover an interrupted shared global-local swap before any new staging work. + * @param cursorRoot - `~/.cursor`. + */ +export function recoverGlobalTransaction(cursorRoot: string): void { + const file = journalFile(cursorRoot); + if (!fs.existsSync(file)) return; + const journal = JSON.parse(fs.readFileSync(file, "utf8")) as GlobalTransactionJournal; + if ( + journal.version !== 1 || + !/^\d+-\d+$/.test(journal.nonce) || + !snapshotFiles.every((name) => Object.hasOwn(journal.snapshot, name)) + ) { + throw new Error(`invalid global transaction journal: ${file}`); + } + const roots = transactionPaths(cursorRoot, journal.nonce); + if (journal.phase === "prepared") { + if (journal.hadLocal && !fs.existsSync(roots.local) && fs.existsSync(roots.backup)) { + fs.renameSync(roots.backup, roots.local); + } else if ( + journal.hadLocal && + fs.existsSync(roots.local) && + fs.existsSync(roots.backup) && + !fs.existsSync(roots.stage) + ) { + removeTree(roots.local); + fs.renameSync(roots.backup, roots.local); + restoreSnapshot(cursorRoot, journal.snapshot); + } + removeTree(roots.stage); + } else if (journal.phase === "old-moved") { + if (journal.hadLocal && fs.existsSync(roots.local) && fs.existsSync(roots.backup)) { + removeTree(roots.local); + fs.renameSync(roots.backup, roots.local); + restoreSnapshot(cursorRoot, journal.snapshot); + } else if (journal.hadLocal && !fs.existsSync(roots.local) && fs.existsSync(roots.backup)) { + fs.renameSync(roots.backup, roots.local); + } else if (!journal.hadLocal && fs.existsSync(roots.local) && !fs.existsSync(roots.stage)) { + removeTree(roots.local); + restoreSnapshot(cursorRoot, journal.snapshot); + } + removeTree(roots.stage); + } else if (journal.phase === "new-moved") { + removeTree(roots.local); + if (journal.hadLocal && fs.existsSync(roots.backup)) fs.renameSync(roots.backup, roots.local); + restoreSnapshot(cursorRoot, journal.snapshot); + } else if ((journal.phase as string) !== "finalized") { + throw new Error(`invalid global transaction phase: ${String(journal.phase)}`); + } + removeTree(roots.backup); + removeTree(roots.stage); + fs.rmSync(file); +} + +/** Test-only failure injection point, mirroring the equivalent project transaction. */ +function inject(point: string): void { + if (process.env.NODE_ENV === "test" && process.env.FUSE_GLOBAL_TEST_FAIL_POINT === point) { + throw new Error(`injected global transaction failure at ${point}`); + } +} + +/** + * Atomically swap the shared local-plugin snapshot and finalize global files + * with crash recovery. + * @param args - Cursor root, staged `local` replacement, and files to finalize. + */ +export function commitGlobalSnapshot({ cursorRoot, stageRoot, finalFiles }: CommitGlobalSnapshotArgs): void { + recoverGlobalTransaction(cursorRoot); + const match = /^\.fusengine-local-stage-(\d+-\d+)$/.exec(path.basename(stageRoot)); + if (!match) throw new Error(`invalid global stage path: ${stageRoot}`); + const nonce = match[1] as string; + const roots = transactionPaths(cursorRoot, nonce); + const journal: GlobalTransactionJournal = { + version: 1, + nonce, + phase: "prepared", + hadLocal: fs.existsSync(roots.local), + snapshot: snapshot(cursorRoot), + }; + writeJournal(cursorRoot, journal); + try { + inject("prepared"); + if (journal.hadLocal) fs.renameSync(roots.local, roots.backup); + inject("after-old-rename-before-journal"); + journal.phase = "old-moved"; + writeJournal(cursorRoot, journal); + inject("after-old-move"); + fs.renameSync(stageRoot, roots.local); + inject("after-new-rename-before-journal"); + journal.phase = "new-moved"; + writeJournal(cursorRoot, journal); + inject("after-new-move"); + for (const item of finalFiles) { + if (item.content === null) restoreFile(item.path, null); + else writeFileAtomic(item.path, item.content); + } + journal.phase = "finalized"; + writeJournal(cursorRoot, journal); + inject("after-finalize"); + } catch (error) { + recoverGlobalTransaction(cursorRoot); + throw error; + } + recoverGlobalTransaction(cursorRoot); +} diff --git a/.cursor-plugin/scripts/src/services/harness-env.ts b/.cursor-plugin/scripts/src/services/harness-env.ts new file mode 100644 index 0000000..8d1653f --- /dev/null +++ b/.cursor-plugin/scripts/src/services/harness-env.ts @@ -0,0 +1,51 @@ +/** + * Harness env service + * Single Responsibility: point @fusengine/harness at every plugin's SOLID + * reference dir — FUSE_HARNESS_REFS is a path-delimiter list of dirs (no copying, + * no aggregation): the harness scans each one directly. Persisted to + * ~/.cursor/.env (not settings.json — the harness loads .env directly). + */ +import { readdirSync, existsSync } from "node:fs"; +import { join, delimiter } from "node:path"; +import * as p from "@clack/prompts"; +import { upsertEnvVar } from "./env-file"; +import type { Settings } from "./settings-manager"; + +/** readdirSync that returns [] instead of throwing on a missing dir. */ +function readEntries(dir: string): string[] { + try { + return readdirSync(dir); + } catch { + return []; + } +} + +/** Every existing plugins//skills/solid-/references directory. */ +function solidRefDirs(pluginsDir: string): string[] { + const dirs: string[] = []; + for (const plugin of readEntries(pluginsDir)) { + for (const skill of readEntries(join(pluginsDir, plugin, "skills"))) { + if (!skill.startsWith("solid-")) continue; + const refs = join(pluginsDir, plugin, "skills", skill, "references"); + if (existsSync(refs)) dirs.push(refs); + } + } + return dirs; +} + +/** + * Set FUSE_HARNESS_REFS (~/.cursor/.env) to every solid-* refs dir. + * + * Takes the plugins directory directly instead of deriving it: Cursor deploys + * plugins flat under `~/.cursor/plugins/local`, so `join(marketplace,"plugins")` + * landed one level short and silently produced an empty ref list. + * @param settings - Passed through unchanged. + * @param pluginsDir - Absolute directory holding the deployed plugins. + * @returns The same settings object. + */ +export function setHarnessRefs(settings: Settings, pluginsDir: string): Settings { + const dirs = solidRefDirs(pluginsDir); + upsertEnvVar("FUSE_HARNESS_REFS", dirs.join(delimiter)); + p.log.success(`Harness SOLID refs set (${dirs.length} skill dirs, FUSE_HARNESS_REFS)`); + return settings; +} diff --git a/.cursor-plugin/scripts/src/services/harness-gates.ts b/.cursor-plugin/scripts/src/services/harness-gates.ts new file mode 100644 index 0000000..a2c9a66 --- /dev/null +++ b/.cursor-plugin/scripts/src/services/harness-gates.ts @@ -0,0 +1,57 @@ +/** + * Harness gates service + * Single Responsibility: opt-in enforcement gates (Gemini MCP, RALPH, Gemini + * design pipeline). FUSE_-prefixed gates persist to ~/.cursor/.env (the + * harness loads .env directly); RALPH_MODE (native, non-FUSE_) stays in + * settings.env. Only persists "1" when enabled — a disabled gate is removed, + * never written as "0", to keep both files clean. + */ +import * as p from "@clack/prompts"; +import { loadEnvFile } from "./env-file"; +import { readRoutedVar, writeRoutedVar } from "./env-route"; +import type { Settings } from "./settings-manager"; + +interface Gate { + key: string; + message: string; +} + +const GATES: readonly Gate[] = [ + { + key: "FUSE_ENFORCE_GEMINI_MCP", + message: "Enforce Gemini Design MCP for Tailwind writes? (blocks hand-written Tailwind)", + }, + { + key: "RALPH_MODE", + message: "Enable RALPH mode? (git safe-commands skip confirmation)", + }, + { + key: "FUSE_DESIGN_GEMINI", + message: "Enable Gemini design pipeline gates?", + }, +]; + +/** + * Prompt for the opt-in harness gates and persist enabled ones. + * An already-set var is surfaced with a keep/replace choice; a gate left off is + * removed rather than stored as "0". + * @param settings - current settings object (mutated + returned) + */ +export async function promptHarnessGates(settings: Settings): Promise { + const envFile = loadEnvFile(); + for (const gate of GATES) { + const current = readRoutedVar(settings, envFile, gate.key); + if (current !== undefined) { + const keep = await p.confirm({ + message: `${gate.key} is set to "${current}". Keep it?`, + initialValue: true, + }); + if (p.isCancel(keep)) continue; + if (keep) continue; + } + const enable = await p.confirm({ message: gate.message, initialValue: false }); + if (p.isCancel(enable)) continue; + writeRoutedVar(settings, gate.key, enable ? "1" : undefined); + } + return settings; +} diff --git a/.cursor-plugin/scripts/src/services/harness-path-resolver.ts b/.cursor-plugin/scripts/src/services/harness-path-resolver.ts new file mode 100644 index 0000000..af2f9dd --- /dev/null +++ b/.cursor-plugin/scripts/src/services/harness-path-resolver.ts @@ -0,0 +1,45 @@ +/** + * Harness path resolver service + * SRP: at install time, resolve the literal `$HOME` placeholder inside every + * deployed `plugins//hooks/hooks.json` into an absolute, OS-aware home + * path. The repo SOURCE keeps `$HOME` (portable, versionable); this resolution + * runs once, only on the deployed marketplace copy under `pluginsDir`. + */ +import { readFileSync, writeFileSync } from "node:fs"; +import { discoverHooksFiles } from "../utils/hooks-discovery"; + +/** Absolute home dir, OS-aware — same fallback chain as env-file/install-hooks. */ +const HOME = process.env.HOME || process.env.USERPROFILE || ""; + +/** + * Substitute `${HOME}` then `$HOME` with the resolved absolute home path using + * literal `replaceAll` (no regex escaping). Idempotent by construction: an + * absolute home path holds no `$HOME`, so re-running leaves the file untouched. + * + * @param content - Raw hooks.json file content + * @returns Content with every HOME placeholder resolved to an absolute path + */ +function substituteHome(content: string): string { + return content.replaceAll("${HOME}", HOME).replaceAll("$HOME", HOME); +} + +/** + * Resolve `$HOME` to an absolute path across every deployed + * `pluginsDir//hooks/hooks.json`, rewriting a file only when it changes. + * Idempotent: a second run finds no placeholder left and reports 0. + * + * @param pluginsDir - Deployed marketplace plugins directory + * @returns Count of hooks.json files actually rewritten + */ +export async function resolveHomeInHooks(pluginsDir: string): Promise { + let rewritten = 0; + for (const hooksFile of discoverHooksFiles(pluginsDir)) { + const before = readFileSync(hooksFile, "utf8"); + const after = substituteHome(before); + if (after !== before) { + writeFileSync(hooksFile, after); + rewritten++; + } + } + return rewritten; +} diff --git a/.cursor-plugin/scripts/src/services/harness-tuning.ts b/.cursor-plugin/scripts/src/services/harness-tuning.ts new file mode 100644 index 0000000..a336e96 --- /dev/null +++ b/.cursor-plugin/scripts/src/services/harness-tuning.ts @@ -0,0 +1,69 @@ +/** + * Harness tuning service + * Single Responsibility: opt-in advanced harness knobs (TTLs, caches). + * FUSE_-prefixed knobs persist to ~/.cursor/.env (the harness loads .env + * directly); non-FUSE_ knobs (FUSENGINE_CACHE_TTL_MIN) stay in settings.env. + * Persists a value only when it differs from the sane default, so an + * untouched tuning run leaves both files clean. + */ +import * as p from "@clack/prompts"; +import { loadEnvFile } from "./env-file"; +import { readRoutedVar, writeRoutedVar } from "./env-route"; +import type { Settings } from "./settings-manager"; + +interface Field { + key: string; + label: string; + def: string; + numeric: boolean; +} + +const TUNING: readonly Field[] = [ + { key: "FUSE_LESSONS_THROTTLE_MIN", label: "Lessons write-reminder throttle (minutes)", def: "5", numeric: true }, + { key: "FUSE_MCP_TTL_SEC", label: "Cached MCP doc freshness (seconds)", def: "172800", numeric: true }, + { key: "FUSE_WEBFETCH_TTL_SEC", label: "WebFetch cache freshness (seconds)", def: "86400", numeric: true }, + { key: "FUSENGINE_CACHE_TTL_MIN", label: "Subagent context cache TTL (minutes)", def: "30", numeric: true }, + { key: "FUSE_HARNESS_MARKETPLACES", label: "Harness marketplaces (comma-separated)", def: "fusengine-plugins", numeric: false }, +]; + +/** Reject anything that is not a whole number (empty allowed → falls back to default). */ +function numericValidator(v: string | undefined): string | undefined { + const s = (v ?? "").trim(); + return s === "" || /^\d+$/.test(s) ? undefined : "Enter a whole number"; +} + +/** Prompt each field pre-filled with its current/default value; keep only non-default entries. */ +async function promptFields( + settings: Settings, + envFile: Record, + fields: readonly Field[], +): Promise { + for (const f of fields) { + const value = await p.text({ + message: f.label, + initialValue: readRoutedVar(settings, envFile, f.key) ?? f.def, + validate: f.numeric ? numericValidator : undefined, + }); + if (p.isCancel(value)) return false; + const v = value.trim(); + writeRoutedVar(settings, f.key, v && v !== f.def ? v : undefined); + } + return true; +} + +/** + * Prompt for advanced harness tuning (TTLs, caches). Opt-in and defaults to + * skipped; only values differing from the defaults are persisted. + * @param settings - current settings object (mutated + returned) + */ +export async function promptHarnessTuning(settings: Settings): Promise { + const wants = await p.confirm({ + message: "Configure advanced harness tuning? (TTLs, caches — sane defaults otherwise)", + initialValue: false, + }); + if (p.isCancel(wants) || !wants) return settings; + + const envFile = loadEnvFile(); + await promptFields(settings, envFile, TUNING); + return settings; +} diff --git a/.cursor-plugin/scripts/src/services/hook-executor.ts b/.cursor-plugin/scripts/src/services/hook-executor.ts new file mode 100644 index 0000000..40c6d17 --- /dev/null +++ b/.cursor-plugin/scripts/src/services/hook-executor.ts @@ -0,0 +1,200 @@ +/** + * Hook execution service + * @description SRP: execute hook commands, merge their parallel results, and + * translate the merge into Cursor's per-event wire format. + * @see https://cursor.com/docs/hooks — response schemas differ by event + * (`permission`, `additional_context`, `followup_message`, `continue`). + */ +import type { ExecutableHook, HookResult } from "../interfaces/hooks"; +import { parseHookCommand } from "../utils/command-parser"; + +/** Cursor permission verdict for blocking-capable events. */ +type CursorPermission = "allow" | "ask" | "deny"; + +/** + * Format-agnostic result of one (or several merged) hook invocations. + * Populated by {@link parseHookOutcome} from either Claude Code's nested + * `hookSpecificOutput` shape or Cursor's flat/snake_case shape, so this loader + * keeps working whether the individual hook script has been migrated yet. + */ +interface HookOutcome { + permission?: CursorPermission; + /** + * Human-readable payload: additional context, a followup, or a deny reason. + * Always a string — empty when no hook emitted any, never `undefined`, so + * callers can test it without guarding. + */ + text: string; + /** Rewritten tool input (`preToolUse` only). */ + updatedInput?: unknown; +} + +/** Cursor's expected response kind per hook event. Unlisted events → no stdout. */ +type EventKind = "permission" | "context" | "followup" | "continue"; + +/** @see https://cursor.com/docs/hooks — events without an entry emit no response body. */ +const EVENT_KIND: Partial> = { + preToolUse: "permission", + beforeShellExecution: "permission", + beforeMCPExecution: "permission", + beforeReadFile: "permission", + subagentStart: "permission", + postToolUse: "context", + sessionStart: "context", + beforeSubmitPrompt: "continue", + stop: "followup", + subagentStop: "followup", +}; + +/** Most restrictive permission wins when merging parallel hooks (deny > ask > allow). */ +const PERMISSION_PRIORITY: Record = { deny: 2, ask: 1, allow: 0 }; + +/** Build spawn argv from a command, swapping literal "bun" for the running Bun binary. */ +function toSpawnArgv(command: string): { argv: string[]; ignoreExit: boolean } { + const { argv, ignoreExit } = parseHookCommand(command); + if (argv[0] === "bun") argv[0] = process.execPath; + return { argv, ignoreExit }; +} + +/** Execute a hook (shell-free) reproducing the previous `bash -c` semantics */ +export async function executeHook( + hook: ExecutableHook, + input: string, +): Promise { + const { argv, ignoreExit } = toSpawnArgv(hook.command); + + if (hook.isAsync) { + try { + await Bun.spawn(argv, { stdout: "ignore", stderr: "ignore" }).exited; + } catch { + // Fire-and-forget: a spawn failure (ENOENT) is ignored, as before. + } + return { success: true, exitCode: 0, stdout: "", stderr: "", blocked: false }; + } + + try { + const proc = Bun.spawn(argv, { + env: { ...process.env, CURSOR_PLUGIN_ROOT: hook.pluginPath }, + stdin: new TextEncoder().encode(input), + stdout: "pipe", + stderr: "pipe", + }); + const rawExit = await proc.exited; + // `|| true` swallows every non-zero exit (incl. 2) → effective 0, never blocked. + const exitCode = ignoreExit ? 0 : rawExit; + const stdout = await new Response(proc.stdout).text(); + const stderr = await new Response(proc.stderr).text(); + return { success: exitCode === 0, exitCode, stdout, stderr, blocked: exitCode === 2 }; + } catch { + // Spawn threw (ENOENT): bash would return a non-zero exit → non-blocking failure. + // `bad-cmd || true` still yields success 0 under bash, so honour ignoreExit here too. + return ignoreExit + ? { success: true, exitCode: 0, stdout: "", stderr: "", blocked: false } + : { success: false, exitCode: 1, stdout: "", stderr: "", blocked: false }; + } +} + +/** + * Parse one hook's raw stdout into a neutral outcome. Reads both Claude Code's + * nested `hookSpecificOutput.*` and Cursor's flat/snake_case fields so a hook + * script still not migrated to Cursor's format keeps producing a usable result. + */ +function parseHookOutcome(stdout: string): HookOutcome { + const trimmed = stdout.trim(); + if (!trimmed) return { text: "" }; + let json: Record; + try { + json = JSON.parse(trimmed); + } catch { + return { text: "" }; + } + const nested = (json.hookSpecificOutput ?? {}) as Record; + const permission = (nested.permissionDecision ?? json.permission) as CursorPermission | undefined; + const text = + (nested.additionalContext as string | undefined) ?? + (json.additional_context as string | undefined) ?? + (json.additionalContext as string | undefined) ?? + (json.followup_message as string | undefined) ?? + (nested.permissionDecisionReason as string | undefined) ?? + (json.user_message as string | undefined) ?? + (json.reason as string | undefined) ?? + (json.systemMessage as string | undefined); + const updatedInput = nested.updatedInput ?? json.updated_input; + return { permission, text: text ?? "", updatedInput }; +} + +/** Merge several parallel hooks' outcomes into one (deny-wins, messages concatenated). */ +function mergeOutcomes(outcomes: HookOutcome[]): HookOutcome { + let permission: CursorPermission | undefined; + const texts: string[] = []; + let updatedInput: unknown; + for (const outcome of outcomes) { + if ( + outcome.permission && + (!permission || PERMISSION_PRIORITY[outcome.permission] > PERMISSION_PRIORITY[permission]) + ) { + permission = outcome.permission; + } + if (outcome.text) texts.push(outcome.text); + if (updatedInput === undefined) updatedInput = outcome.updatedInput; + } + return { permission, text: texts.join("\n"), updatedInput }; +} + +/** Execute a list of hooks in PARALLEL, merging their outcomes after the run. */ +export async function executeHooks( + hooks: ExecutableHook[], + input: string, +): Promise<{ blocked: boolean; stderr: string; outcome: HookOutcome }> { + if (hooks.length === 0) { + return { blocked: false, stderr: "", outcome: { text: "" } }; + } + + const results = await Promise.all(hooks.map((hook) => executeHook(hook, input))); + + // Exit code 2 == `permission: "deny"` in both Claude Code and Cursor — keep hard-blocking. + const blockedResult = results.find((r) => r.blocked); + if (blockedResult) { + return { blocked: true, stderr: blockedResult.stderr, outcome: { text: "" } }; + } + + const outcome = mergeOutcomes( + results.filter((r) => r.stdout.trim()).map((r) => parseHookOutcome(r.stdout)), + ); + const stderr = results.map((r) => r.stderr).filter((s) => s.trim()).join("\n"); + + return { blocked: false, stderr, outcome }; +} + +/** + * Translate a merged, format-agnostic hook outcome into Cursor's wire JSON for + * the given event (never Claude Code's `hookSpecificOutput`/`systemMessage`). + * Returns "" for events with no documented response schema (e.g. `afterFileEdit`), + * matching "no response expected" — printing nothing is the correct behaviour. + */ +export function buildCursorResponse( + hookType: string, + outcome: { permission?: CursorPermission; text?: string; updatedInput?: unknown }, + stderr: string, +): string { + const message = outcome.text ?? (stderr.trim() || undefined); + switch (EVENT_KIND[hookType]) { + case "permission": { + const body: Record = { permission: outcome.permission ?? "allow" }; + if (message) { + body.user_message = message; + body.agent_message = message; + } + if (outcome.updatedInput !== undefined) body.updated_input = outcome.updatedInput; + return JSON.stringify(body); + } + case "context": + return message ? JSON.stringify({ additional_context: message }) : ""; + case "followup": + return message ? JSON.stringify({ followup_message: message }) : ""; + case "continue": + return JSON.stringify({ continue: outcome.permission !== "deny" }); + default: + return ""; + } +} diff --git a/.cursor-plugin/scripts/src/services/hook-merge.ts b/.cursor-plugin/scripts/src/services/hook-merge.ts new file mode 100644 index 0000000..366049a --- /dev/null +++ b/.cursor-plugin/scripts/src/services/hook-merge.ts @@ -0,0 +1,68 @@ +/** + * Hook merge service + * @description SRP: conservatively merge the fusengine hooks-loader entry into an + * existing Cursor hooks map without clobbering user-authored entries. Idempotent. + * + * Cursor entries are flat — `{command, matcher?}` — unlike Claude Code's nested + * `{matcher, hooks:[{type, command}]}`. + */ +import type { HookType } from "../interfaces/hook-types"; + +/** A single Cursor hooks[event] entry. */ +interface HookEntry { + command?: string; + matcher?: string; + [key: string]: unknown; +} + +/** + * Path-anchored marker identifying an entry owned by the fusengine hooks-loader. + * The leading separator requires the actual `hooks-loader.ts` filename (not the + * bare word), so a user command that merely mentions "hooks-loader" — or a + * differently-named file like `my-hooks-loader.ts` — is never mistaken for ours. + * Path-independent on purpose: matches the loader across install paths so a stale + * entry from a previous location is still replaced, not duplicated. + */ +const LOADER_MARKER = "/hooks-loader.ts"; + +/** + * True when the entry routes through the fusengine hooks-loader. Loader entries + * are regenerated on every setup run; every other entry is user data and is + * preserved verbatim. + * + * @param entry - A hooks[event] entry. + * @returns Whether the entry belongs to the fusengine loader. + */ +function isLoaderEntry(entry: HookEntry): boolean { + return typeof entry.command === "string" && entry.command.includes(LOADER_MARKER); +} + +/** + * Build the canonical fusengine loader entry for one hook type. + * + * @param loaderPath - Absolute path to hooks-loader.ts. + * @param hookType - The Cursor hook event name. + * @returns The loader entry to install. + */ +function loaderEntry(loaderPath: string, hookType: HookType): HookEntry { + return { command: `bun ${loaderPath} ${hookType}` }; +} + +/** + * Conservatively merge the fusengine loader entry for one hook type: drop the + * previous loader entry (idempotent — no duplicate on re-run), keep every + * user-authored entry in place, then append the fresh loader entry. + * + * @param existing - Current entries for the hook type (may be undefined). + * @param loaderPath - Absolute path to hooks-loader.ts. + * @param hookType - The Cursor hook event name. + * @returns The merged entry list. + */ +export function mergeHookType( + existing: unknown[] | undefined, + loaderPath: string, + hookType: HookType, +): unknown[] { + const kept = (existing ?? []).filter((e) => !isLoaderEntry(e as HookEntry)); + return [...kept, loaderEntry(loaderPath, hookType)]; +} diff --git a/.cursor-plugin/scripts/src/services/load-project-plugins.ts b/.cursor-plugin/scripts/src/services/load-project-plugins.ts new file mode 100644 index 0000000..16f2bc4 --- /dev/null +++ b/.cursor-plugin/scripts/src/services/load-project-plugins.ts @@ -0,0 +1,25 @@ +/** + * Project-local plugin loader + * @description SRP: validate the loader's own CLI arguments and resolve the stdout payload + * for the Cursor `workspaceOpen` hook loader. Ported 1:1 from load-project-plugins.mjs — + * the printed JSON shape (`{"pluginPaths":[...]}`) is a stdout contract consumed by a Cursor + * hook, preserve exactly. + */ +import type { ProjectLoaderResponse } from "../interfaces/marketplace"; +import { assertProjectManagedRoot, resolvePluginPaths } from "./plugin-inventory"; + +const OWNER_FLAG = /^--fusengine-owner=[a-f0-9]{16}$/; + +/** Validate the loader's own argv (only an optional `--fusengine-owner=` flag). */ +export function validateLoaderArguments(loaderArguments: string[]): void { + const first = loaderArguments[0]; + if (loaderArguments.length > 1 || (first && !OWNER_FLAG.test(first))) { + throw new Error("unsupported loader argument"); + } +} + +/** Assert the managed root and resolve the stdout payload printed by the loader. */ +export function loadProjectPlugins(managedRoot: string, projectRoot: string): ProjectLoaderResponse { + assertProjectManagedRoot(managedRoot, projectRoot); + return { pluginPaths: resolvePluginPaths(managedRoot) }; +} diff --git a/.cursor-plugin/scripts/src/services/marketplace-sources.ts b/.cursor-plugin/scripts/src/services/marketplace-sources.ts new file mode 100644 index 0000000..6a4504d --- /dev/null +++ b/.cursor-plugin/scripts/src/services/marketplace-sources.ts @@ -0,0 +1,66 @@ +/** + * Marketplace plugin source resolution + * @description SRP: validate and resolve plugin entries declared in the repo's marketplace.json. + * Ported 1:1 from marketplace-sources.mjs — this whole module IS the Cursor sparse-checkout fix + * (commit c44bb82): plugins live as real per-plugin directories directly under + * `.cursor-plugin/plugins/`, and a source is only accepted once resolved to a physical, + * non-symlink directory whose parent is that exact plugin root. + */ +import fs from "node:fs"; +import path from "node:path"; +import type { + MarketplaceEntryInput, + MarketplaceManifest, + MarketplacePluginIdentity, + PluginManifest, + ResolvedMarketplacePlugin, +} from "../interfaces/marketplace"; + +const PLUGIN_ROOT = ".cursor-plugin/plugins"; +const SAFE_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]*$/; + +/** Validate one marketplace entry and return its flat identity and nested source. */ +export function validateMarketplaceEntry(entry: unknown): MarketplacePluginIdentity { + const record = entry as MarketplaceEntryInput | null | undefined; + const name = record?.name; + if (typeof name !== "string" || !SAFE_NAME.test(name) || path.basename(name) !== name) { + throw new Error(`unsafe marketplace plugin name: ${String(name)}`); + } + const expectedSource = `${PLUGIN_ROOT}/${name}`; + const source = record?.source; + if (typeof source !== "string" || source !== expectedSource) { + throw new Error(`unsafe marketplace plugin source: ${String(source)}`); + } + return { name, source: expectedSource }; +} + +/** Resolve declared plugin sources as real direct children of the marketplace plugin root. */ +export function resolveMarketplacePlugins( + repositoryRoot: string, + marketplace: MarketplaceManifest | null | undefined, +): ResolvedMarketplacePlugin[] { + const plugins = marketplace?.plugins; + if (!Array.isArray(plugins)) throw new Error("marketplace plugins must be an array"); + const root = path.resolve(repositoryRoot); + const physicalPluginRoot = fs.realpathSync(path.join(root, PLUGIN_ROOT)); + const seen = new Set(); + return plugins.map((entry: unknown) => { + const identity = validateMarketplaceEntry(entry); + if (seen.has(identity.name)) throw new Error(`duplicate marketplace plugin name: ${identity.name}`); + seen.add(identity.name); + const source = path.join(root, identity.source); + const sourceStat = fs.lstatSync(source); + if (sourceStat.isSymbolicLink() || !sourceStat.isDirectory()) { + throw new Error(`invalid plugin source: ${identity.source}`); + } + const physicalSource = fs.realpathSync(source); + if (path.dirname(physicalSource) !== physicalPluginRoot) { + throw new Error(`plugin source escapes plugin root: ${identity.source}`); + } + const manifest = JSON.parse( + fs.readFileSync(path.join(physicalSource, ".cursor-plugin", "plugin.json"), "utf8"), + ) as PluginManifest; + if (manifest.name !== identity.name) throw new Error(`plugin identity mismatch: ${identity.name}`); + return { name: identity.name, source: physicalSource }; + }); +} diff --git a/.cursor-plugin/scripts/src/services/mcp-config.ts b/.cursor-plugin/scripts/src/services/mcp-config.ts new file mode 100644 index 0000000..c2cc260 --- /dev/null +++ b/.cursor-plugin/scripts/src/services/mcp-config.ts @@ -0,0 +1,68 @@ +/** + * MCP Config Service + * Single Responsibility: read, merge and atomically write Cursor's mcp.json + */ +import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import type { McpDocument, McpMergeResult } from "../interfaces/mcp"; + +/** Absolute path of the user-scoped Cursor MCP configuration. */ +export function userMcpPath(): string { + const home = process.env.HOME || process.env.USERPROFILE || ""; + return join(home, ".cursor", "mcp.json"); +} + +/** + * Parse an existing mcp.json. A missing or malformed file yields an empty + * document rather than throwing: the installer never aborts on a file it does + * not own. + * @param file - Path to mcp.json. + * @returns The parsed document, or an empty one. + */ +export function readMcpDocument(file: string): McpDocument { + try { + return JSON.parse(readFileSync(file, "utf8")) as McpDocument; + } catch { + return {}; + } +} + +/** + * Add absent servers, preserving every server the user already declares. + * + * A name already present is never overwritten — it comes back in `kept`, which + * is what makes the installer safe to re-run. + * @param existing - Current document. + * @param entries - Server name to Cursor-shaped config. + * @returns Merged document, plus what was added and preserved. + */ +export function mergeMcpDocument( + existing: McpDocument, + entries: Map>, +): McpMergeResult { + const servers: Record = { ...(existing.mcpServers ?? {}) }; + const added: string[] = []; + const kept: string[] = []; + + for (const [name, config] of entries) { + if (Object.hasOwn(servers, name)) kept.push(name); + else { + servers[name] = config; + added.push(name); + } + } + return { doc: { ...existing, mcpServers: servers }, added, kept }; +} + +/** + * Write through a sibling temp file then rename, so an interrupted run never + * leaves a truncated mcp.json behind. + * @param file - Target path. + * @param doc - Document to serialise. + */ +export function writeMcpDocument(file: string, doc: McpDocument): void { + const temp = `${file}.fusengine-${process.pid}`; + mkdirSync(dirname(file), { recursive: true }); + writeFileSync(temp, `${JSON.stringify(doc, null, 2)}\n`); + renameSync(temp, file); +} diff --git a/.cursor-plugin/scripts/src/services/mcp-defaults.ts b/.cursor-plugin/scripts/src/services/mcp-defaults.ts new file mode 100644 index 0000000..0be377c --- /dev/null +++ b/.cursor-plugin/scripts/src/services/mcp-defaults.ts @@ -0,0 +1,54 @@ +/** + * MCP Default selections and UI options + * Single Responsibility: Build selection options for installer UI + */ +import type { McpCatalog, McpSelectOption } from "../interfaces/mcp"; +import { loadEnvFile } from "./env-file"; + +/** Check if API key is available in environment or .env file */ +export function hasApiKey(envVar: string): boolean { + if (process.env[envVar]) return true; + const envFile = loadEnvFile(); + return !!envFile[envVar]; +} + +/** Build selection options grouped by API key requirement */ +export function buildMcpOptions(catalog: McpCatalog): McpSelectOption[] { + const entries = Object.entries(catalog.mcpServers).filter( + ([k]) => !k.startsWith("_"), + ); + const noKey = entries.filter(([, v]) => !v.requiresApiKey); + const withKey = entries.filter(([, v]) => v.requiresApiKey); + const options: McpSelectOption[] = []; + + for (const [name, config] of noKey) { + options.push({ + value: name, + label: name, + hint: config._description, + }); + } + + for (const [name, config] of withKey) { + const keyStatus = hasApiKey(config.apiKeyEnv ?? "") ? "✓" : "⚠ key missing"; + options.push({ + value: name, + label: `${name} [${keyStatus}]`, + hint: config._description, + }); + } + + return options; +} + +/** Get default selections (explicit default > no-key > configured key) */ +export function getDefaultSelections(catalog: McpCatalog): string[] { + return Object.entries(catalog.mcpServers) + .filter(([k]) => !k.startsWith("_")) + .filter(([, v]) => { + if (v.default === true) return true; + if (v.default === false) return false; + return !v.requiresApiKey || (v.apiKeyEnv && hasApiKey(v.apiKeyEnv)); + }) + .map(([name]) => name); +} diff --git a/.cursor-plugin/scripts/src/services/mcp-installer.ts b/.cursor-plugin/scripts/src/services/mcp-installer.ts new file mode 100644 index 0000000..7470a8c --- /dev/null +++ b/.cursor-plugin/scripts/src/services/mcp-installer.ts @@ -0,0 +1,88 @@ +/** + * MCP Installer Service + * Single Responsibility: turn catalog entries into Cursor mcp.json servers + */ +import { join } from "node:path"; +import type { McpCatalog, McpServerConfig } from "../interfaces/mcp"; +import { mergeMcpDocument, readMcpDocument, userMcpPath, writeMcpDocument } from "./mcp-config"; + +const MCP_JSON_PATH = join(import.meta.dir, "../../mcp/mcp.json"); + +/** Load MCP catalog from mcp.json */ +export async function loadMcpCatalog(): Promise { + const file = Bun.file(MCP_JSON_PATH); + return await file.json(); +} + +/** Replace environment variables in string */ +function expandEnvVars(value: string): string { + const home = process.env.HOME || process.env.USERPROFILE || ""; + return value.replace(/\$\{HOME\}/g, home).replace(/\$HOME/g, home); +} + +/** Recursively expand env vars in config */ +function expandConfigVars(obj: unknown): unknown { + if (typeof obj === "string") return expandEnvVars(obj); + if (Array.isArray(obj)) return obj.map(expandConfigVars); + if (obj && typeof obj === "object") { + const result: Record = {}; + for (const [k, v] of Object.entries(obj)) result[k] = expandConfigVars(v); + return result; + } + return obj; +} + +/** + * Build a Cursor mcp.json entry: drop catalog metadata, expand `$HOME`, and + * reference the API key as `${env:NAME}` so the secret never lands in the file + * — Cursor interpolates it when it launches the server. + * @param config - Catalog entry. + * @returns The server object to write under `mcpServers`. + */ +export function buildCursorConfig(config: McpServerConfig): Record { + const { _description, requiresApiKey, apiKeyEnv, apiKeyUrl, default: _d, ...rest } = config; + void _description; + void apiKeyUrl; + const server = expandConfigVars(rest) as Record; + if (requiresApiKey && apiKeyEnv) { + const env = (server.env ?? {}) as Record; + server.env = { ...env, [apiKeyEnv]: `\${env:${apiKeyEnv}}` }; + } + return server; +} + +/** + * Install the selected servers into Cursor's user-scoped mcp.json. + * + * Non-destructive: a server the user already declares is preserved and + * reported in `kept`, never overwritten. + * @param names - Selected catalog server names. + * @param catalog - Parsed catalog. + * @param mcpPath - Target mcp.json; defaults to `~/.cursor/mcp.json`. + * @returns Added, failed and preserved server names. + */ +export async function installMcpServers( + names: string[], + catalog: McpCatalog, + mcpPath: string = userMcpPath(), +): Promise<{ success: string[]; failed: string[]; kept: string[] }> { + const entries = new Map>(); + const failed: string[] = []; + for (const name of names) { + const config = catalog.mcpServers[name]; + if (!config) failed.push(name); + else entries.set(name, buildCursorConfig(config)); + } + + try { + const { doc, added, kept } = mergeMcpDocument(readMcpDocument(mcpPath), entries); + if (added.length > 0) writeMcpDocument(mcpPath, doc); + return { success: added, failed, kept }; + } catch (error) { + const reason = error instanceof Error ? error.message : String(error); + process.stderr.write(`warning: MCP configuration skipped (${reason})\n`); + return { success: [], failed: [...failed, ...entries.keys()], kept: [] }; + } +} + +export { buildMcpOptions, getDefaultSelections, hasApiKey } from "./mcp-defaults"; diff --git a/.cursor-plugin/scripts/src/services/mcp-key-prompt.ts b/.cursor-plugin/scripts/src/services/mcp-key-prompt.ts new file mode 100644 index 0000000..34ffe02 --- /dev/null +++ b/.cursor-plugin/scripts/src/services/mcp-key-prompt.ts @@ -0,0 +1,45 @@ +/** + * MCP API Key Prompt Service + * Single Responsibility: Prompt for missing API keys needed by selected MCP servers + */ +import * as p from "@clack/prompts"; +import type { McpCatalog } from "../interfaces/mcp"; +import { hasApiKey } from "./mcp-defaults"; +import { loadEnvFile, saveEnvFile } from "./env-file"; + +/** Prompt for missing API keys needed by selected MCP servers */ +export async function promptMissingKeys( + selected: string[], + catalog: McpCatalog, +): Promise { + const missing = selected + .map((name) => catalog.mcpServers[name]) + .filter((c) => c.requiresApiKey && c.apiKeyEnv) + .filter((c) => !hasApiKey(c.apiKeyEnv!)); + + if (missing.length === 0) return; + + p.note( + missing.map((c) => ` ${c.apiKeyEnv}`).join("\n"), + `${missing.length} API key(s) required`, + ); + + const env = loadEnvFile(); + let hasChanges = false; + + for (const config of missing) { + const value = await p.text({ + message: `${config.apiKeyEnv}`, + placeholder: config.apiKeyUrl ?? config._description, + }); + + if (p.isCancel(value)) break; + if (value?.trim()) { + env[config.apiKeyEnv!] = value.trim(); + process.env[config.apiKeyEnv!] = value.trim(); + hasChanges = true; + } + } + + if (hasChanges) saveEnvFile(env); +} diff --git a/.cursor-plugin/scripts/src/services/mcp-setup.ts b/.cursor-plugin/scripts/src/services/mcp-setup.ts new file mode 100644 index 0000000..e078d3b --- /dev/null +++ b/.cursor-plugin/scripts/src/services/mcp-setup.ts @@ -0,0 +1,71 @@ +/** + * MCP Setup Service + * Single Responsibility: Interactive MCP server configuration + */ +import * as p from "@clack/prompts"; +import { + buildMcpOptions, + getDefaultSelections, + installMcpServers, + loadMcpCatalog, +} from "./mcp-installer"; +import { promptMissingKeys } from "./mcp-key-prompt"; + +/** + * Interactive MCP server configuration. + * @returns the list of selected MCP server ids (empty if cancelled/skipped). + */ +export async function configureMcpServers(): Promise { + const installMcp = await p.confirm({ + message: "Install MCP servers to global scope?", + initialValue: true, + }); + + if (p.isCancel(installMcp) || !installMcp) { + p.log.info("Skipping MCP server installation"); + return []; + } + + const catalog = await loadMcpCatalog(); + const options = buildMcpOptions(catalog); + const defaults = getDefaultSelections(catalog); + + const selected = await p.multiselect({ + message: "Select MCP servers to install globally:", + options: options.map((opt) => ({ + value: opt.value, + label: opt.label, + hint: opt.hint, + })), + initialValues: defaults, + required: false, + }); + + if (p.isCancel(selected) || selected.length === 0) { + p.log.info("No MCP servers selected"); + return []; + } + + await promptMissingKeys(selected as string[], catalog); + + const s = p.spinner(); + s.start(`Installing ${selected.length} MCP servers...`); + + const { success, failed } = await installMcpServers( + selected as string[], + catalog, + ); + + if (failed.length > 0) { + s.stop(`Installed ${success.length}/${selected.length} MCP servers`); + p.log.warn(`Failed: ${failed.join(", ")}`); + } else { + s.stop(`${success.length} MCP servers installed globally`); + } + + if (success.length > 0) { + p.log.success(`Installed: ${success.join(", ")}`); + } + + return selected as string[]; +} diff --git a/.cursor-plugin/scripts/src/services/merge-utils.ts b/.cursor-plugin/scripts/src/services/merge-utils.ts new file mode 100644 index 0000000..1569519 --- /dev/null +++ b/.cursor-plugin/scripts/src/services/merge-utils.ts @@ -0,0 +1,54 @@ +/** + * JSON output merging utilities for hook results + * Single Responsibility: Merge multiple hook JSON outputs + */ + +/** Merge JSON outputs (additionalContext, hookSpecificOutput, systemMessage) */ +export function mergeJsonOutput(existing: string, newOutput: string): string { + try { + const newJson = JSON.parse(newOutput); + + if (!existing) return newOutput; + const existingJson = JSON.parse(existing); + + // Merge systemMessage fields (concatenate with newline separator) + if (newJson.systemMessage && existingJson.systemMessage) { + existingJson.systemMessage += `\n${newJson.systemMessage}`; + } else if (newJson.systemMessage) { + existingJson.systemMessage = newJson.systemMessage; + } + + // If ANY hook denies, deny wins (permissionDecision: "deny" takes priority) + if (newJson.hookSpecificOutput?.permissionDecision === "deny") { + return newOutput; + } + if (existingJson.hookSpecificOutput?.permissionDecision === "deny") { + return existing; + } + + // Merge hookSpecificOutput.additionalContext across multiple hooks + if (newJson.hookSpecificOutput && existingJson.hookSpecificOutput) { + const existCtx = existingJson.hookSpecificOutput.additionalContext ?? ""; + const newCtx = newJson.hookSpecificOutput.additionalContext ?? ""; + if (newCtx) { + existingJson.hookSpecificOutput.additionalContext = existCtx + ? `${existCtx}\n\n${newCtx}` + : newCtx; + } + return JSON.stringify(existingJson); + } + + // First hookSpecificOutput wins structure, or plain additionalContext merge + if (newJson.hookSpecificOutput) return newOutput; + if (newJson.additionalContext && existingJson.additionalContext) { + existingJson.additionalContext += `\n\n${newJson.additionalContext}`; + return JSON.stringify(existingJson); + } + + return newJson.hookSpecificOutput || newJson.additionalContext + ? newOutput + : existing; + } catch { + return existing; + } +} diff --git a/.cursor-plugin/scripts/src/services/perf-env.ts b/.cursor-plugin/scripts/src/services/perf-env.ts new file mode 100644 index 0000000..364e065 --- /dev/null +++ b/.cursor-plugin/scripts/src/services/perf-env.ts @@ -0,0 +1,171 @@ +/** + * Legacy perf env-var bookkeeping service (Claude Code carry-over). + * + * @description Single Responsibility: persist a handful of `CLAUDE_CODE_*` / + * `DISABLE_AUTOUPDATER` flags into `settings.env`, a field of THIS installer's + * own private store (`~/.cursor/.fusengine-global/settings.json`, see + * `settings-manager.ts`). That store is bookkeeping only: Cursor's CLI's + * primary runtime config lives in `~/.cursor/cli-config.json` + * (https://cursor.com/docs/cli/reference/configuration; the only other CLI + * config surface, project-level `.cursor/cli.json`, carries permissions + * only, and `CURSOR_CONFIG_DIR` / `XDG_CONFIG_HOME` merely relocate the + * file) — none of those has an `env` block or any of these keys. Cursor's + * IDE does have its own VS Code-inherited `settings.json` + * (`~/Library/Application Support/Cursor/User/settings.json` on macOS), but + * it holds editor preferences, not agent/hook config, so none of these keys + * map there either. So every option below is currently INERT for + * Cursor — writing it only records user intent in our own file, it does not + * change any Cursor behavior. Verified against the CLI config schema and + * https://cursor.com/docs/subagents (2026-09-01); kept per explicit "no + * function removal" mandate rather than deleted. + * + * One option (`CLAUDE_CODE_ATTRIBUTION_HEADER`) DOES have a real Cursor + * equivalent (`attribution.attributeCommitsToAgent` / + * `attribution.attributePRsToAgent`, booleans in `cli-config.json`, default + * `true`) — see its `hint` below. Wiring it requires a `cli-config.json` + * reader/writer, which is a new file outside this service's exclusive scope; + * flagged as a follow-up rather than silently left unmentioned. + */ +import * as p from "@clack/prompts"; +import type { Settings } from "./settings-manager"; + +/** + * Legacy perf env vars, kept for backward-compat bookkeeping only. + * `defaultOn` entries are recorded at install time. None of these currently + * change Cursor's runtime behavior — see the module JSDoc above. + */ +export const PERF_ENV_OPTIONS = [ + { + value: "CLAUDE_CODE_ENABLE_TODO_TOOLS", + label: "Task tools (TaskCreate/TaskGet/TaskList/TaskUpdate)", + hint: "Inert on Cursor: no cli-config.json/env-var gate exists for this. Cursor's Task/subagent tooling (parallel + background subagents) runs by default — see cursor.com/docs/subagents. Kept for bookkeeping only.", + envValue: "1", + defaultOn: true, + }, + { + value: "CLAUDE_CODE_FORK_SUBAGENT", + label: "Fork subagent prompt cache", + hint: "Inert on Cursor: no documented cli-config.json/env-var control for subagent prompt-cache inheritance. Kept for bookkeeping only.", + envValue: "1", + defaultOn: false, + }, + { + value: "CLAUDE_CODE_ATTRIBUTION_HEADER", + label: "Strip attribution header", + hint: "Inert here (settings.env is never read by Cursor). Real Cursor equivalent: attribution.attributeCommitsToAgent / attribution.attributePRsToAgent (booleans, default true) in ~/.cursor/cli-config.json — not yet wired, needs a dedicated cli-config.json writer.", + envValue: "0", + defaultOn: false, + }, + { + value: "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC", + label: "Disable all non-essential traffic", + hint: "Inert on Cursor: no documented cli-config.json/env-var equivalent to bulk-disable telemetry/autoupdate/feedback. Kept for bookkeeping only.", + envValue: "1", + defaultOn: false, + }, + { + value: "DISABLE_AUTOUPDATER", + label: "Disable autoupdater only", + hint: "Inert here: Cursor's own switch is the `--disable-auto-update` CLI launch flag, not an env var or cli-config.json field, so this stored value has no effect. Kept for bookkeeping only.", + envValue: "1", + defaultOn: false, + }, +] as const; + +/** Env vars enabled out of the box; the prompt lets the user opt OUT of them. */ +export const DEFAULT_ON_PERF_ENV: readonly string[] = PERF_ENV_OPTIONS.filter( + (o) => o.defaultOn, +).map((o) => o.value); + +const PERF_ASKED_MARKER = "_FUSENGINE_PERF_ASKED"; +const PERF_DEFAULTS_MARKER = "_FUSENGINE_PERF_DEFAULTS"; + +/** + * Turn ON every `defaultOn` option, once per install. + * + * Runs BEFORE the "already asked" short-circuit so existing installs (which + * already carry PERF_ASKED_MARKER) still receive newly-added defaults. The + * dedicated marker makes it one-shot: a user who later unticks the option in + * the prompt keeps it off instead of having it re-imposed on every re-run. + * + * @param settings - Settings object mutated in place + * @returns The same settings, with defaults applied and the marker set + */ +export function applyDefaultPerfEnv(settings: Settings): Settings { + const env = (settings.env as Record) || {}; + if (env[PERF_DEFAULTS_MARKER] === "1") return settings; + for (const opt of PERF_ENV_OPTIONS) { + if (opt.defaultOn && env[opt.value] === undefined) { + env[opt.value] = opt.envValue; + } + } + env[PERF_DEFAULTS_MARKER] = "1"; + settings.env = env; + return settings; +} + +/** Read currently-enabled perf env vars from settings */ +export function getEnabledPerfEnv(settings: Settings): string[] { + const env = (settings.env as Record) || {}; + return PERF_ENV_OPTIONS.filter((o) => env[o.value] === o.envValue).map( + (o) => o.value, + ); +} + +/** True if the perf env prompt has already been answered */ +export function isPerfEnvAsked(settings: Settings): boolean { + const env = settings.env as Record | undefined; + return env?.[PERF_ASKED_MARKER] === "1"; +} + +/** Apply selected perf env vars; remove unselected ones; mark as asked */ +export function configurePerfEnv( + settings: Settings, + selectedKeys: readonly string[], +): Settings { + const env = (settings.env as Record) || {}; + for (const opt of PERF_ENV_OPTIONS) { + if (selectedKeys.includes(opt.value)) env[opt.value] = opt.envValue; + else delete env[opt.value]; + } + env[PERF_ASKED_MARKER] = "1"; + settings.env = env; + return settings; +} + +/** + * Interactive prompt: ask user which legacy perf flags to record. + * + * @remarks The prompt copy is deliberately honest: none of these flags are + * read by Cursor today (see module JSDoc) — this only stores a preference. + */ +export async function promptPerfEnv(settings: Settings): Promise { + settings = applyDefaultPerfEnv(settings); + if (isPerfEnvAsked(settings)) { + p.log.info("Perf flags already recorded (skipping prompt)"); + return settings; + } + const wants = await p.confirm({ + message: + "Record legacy perf flags? (bookkeeping only - not read by Cursor today)", + initialValue: true, + }); + if (p.isCancel(wants) || !wants) return settings; + + const choices = await p.multiselect({ + message: "Select legacy perf flags to record (see hints for Cursor status):", + options: PERF_ENV_OPTIONS.map((o) => ({ + value: o.value, + label: o.label, + hint: o.hint, + })), + initialValues: getEnabledPerfEnv(settings), + required: false, + }); + if (p.isCancel(choices)) return settings; + + const keys = choices as string[]; + const updated = configurePerfEnv(settings, keys); + p.log.success(`Perf flags recorded (${keys.length} selected)`); + return updated; +} diff --git a/.cursor-plugin/scripts/src/services/plugin-inventory.ts b/.cursor-plugin/scripts/src/services/plugin-inventory.ts new file mode 100644 index 0000000..deac4a8 --- /dev/null +++ b/.cursor-plugin/scripts/src/services/plugin-inventory.ts @@ -0,0 +1,63 @@ +/** + * Project-local managed plugin inventory + * @description SRP: resolve the physical plugin roots declared by an installed project's + * marketplace.json, and assert the loader runs from its own physical managed root. + * Ported 1:1 from project-plugin-inventory.mjs. + */ +import fs from "node:fs"; +import path from "node:path"; +import type { MarketplaceManifest, PluginManifest } from "../interfaces/marketplace"; +import { validateMarketplaceEntry } from "./marketplace-sources"; + +type RealPathKind = "directory" | "file"; + +function requireRealPath(target: string, kind: RealPathKind): void { + const stat = fs.lstatSync(target); + const isExpectedType = kind === "directory" ? stat.isDirectory() : stat.isFile(); + if (stat.isSymbolicLink() || !isExpectedType) { + throw new Error(`${target} must be a real ${kind}, not a symlink`); + } +} + +/** Require the loader to be the physical managed root below the current project. */ +export function assertProjectManagedRoot(managedRoot: string, projectRoot: string): void { + const cursorRoot = path.resolve(projectRoot, ".cursor"); + const projectManagedRoot = path.join(cursorRoot, "fusengine"); + requireRealPath(cursorRoot, "directory"); + requireRealPath(projectManagedRoot, "directory"); + if (fs.realpathSync(projectManagedRoot) !== fs.realpathSync(managedRoot)) { + throw new Error("loader must run from its physical project-local managed root"); + } +} + +/** Resolve exactly the safe physical plugin roots declared by an installed marketplace. */ +export function resolvePluginPaths(managedRoot: string): string[] { + const pluginsRoot = path.join(managedRoot, "plugins"); + const marketplacePath = path.join(managedRoot, "marketplace.json"); + requireRealPath(managedRoot, "directory"); + requireRealPath(pluginsRoot, "directory"); + requireRealPath(marketplacePath, "file"); + const pluginsRootPhysical = fs.realpathSync(pluginsRoot); + const marketplace = JSON.parse(fs.readFileSync(marketplacePath, "utf8")) as MarketplaceManifest; + if (!Array.isArray(marketplace.plugins)) throw new Error("installed marketplace plugins must be an array"); + + const seen = new Set(); + return marketplace.plugins.map((entry: unknown) => { + const identity = validateMarketplaceEntry(entry); + if (seen.has(identity.name)) throw new Error(`duplicate marketplace plugin name: ${identity.name}`); + seen.add(identity.name); + const pluginPath = path.join(pluginsRoot, identity.name); + requireRealPath(pluginPath, "directory"); + const pluginPhysical = fs.realpathSync(pluginPath); + if (path.dirname(pluginPhysical) !== pluginsRootPhysical) { + throw new Error(`plugin root resolves outside managed plugins: ${identity.name}`); + } + const metadataPath = path.join(pluginPath, ".cursor-plugin"); + requireRealPath(metadataPath, "directory"); + const manifestPath = path.join(metadataPath, "plugin.json"); + requireRealPath(manifestPath, "file"); + const plugin = JSON.parse(fs.readFileSync(manifestPath, "utf8")) as PluginManifest; + if (plugin.name !== identity.name) throw new Error(`plugin identity mismatch: ${identity.name}`); + return pluginPhysical; + }); +} diff --git a/.cursor-plugin/scripts/src/services/plugin-scanner.ts b/.cursor-plugin/scripts/src/services/plugin-scanner.ts new file mode 100644 index 0000000..0a168c8 --- /dev/null +++ b/.cursor-plugin/scripts/src/services/plugin-scanner.ts @@ -0,0 +1,96 @@ +/** + * Plugin scanning service + * @description SRP: Scan and load plugin configurations + */ +import { existsSync, readdirSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import type { + ExecutableHook, + HookEntry, + PluginInfo, + ScannerConfig, +} from "../interfaces/hooks"; + +/** Absolute home dir, OS-aware — same fallback chain as harness-path-resolver.ts. */ +const HOME = process.env.HOME || process.env.USERPROFILE || ""; + +/** Scan plugins and return their configurations */ +export function scanPlugins(config: ScannerConfig): PluginInfo[] { + const { pluginsDir } = config; + if (!existsSync(pluginsDir)) return []; + + return readdirSync(pluginsDir).map((name) => { + const path = join(pluginsDir, name); + const hooksFile = join(path, "hooks/hooks.json"); + const hasHooks = existsSync(hooksFile); + let config: PluginInfo["config"]; + if (hasHooks) { + try { + config = JSON.parse(readFileSync(hooksFile, "utf8")); + } catch { + /* ignore parse errors */ + } + } + return { name, path, hasHooks, config }; + }); +} + +/** Extract executable hooks for a given type */ +export function extractHooks( + plugins: PluginInfo[], + hookType: string, + toolName: string, + notifType: string, + agentType = "", +): ExecutableHook[] { + const hooks: ExecutableHook[] = []; + + for (const plugin of plugins) { + if (!plugin.config) continue; + const entries: HookEntry[] = plugin.config.hooks?.[hookType] ?? []; + + for (const entry of entries) { + if (!matchesFilter(entry.matcher, hookType, toolName, notifType, agentType)) continue; + + for (const hook of entry.hooks) { + if (hook.type && hook.type !== "command") continue; + // $HOME/${HOME} runtime fallback: a marketplace re-checkout resets + // hooks.json to its git-tracked literal (resolveHomeInHooks only + // rewrites install-time), and hook-executor spawns argv directly + // with no shell to expand it — resolve here too, mirroring + // CURSOR_PLUGIN_ROOT/CLAUDE_PROJECT_DIR above. + const command = hook.command + .replace(/\$\{CURSOR_PLUGIN_ROOT\}/g, plugin.path) + .replace(/\$\{CLAUDE_PROJECT_DIR\}/g, process.cwd()) + .replace(/\$\{HOME\}/g, HOME) + .replace(/\$HOME/g, HOME); + hooks.push({ command, isAsync: command.startsWith("afplay"), pluginName: plugin.name, pluginPath: plugin.path }); + } + } + } + + return hooks; +} + +/** Check if a matcher matches the current context */ +function matchesFilter( + matcher: string | undefined, + hookType: string, + toolName: string, + notifType: string, + agentType = "", +): boolean { + if (!matcher) return true; + + const testValue = + hookType === "notification" + ? notifType + : hookType === "subagentStart" || hookType === "subagentStop" + ? agentType + : toolName; + try { + return new RegExp(matcher).test(testValue); + } catch { + return false; + } +} diff --git a/.cursor-plugin/scripts/src/services/project-files.ts b/.cursor-plugin/scripts/src/services/project-files.ts new file mode 100644 index 0000000..906fe34 --- /dev/null +++ b/.cursor-plugin/scripts/src/services/project-files.ts @@ -0,0 +1,78 @@ +/** + * Project installer file primitives. + * @description SRP: symlink guard, atomic write/restore, and the per-project + * install lock — no installer policy, just safe low-level file operations. + */ +import fs from "node:fs"; +import path from "node:path"; + +/** Reject existing or dangling symlinks before a write-capable operation. */ +export function assertNotSymlink(file: string): void { + let stat: fs.Stats; + try { + stat = fs.lstatSync(file); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return; + throw error; + } + if (stat.isSymbolicLink()) throw new Error(`refusing to write through symlink: ${file}`); +} + +/** Replace one file atomically on its current filesystem. */ +export function writeFileAtomic(file: string, content: string | Buffer): void { + fs.mkdirSync(path.dirname(file), { recursive: true }); + const temporary = `${file}.fusengine-${process.pid}-${process.hrtime.bigint()}`; + try { + fs.writeFileSync(temporary, content, { flag: "wx" }); + fs.renameSync(temporary, file); + } finally { + if (fs.existsSync(temporary)) fs.rmSync(temporary); + } +} + +/** Restore an earlier file snapshot atomically, or remove a newly created file. */ +export function restoreFile(file: string, content: Buffer | null): void { + if (content === null) { + if (fs.existsSync(file)) fs.rmSync(file); + return; + } + writeFileAtomic(file, content); +} + +/** + * Serialize writes to one project's managed Cursor installation. + * + * SYNCHRONOUS by design: the lock is released in a `finally` wrapped directly + * around `action()`, not around an awaited settlement. Passing an async + * `action` (or making this function itself `async`) would release the lock + * before the action's real work finishes — preserve this contract exactly. + * @param cursorRoot - The project's `.cursor` directory; the lock lives here. + * @param action - Synchronous work to run while holding the lock. + * @returns Whatever `action` returns. + */ +export function withProjectLock(cursorRoot: string, action: () => T): T { + fs.mkdirSync(cursorRoot, { recursive: true }); + const lock = path.join(cursorRoot, ".fusengine-install.lock"); + try { + fs.mkdirSync(lock); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "EEXIST") { + throw new Error(`another Fusengine install is active: ${lock}`); + } + throw error; + } + try { + if (process.env.NODE_ENV === "test") { + const holdMs = Number.parseInt(process.env.FUSE_INSTALL_TEST_HOLD_LOCK_MS ?? "0", 10); + if (holdMs > 0) Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, holdMs); + } + return action(); + } finally { + fs.rmSync(lock, { recursive: true }); + } +} + +/** Render a caught value as a message — `catch` bindings are `unknown` under strict mode. */ +export function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/.cursor-plugin/scripts/src/services/project-hooks.ts b/.cursor-plugin/scripts/src/services/project-hooks.ts new file mode 100644 index 0000000..ddab08f --- /dev/null +++ b/.cursor-plugin/scripts/src/services/project-hooks.ts @@ -0,0 +1,87 @@ +/** + * Project-local Cursor loader hook merge/removal. + * @description SRP: own the `hooks.json` `workspaceOpen` entry that points at + * the project's loader — merge it in non-destructively, remove only the + * exact owned entry. + */ +import type { + HookDocument, + HookEntry, + HookMergeResult, + InstallReceipt, + RawHookDocument, +} from "../interfaces/project-install"; + +/** Project-relative Cursor loader command prefix owned by this installer. */ +export const baseCommand = "node .cursor/fusengine/load-plugins.mjs"; + +/** Build the uniquely owned loader command persisted in the install receipt. */ +export function loaderCommand(token: string): string { + return `${baseCommand} --fusengine-owner=${token}`; +} + +function isMatching(entry: unknown): entry is HookEntry { + if (typeof entry !== "object" || entry === null || Array.isArray(entry)) return false; + const command = (entry as { command?: unknown }).command; + return ( + command === baseCommand || + (typeof command === "string" && + new RegExp(`^${baseCommand.replaceAll(".", "\\.")} --fusengine-owner=[a-f0-9]{16}$`).test(command)) + ); +} + +function isExact(entry: unknown, command: string): entry is HookEntry { + return isMatching(entry) && entry.command === command && Object.keys(entry).length === 1; +} + +/** Merge a loader entry only when no matching foreign entry already supplies discovery. */ +export function mergeLoaderHook( + document: HookDocument, + previousReceipt: InstallReceipt, + candidateToken: string, +): HookMergeResult { + const entries = document.hooks.workspaceOpen; + if (previousReceipt.hookAdded === true && /^[a-f0-9]{16}$/.test(previousReceipt.hookToken ?? "")) { + const command = loaderCommand(previousReceipt.hookToken as string); + let hookIndex = previousReceipt.hookIndex; + if (!Number.isInteger(hookIndex) || !isExact(entries[hookIndex as number], command)) { + const indexes = entries.flatMap((entry, index) => (isExact(entry, command) ? [index] : [])); + hookIndex = indexes.length === 1 ? indexes[0] : -1; + } + if ((hookIndex as number) < 0) { + hookIndex = entries.length; + entries.push({ command }); + } + return { + document, + hookAdded: true, + hookCommand: command, + hookIndex: hookIndex as number, + hookToken: previousReceipt.hookToken as string, + }; + } + if (entries.some(isMatching)) { + return { document, hookAdded: false, hookCommand: baseCommand, hookIndex: -1, hookToken: null }; + } + const command = loaderCommand(candidateToken); + const hookIndex = entries.length; + entries.push({ command }); + return { document, hookAdded: true, hookCommand: command, hookIndex, hookToken: candidateToken }; +} + +/** Remove at most one exact canonical entry, and only when the receipt says we added it. */ +export function removeLoaderHook(document: RawHookDocument, receipt: InstallReceipt): boolean { + if (receipt.hookAdded !== true || !/^[a-f0-9]{16}$/.test(receipt.hookToken ?? "")) return false; + const entries = document.hooks?.workspaceOpen; + if (!Array.isArray(entries)) return false; + const command = loaderCommand(receipt.hookToken as string); + let index = receipt.hookIndex; + if (!Number.isInteger(index) || !isExact(entries[index as number], command)) { + const indexes = entries.flatMap((entry, entryIndex) => (isExact(entry, command) ? [entryIndex] : [])); + if (indexes.length !== 1) throw new Error("owned loader hook occurrence is missing or ambiguous"); + [index] = indexes; + } + entries.splice(index as number, 1); + if (entries.length === 0 && document.hooks) delete document.hooks.workspaceOpen; + return true; +} diff --git a/.cursor-plugin/scripts/src/services/project-install.ts b/.cursor-plugin/scripts/src/services/project-install.ts new file mode 100755 index 0000000..295aa56 --- /dev/null +++ b/.cursor-plugin/scripts/src/services/project-install.ts @@ -0,0 +1,167 @@ +#!/usr/bin/env bun +/** + * Project-local Cursor installer entry point. + * @description SRP: parse CLI options, then dispatch to install/uninstall + * under the project lock. All writes stay below the explicit `--project` root. + */ +import crypto from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import type { HookDocument, InstallOptions, InstallReceipt, MarketplaceManifest, RawHookDocument } from "../interfaces/project-install"; +import { assertNotSymlink, errorMessage, restoreFile, withProjectLock, writeFileAtomic } from "./project-files"; +import { mergeLoaderHook } from "./project-hooks"; +import { recoverManagedRoot, replaceManagedRoot } from "./project-transaction"; +import { uninstallProject } from "./project-uninstall"; +import { resolveMarketplacePlugins } from "./marketplace-sources"; + +const sourceRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../../.."); +const markerName = ".managed-by-fusengine"; +const args = process.argv.slice(2); +const options: InstallOptions = { dryRun: false, uninstall: false, project: "" }; + +function fail(message: string): never { + process.stderr.write(`error: ${message}\n`); + process.exit(1); +} + +function usage(): never { + process.stdout.write("Usage: install.sh --project [--dry-run] [--uninstall]\n"); + process.exit(0); +} + +for (let index = 0; index < args.length; index += 1) { + const arg = args[index]; + if (arg === "--project") options.project = args[++index] ?? ""; + else if (arg === "--dry-run") options.dryRun = true; + else if (arg === "--uninstall") options.uninstall = true; + else if (arg === "--help" || arg === "-h") usage(); + else fail(`unknown option: ${arg}`); +} +if (!options.project) fail("--project is required; no global scope is implied"); + +const requestedProject = path.resolve(options.project); +if (!fs.existsSync(requestedProject) || !fs.statSync(requestedProject).isDirectory()) { + fail(`project directory does not exist: ${requestedProject}`); +} +const projectRoot = fs.realpathSync(requestedProject); +const cursorRoot = path.join(projectRoot, ".cursor"); +const managedRoot = path.join(cursorRoot, "fusengine"); +const markerPath = path.join(managedRoot, markerName); +const hooksPath = path.join(cursorRoot, "hooks.json"); +const rulePath = path.join(cursorRoot, "rules", "fusengine.mdc"); +const receiptPath = path.join(managedRoot, "install-receipt.json"); + +function readJson(file: string): T { + try { + return JSON.parse(fs.readFileSync(file, "utf8")) as T; + } catch (error) { + fail(`${file} is not valid JSON: ${errorMessage(error)}`); + } +} + +function writeJson(file: string, value: unknown): void { + writeFileAtomic(file, `${JSON.stringify(value, null, 2)}\n`); +} + +function sha256(value: string | Buffer): string { + return crypto.createHash("sha256").update(value).digest("hex"); +} + +function hookDocument(): { document: HookDocument; existed: boolean } { + const existed = fs.existsSync(hooksPath); + const document: RawHookDocument = existed ? readJson(hooksPath) : { version: 1, hooks: {} }; + if (!document || Array.isArray(document) || typeof document !== "object") fail(`${hooksPath} must contain an object`); + if (document.version !== undefined && document.version !== 1) fail(`${hooksPath} must use Cursor hooks version 1`); + if (document.hooks !== undefined && (Array.isArray(document.hooks) || typeof document.hooks !== "object")) { + fail(`${hooksPath}.hooks must be an object`); + } + document.version = 1; + document.hooks ??= {}; + document.hooks.workspaceOpen ??= []; + if (!Array.isArray(document.hooks.workspaceOpen)) fail(`${hooksPath}.hooks.workspaceOpen must be an array`); + return { document: document as HookDocument, existed }; +} + +function buildRule(): string { + const defaultSource = path.join(sourceRoot, "AGENTS.md"); + const ruleSource = + process.env.NODE_ENV === "test" && process.env.FUSE_INSTALL_TEST_RULE_SOURCE + ? process.env.FUSE_INSTALL_TEST_RULE_SOURCE + : defaultSource; + const agents = fs.readFileSync(ruleSource, "utf8").replace(/\n\n/, "\n"); + return `---\ndescription: Fusengine project engineering rules.\nalwaysApply: true\n---\n\n${agents}`; +} + +function install(): void { + assertNotSymlink(cursorRoot); + assertNotSymlink(managedRoot); + assertNotSymlink(hooksPath); + assertNotSymlink(path.dirname(rulePath)); + assertNotSymlink(rulePath); + recoverManagedRoot(cursorRoot, managedRoot); + assertNotSymlink(managedRoot); + const market = readJson(path.join(sourceRoot, ".cursor-plugin", "marketplace.json")); + if (!Array.isArray(market.plugins) || market.plugins.length === 0) fail("marketplace has no plugins"); + const plugins = resolveMarketplacePlugins(sourceRoot, market); + const previousReceipt = fs.existsSync(receiptPath) ? readJson(receiptPath) : {}; + const originalHooks = fs.existsSync(hooksPath) ? fs.readFileSync(hooksPath) : null; + const originalRule = fs.existsSync(rulePath) ? fs.readFileSync(rulePath) : null; + const ruleExisted = + typeof previousReceipt.ruleFileExisted === "boolean" ? previousReceipt.ruleFileExisted : fs.existsSync(rulePath); + const { document, existed } = hookDocument(); + const candidateToken = crypto.randomBytes(8).toString("hex"); + const hookPlan = mergeLoaderHook(document, previousReceipt, candidateToken); + const { document: clean, hookAdded, hookCommand, hookIndex, hookToken } = hookPlan; + const rule = buildRule(); + if (options.dryRun) { + process.stdout.write(`Would install ${market.plugins.length} plugins under ${managedRoot}\n`); + process.stdout.write(`Would merge ${hookCommand} into ${hooksPath}\nWould write ${rulePath}\nDry run: nothing written.\n`); + return; + } + if (fs.existsSync(managedRoot) && !fs.existsSync(markerPath)) fail(`refusing to replace unowned directory: ${managedRoot}`); + const currentRule = fs.existsSync(rulePath) ? fs.readFileSync(rulePath) : null; + const ownedRuleIntact = + currentRule !== null && previousReceipt.ruleFileExisted === false && sha256(currentRule) === previousReceipt.ruleSha256; + if (currentRule !== null && currentRule.toString("utf8") !== rule && !ownedRuleIntact) { + fail(`refusing to overwrite existing rule: ${rulePath}`); + } + const receipt: InstallReceipt = { + hooksFileExisted: typeof previousReceipt.hooksFileExisted === "boolean" ? previousReceipt.hooksFileExisted : existed, + ruleFileExisted: ruleExisted, + hookAdded, + hookIndex, + hookToken, + ruleSha256: sha256(rule), + }; + const finalize = () => { + writeFileAtomic(rulePath, rule); + writeJson(hooksPath, clean); + }; + const rollbackFinalize = () => { + restoreFile(rulePath, originalRule); + restoreFile(hooksPath, originalHooks); + }; + replaceManagedRoot({ cursorRoot, managedRoot, sourceRoot, plugins, receipt, finalize, rollbackFinalize }); + process.stdout.write(`Installed ${market.plugins.length} project-local plugins in ${projectRoot}\nReload the Cursor window to activate them.\n`); +} + +if (options.dryRun && options.uninstall) { + process.stdout.write(`Would remove only the Fusengine hook, rule, and managed directory under ${projectRoot}\nDry run: nothing written.\n`); +} else if (options.uninstall) { + try { + assertNotSymlink(cursorRoot); + withProjectLock(cursorRoot, () => uninstallProject({ cursorRoot, hooksPath, managedRoot, receiptPath, rulePath })); + process.stdout.write(`Uninstalled project-local plugins from ${projectRoot}\n`); + } catch (error) { + fail(errorMessage(error)); + } +} else { + try { + assertNotSymlink(cursorRoot); + if (options.dryRun) install(); + else withProjectLock(cursorRoot, install); + } catch (error) { + fail(errorMessage(error)); + } +} diff --git a/.cursor-plugin/scripts/src/services/project-transaction.ts b/.cursor-plugin/scripts/src/services/project-transaction.ts new file mode 100644 index 0000000..dc0ea06 --- /dev/null +++ b/.cursor-plugin/scripts/src/services/project-transaction.ts @@ -0,0 +1,162 @@ +/** + * Project installer stage/swap transaction. + * @description SRP: build a validated staged managed root, then atomically + * swap it in with rollback around finalize (rule + hooks writes). + */ +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import type { ReplaceManagedRootInput, StageBuildInput } from "../interfaces/project-install"; +import type { ResolvedMarketplacePlugin } from "../interfaces/marketplace"; +import { errorMessage } from "./project-files"; +import { resolvePluginPaths } from "./plugin-inventory"; + +function validateStage(stageRoot: string, plugins: ResolvedMarketplacePlugin[]): void { + const loader = path.join(stageRoot, "load-plugins.mjs"); + const inventory = path.join(stageRoot, "project-plugin-inventory.mjs"); + for (const script of [loader, inventory]) { + // "node", not process.execPath: these staged artifacts are run by Cursor + // with `node`, and this installer now runs under bun — checking them with + // the current runtime would validate against the wrong parser. + const run = spawnSync("node", ["--check", script], { encoding: "utf8" }); + if (run.status !== 0) throw new Error(`staged loader validation failed: ${run.stderr.trim()}`); + } + const pluginPaths = resolvePluginPaths(stageRoot); + const expected = plugins.map((plugin) => fs.realpathSync(path.join(stageRoot, "plugins", plugin.name))); + if (JSON.stringify(pluginPaths) !== JSON.stringify(expected)) { + throw new Error("staged loader paths do not match the marketplace inventory"); + } +} + +function buildStage({ stageRoot, sourceRoot, plugins, receipt }: StageBuildInput): void { + fs.mkdirSync(path.join(stageRoot, "plugins"), { recursive: true }); + fs.writeFileSync(path.join(stageRoot, ".managed-by-fusengine"), "fusengine cursor project installation\n"); + const failAfter = + process.env.NODE_ENV === "test" + ? Number.parseInt(process.env.FUSE_INSTALL_TEST_FAIL_AFTER_PLUGIN ?? "0", 10) + : 0; + for (let index = 0; index < plugins.length; index += 1) { + const plugin = plugins[index]; + fs.cpSync(plugin.source, path.join(stageRoot, "plugins", plugin.name), { recursive: true }); + if (failAfter === index + 1) throw new Error(`injected copy failure after plugin ${plugin.name}`); + } + // Deployed runtime artifacts, not installer sources: Cursor's workspaceOpen + // hook runs them with `node .cursor/fusengine/load-plugins.mjs`, so they stay + // plain .mjs and live in plugin-runtime/ — the installer itself is TypeScript. + const runtimeRoot = path.join(sourceRoot, ".cursor-plugin", "plugin-runtime"); + fs.copyFileSync(path.join(runtimeRoot, "load-project-plugins.mjs"), path.join(stageRoot, "load-plugins.mjs")); + fs.copyFileSync( + path.join(runtimeRoot, "project-plugin-inventory.mjs"), + path.join(stageRoot, "project-plugin-inventory.mjs"), + ); + fs.copyFileSync(path.join(runtimeRoot, "marketplace-sources.mjs"), path.join(stageRoot, "marketplace-sources.mjs")); + fs.copyFileSync( + path.join(sourceRoot, ".cursor-plugin", "marketplace.json"), + path.join(stageRoot, "marketplace.json"), + ); + fs.writeFileSync(path.join(stageRoot, "install-receipt.json"), `${JSON.stringify(receipt, null, 2)}\n`); + validateStage(stageRoot, plugins); +} + +function isOwnedBackup(candidate: string): boolean { + try { + const stat = fs.lstatSync(candidate); + const marker = path.join(candidate, ".managed-by-fusengine"); + return ( + stat.isDirectory() && + !stat.isSymbolicLink() && + fs.lstatSync(marker).isFile() && + fs.readFileSync(marker, "utf8") === "fusengine cursor project installation\n" + ); + } catch { + return false; + } +} + +function ownedRoots(cursorRoot: string, pattern: RegExp): string[] { + if (!fs.existsSync(cursorRoot)) return []; + return fs + .readdirSync(cursorRoot) + .filter((name) => pattern.test(name)) + .map((name) => path.join(cursorRoot, name)) + .filter(isOwnedBackup); +} + +/** Restore a sole crash backup before removing stale owned recovery artifacts. */ +export function recoverManagedRoot(cursorRoot: string, managedRoot: string): void { + if (!fs.existsSync(cursorRoot)) return; + const backups = ownedRoots(cursorRoot, /^\.fusengine-backup-\d+-\d+$/); + if (!fs.existsSync(managedRoot)) { + if (backups.length > 1) throw new Error("multiple owned crash backups require manual recovery"); + if (backups.length === 1) fs.renameSync(backups[0], managedRoot); + } + if (fs.existsSync(managedRoot) && !isOwnedBackup(managedRoot)) { + throw new Error(`managed root has no valid ownership marker: ${managedRoot}`); + } + for (const backup of backups) { + if (fs.existsSync(backup)) fs.rmSync(backup, { recursive: true }); + } + for (const stage of ownedRoots(cursorRoot, /^\.fusengine-stage-\d+-\d+$/)) { + fs.rmSync(stage, { recursive: true }); + } +} + +function injectFailure(point: string): void { + if (process.env.NODE_ENV === "test" && process.env.FUSE_INSTALL_TEST_FAIL_POINT === point) { + throw new Error(`injected transaction failure at ${point}`); + } +} + +/** Build and validate a complete managed root, then replace it with rollback around finalization. */ +export function replaceManagedRoot({ + cursorRoot, + managedRoot, + sourceRoot, + plugins, + receipt, + finalize, + rollbackFinalize, +}: ReplaceManagedRootInput): void { + fs.mkdirSync(cursorRoot, { recursive: true }); + recoverManagedRoot(cursorRoot, managedRoot); + const nonce = `${process.pid}-${Date.now()}`; + const stageRoot = path.join(cursorRoot, `.fusengine-stage-${nonce}`); + const backupRoot = path.join(cursorRoot, `.fusengine-backup-${nonce}`); + let oldMoved = false; + let newMoved = false; + let finalizationStarted = false; + try { + buildStage({ stageRoot, sourceRoot, plugins, receipt }); + injectFailure("before-old-move"); + if (fs.existsSync(managedRoot)) { + fs.renameSync(managedRoot, backupRoot); + oldMoved = true; + } + injectFailure("after-old-move"); + fs.renameSync(stageRoot, managedRoot); + newMoved = true; + injectFailure("after-new-move"); + finalizationStarted = true; + finalize(); + injectFailure("after-finalize"); + } catch (error) { + if (finalizationStarted) rollbackFinalize(); + if (newMoved && fs.existsSync(managedRoot)) fs.rmSync(managedRoot, { recursive: true }); + if (oldMoved && fs.existsSync(backupRoot)) fs.renameSync(backupRoot, managedRoot); + throw error; + } finally { + if (fs.existsSync(stageRoot)) fs.rmSync(stageRoot, { recursive: true }); + } + if (oldMoved && fs.existsSync(backupRoot)) { + const retainForTest = + process.env.NODE_ENV === "test" && process.env.FUSE_INSTALL_TEST_FAIL_BACKUP_CLEANUP === "1"; + try { + if (retainForTest) throw new Error("injected backup cleanup failure"); + fs.rmSync(backupRoot, { recursive: true }); + } catch (error) { + process.stderr.write( + `warning: install committed; recovery backup retained at ${backupRoot}: ${errorMessage(error)}\n`, + ); + } + } +} diff --git a/.cursor-plugin/scripts/src/services/project-uninstall.ts b/.cursor-plugin/scripts/src/services/project-uninstall.ts new file mode 100644 index 0000000..ca98313 --- /dev/null +++ b/.cursor-plugin/scripts/src/services/project-uninstall.ts @@ -0,0 +1,118 @@ +/** + * Project installer removal. + * @description SRP: preflight a removal plan against the owned marker, then + * atomically apply it — restoring on any failure mid-way. + */ +import crypto from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; +import type { + FileAction, + InstallReceipt, + RawHookDocument, + UninstallPaths, + UninstallPlan, +} from "../interfaces/project-install"; +import { assertNotSymlink, errorMessage, restoreFile, writeFileAtomic } from "./project-files"; +import { removeLoaderHook } from "./project-hooks"; + +const markerContent = "fusengine cursor project installation\n"; + +function sha256(value: Buffer): string { + return crypto.createHash("sha256").update(value).digest("hex"); +} + +function readJson(file: string): T { + return JSON.parse(fs.readFileSync(file, "utf8")) as T; +} + +function requireType(file: string, type: "file" | "directory"): void { + const stat = fs.lstatSync(file); + if ((type === "file" && !stat.isFile()) || (type === "directory" && !stat.isDirectory())) { + throw new Error(`expected ${type}: ${file}`); + } +} + +function serialize(document: unknown): Buffer { + return Buffer.from(`${JSON.stringify(document, null, 2)}\n`); +} + +function buildPlan(paths: UninstallPaths): UninstallPlan { + const { cursorRoot, managedRoot, hooksPath, rulePath, receiptPath } = paths; + for (const target of [cursorRoot, managedRoot, hooksPath, path.dirname(rulePath), rulePath, receiptPath]) { + assertNotSymlink(target); + } + requireType(cursorRoot, "directory"); + requireType(managedRoot, "directory"); + const markerPath = path.join(managedRoot, ".managed-by-fusengine"); + assertNotSymlink(markerPath); + requireType(markerPath, "file"); + if (fs.readFileSync(markerPath, "utf8") !== markerContent) throw new Error(`invalid ownership marker: ${markerPath}`); + if (fs.existsSync(receiptPath)) requireType(receiptPath, "file"); + const receipt = fs.existsSync(receiptPath) ? readJson(receiptPath) : {}; + + let hooksAction: FileAction = { type: "none" }; + const hooksExist = fs.existsSync(hooksPath); + if (hooksExist) requireType(hooksPath, "file"); + const originalHooks = hooksExist ? fs.readFileSync(hooksPath) : null; + if (originalHooks !== null) { + const document = readJson(hooksPath); + if (removeLoaderHook(document, receipt)) { + const hookKeys = Object.keys(document.hooks ?? {}); + const onlyBaseKeys = Object.keys(document).every((key) => key === "version" || key === "hooks"); + hooksAction = + receipt.hooksFileExisted === false && hookKeys.length === 0 && onlyBaseKeys + ? { type: "remove" } + : { type: "write", content: serialize(document) }; + } + } + + let ruleAction: FileAction = { type: "none" }; + const ruleExists = fs.existsSync(rulePath); + if (ruleExists) requireType(rulePath, "file"); + const originalRule = ruleExists ? fs.readFileSync(rulePath) : null; + if (originalRule !== null) { + ruleAction = + sha256(originalRule) === receipt.ruleSha256 && receipt.ruleFileExisted !== true + ? { type: "remove" } + : { type: "preserve" }; + } + return { hooksAction, originalHooks, originalRule, receipt, ruleAction }; +} + +function applyFileAction(file: string, action: FileAction): void { + if (action.type === "write") writeFileAtomic(file, action.content); + else if (action.type === "remove") fs.rmSync(file); +} + +function injectAfterHook(): void { + if (process.env.NODE_ENV === "test" && process.env.FUSE_INSTALL_TEST_FAIL_UNINSTALL_AFTER_HOOK === "1") { + throw new Error("injected uninstall failure after hook mutation"); + } +} + +/** Preflight and atomically coordinate removal of one owned project installation. */ +export function uninstallProject(paths: UninstallPaths): void { + const plan = buildPlan(paths); + const tombstone = path.join(paths.cursorRoot, `.fusengine-uninstall-${process.pid}-${Date.now()}`); + try { + applyFileAction(paths.hooksPath, plan.hooksAction); + injectAfterHook(); + applyFileAction(paths.rulePath, plan.ruleAction); + fs.renameSync(paths.managedRoot, tombstone); + } catch (error) { + restoreFile(paths.rulePath, plan.originalRule); + restoreFile(paths.hooksPath, plan.originalHooks); + throw error; + } + if (plan.ruleAction.type === "preserve") { + process.stderr.write(`warning: preserved pre-existing or modified rule ${paths.rulePath}\n`); + } + try { + fs.rmSync(tombstone, { recursive: true }); + } catch (error) { + process.stderr.write( + `warning: uninstall committed; retained cleanup directory ${tombstone}: ${errorMessage(error)}\n`, + ); + } +} diff --git a/.cursor-plugin/scripts/src/services/project-verify.ts b/.cursor-plugin/scripts/src/services/project-verify.ts new file mode 100644 index 0000000..7219ee9 --- /dev/null +++ b/.cursor-plugin/scripts/src/services/project-verify.ts @@ -0,0 +1,71 @@ +/** + * Project installation verification (read-only) + * @description SRP: verify one installed project's Fusengine plugin wiring without changing + * it. Ported 1:1 from project-verify.mjs — this module and every check it performs are + * READ-ONLY, preserve that property, never write to the target project. + */ +import fs from "node:fs"; +import path from "node:path"; +import { spawnSync } from "node:child_process"; +import type { HooksFile, InstalledMarketplace, LoaderCliResponse } from "../interfaces/verify"; + +const WORKSPACE_OPEN_COMMAND = + /^node \.cursor\/fusengine\/load-plugins\.mjs(?: --fusengine-owner=[a-f0-9]{16})?$/; + +/** Verify one installed project's Fusengine wiring, resolved against its real path. */ +export function verifyProject(projectArgument: string): string[] { + const project = fs.realpathSync(path.resolve(projectArgument)); + const cursorRoot = path.join(project, ".cursor"); + const managedRoot = path.join(cursorRoot, "fusengine"); + const failures: string[] = []; + const pass = (message: string): void => { + process.stdout.write(`PASS ${message}\n`); + }; + const check = (condition: boolean, message: string): void => { + if (condition) pass(message); + else failures.push(message); + }; + + const hooksPath = path.join(cursorRoot, "hooks.json"); + const marketPath = path.join(managedRoot, "marketplace.json"); + check(fs.existsSync(path.join(managedRoot, ".managed-by-fusengine")), "managed installation marker exists"); + check(fs.existsSync(path.join(cursorRoot, "rules", "fusengine.mdc")), "project rule exists"); + check(fs.existsSync(hooksPath), "project hooks file exists"); + check(fs.existsSync(marketPath), "installed marketplace inventory exists"); + + if (fs.existsSync(hooksPath)) { + const hooks = JSON.parse(fs.readFileSync(hooksPath, "utf8")) as HooksFile; + const entries = + hooks.hooks?.workspaceOpen?.filter((hook) => + WORKSPACE_OPEN_COMMAND.test(String(hook?.command ?? "")), + ) ?? []; + check(hooks.version === 1, "hooks schema version is 1"); + check(entries.length === 1, "workspaceOpen loader is registered exactly once"); + } + + if (fs.existsSync(marketPath)) { + const market = JSON.parse(fs.readFileSync(marketPath, "utf8")) as InstalledMarketplace; + // "node", not process.execPath: this entry runs under bun, whereas the + // deployed hook command is `node .cursor/fusengine/load-plugins.mjs` + // (project-hooks.ts::baseCommand). process.execPath would re-launch the + // CURRENT runtime — verifying an interpreter production never uses. + const run = spawnSync("node", [".cursor/fusengine/load-plugins.mjs"], { + cwd: project, + encoding: "utf8", + }); + check(run.status === 0, "project loader exits successfully"); + if (run.status === 0) { + const response = JSON.parse(run.stdout) as LoaderCliResponse; + const paths = response.pluginPaths ?? []; + check(paths.length === market.plugins.length, "loader returns one path per marketplace entry"); + check(!paths.includes(managedRoot), "loader does not return the marketplace root"); + check(paths.every((pluginPath) => path.isAbsolute(pluginPath)), "all plugin paths are absolute"); + check( + paths.every((pluginPath) => fs.existsSync(path.join(pluginPath, ".cursor-plugin", "plugin.json"))), + "all paths resolve to individual plugin roots", + ); + } + } + + return failures; +} diff --git a/.cursor-plugin/scripts/src/services/settings-env-purge.ts b/.cursor-plugin/scripts/src/services/settings-env-purge.ts new file mode 100644 index 0000000..99b38cc --- /dev/null +++ b/.cursor-plugin/scripts/src/services/settings-env-purge.ts @@ -0,0 +1,24 @@ +/** + * Settings env purge service + * Single Responsibility: strip residual FUSE_-prefixed keys from settings.json's + * env block. FUSE_* config now lives in ~/.cursor/.env (see env-file.ts) — a + * leftover FUSE_* key in settings.json would otherwise take priority over .env + * and resurrect stale config. Native CLAUDE_CODE_* keys and the + * _FUSENGINE_PERF_ASKED marker are untouched (they stay in settings.json). + */ +import type { Settings } from "./settings-manager"; + +/** + * Remove every FUSE_-prefixed key from settings.env, in place. + * Idempotent: a settings.env already free of FUSE_* keys is left unchanged. + * @param settings - current settings object (mutated + returned) + */ +export function purgeFuseEnvVars(settings: Settings): Settings { + const env = settings.env as Record | undefined; + if (!env) return settings; + for (const key of Object.keys(env)) { + if (key.startsWith("FUSE_")) delete env[key]; + } + settings.env = env; + return settings; +} diff --git a/.cursor-plugin/scripts/src/services/settings-language.ts b/.cursor-plugin/scripts/src/services/settings-language.ts new file mode 100644 index 0000000..f8062e1 --- /dev/null +++ b/.cursor-plugin/scripts/src/services/settings-language.ts @@ -0,0 +1,54 @@ +/** + * Settings language service + * @description SRP: response-language options, the default-parameters writer, + * and the interactive language prompt. + */ +import * as p from "@clack/prompts"; +import type { Settings } from "./settings-manager"; + +/** Supported languages for Cursor responses */ +export const SUPPORTED_LANGUAGES = [ + { value: "english", label: "English" }, + { value: "french", label: "French" }, + { value: "german", label: "German" }, + { value: "spanish", label: "Spanish" }, + { value: "italian", label: "Italian" }, + { value: "portuguese", label: "Portuguese" }, + { value: "dutch", label: "Dutch" }, + { value: "japanese", label: "Japanese" }, + { value: "chinese", label: "Chinese" }, + { value: "korean", label: "Korean" }, +] as const; + +/** Default language when none selected */ +export const DEFAULT_LANGUAGE = "english"; + +/** + * Configure default parameters (response language + empty attribution). + * + * @param settings - Settings object to mutate. + * @param language - Preferred response language; falls back to the existing + * value then {@link DEFAULT_LANGUAGE}. + * @returns The same settings object with defaults applied. + */ +export function configureDefaults( + settings: Settings, + language?: string, +): Settings { + settings.language = language ?? settings.language ?? DEFAULT_LANGUAGE; + settings.attribution = { commit: "", pr: "" }; + return settings; +} + +/** Prompt user for response language */ +export async function promptLanguage(): Promise { + const choice = await p.select({ + message: "Select response language for Cursor:", + options: SUPPORTED_LANGUAGES.map((lang) => ({ + value: lang.value, + label: lang.label, + })), + initialValue: DEFAULT_LANGUAGE, + }); + return p.isCancel(choice) ? DEFAULT_LANGUAGE : (choice as string); +} diff --git a/.cursor-plugin/scripts/src/services/settings-manager.ts b/.cursor-plugin/scripts/src/services/settings-manager.ts new file mode 100644 index 0000000..e125878 --- /dev/null +++ b/.cursor-plugin/scripts/src/services/settings-manager.ts @@ -0,0 +1,163 @@ +/** + * Settings management service + * Single Responsibility: Read/write ~/.cursor/.fusengine-global/settings.json + */ +import { copyFileSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { HOOK_TYPES } from "../interfaces/hooks"; +import { mergeHookType } from "./hook-merge"; + +export { + SUPPORTED_LANGUAGES, + DEFAULT_LANGUAGE, + configureDefaults, + promptLanguage, +} from "./settings-language"; + +export interface Settings { + language?: string; + attribution?: { commit: string; pr: string }; + hooks?: Record; + statusLine?: { type: string; command: string; padding: number }; + [key: string]: unknown; +} + +/** Load existing settings */ +export async function loadSettings(path: string): Promise { + if (!existsSync(path)) return {}; + return await Bun.file(path).json(); +} + +/** Save settings */ +export async function saveSettings( + path: string, + settings: Settings, +): Promise { + mkdirSync(dirname(path), { recursive: true }); + await Bun.write(path, `${JSON.stringify(settings, null, 2)}\n`); +} + +/** Create a settings backup */ +export function backupSettings(path: string): void { + if (!existsSync(path)) return; + const timestamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19); + copyFileSync(path, `${path}.backup.${timestamp}`); +} + +/** Absolute path of Cursor's user-scoped hooks file. */ +export function userHooksPath(): string { + const home = process.env.HOME || process.env.USERPROFILE || ""; + return join(home, ".cursor", "hooks.json"); +} + +/** + * Configure fusengine hooks in Cursor's own `~/.cursor/hooks.json`. + * + * Cursor reads its hooks from that file (schema `{version:1, hooks:{...}}`) and + * never from a settings.json, so the loader is wired there. For each managed + * hook type the previous loader entry is replaced and any user-authored entry + * is preserved in place. Idempotent: re-running yields no duplicates. + * + * @param loaderPath - Absolute path to hooks-loader.ts. + * @param hooksPath - Target hooks.json; defaults to the user-scoped one. + * @returns The number of hook events wired. + */ +export function configureHooks( + loaderPath: string, + hooksPath: string = userHooksPath(), +): number { + let doc: { version?: number; hooks?: Record } = {}; + if (existsSync(hooksPath)) { + try { + doc = JSON.parse(readFileSync(hooksPath, "utf8")); + } catch { + doc = {}; + } + } + + const hooks = doc.hooks ?? {}; + for (const hookType of HOOK_TYPES) { + hooks[hookType] = mergeHookType(hooks[hookType], loaderPath, hookType); + } + + const next = { ...doc, version: 1, hooks }; + const temp = `${hooksPath}.fusengine-${process.pid}`; + mkdirSync(dirname(hooksPath), { recursive: true }); + writeFileSync(temp, `${JSON.stringify(next, null, 2)}\n`); + renameSync(temp, hooksPath); + return HOOK_TYPES.length; +} + +/** + * Record the "Agent Teams" preference — Claude Code carry-over, inert on Cursor. + * + * @remarks `CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS` is written into + * `settings.env`, a field of this installer's own private store + * (`~/.cursor/.fusengine-global/settings.json`). Cursor's CLI never reads + * that file — its primary runtime config is `~/.cursor/cli-config.json` + * (https://cursor.com/docs/cli/reference/configuration; the only other CLI + * config surface, project-level `.cursor/cli.json`, carries permissions + * only), and neither has an `env` block or an "Agent Teams" concept at all. + * Cursor's actual multi-agent + * feature is Subagents (parallel + background execution, on by default, no + * flag needed — https://cursor.com/docs/subagents), so there is nothing to + * "enable" here for Cursor. Kept per explicit "no function removal" mandate; + * this call only records a preference in our own bookkeeping file. The + * "Enable Agent Teams? (beta)" prompt copy that overclaims this lives in + * `setup-runner.ts`, outside this file's scope — flagged for a follow-up fix. + * + * @param settings - Settings object mutated in place. + * @returns The same settings, with the bookkeeping flag set. + */ +export function enableAgentTeams(settings: Settings): Settings { + const env = (settings.env as Record) || {}; + env.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS = "1"; + settings.env = env; + return settings; +} + +/** + * Check whether the (inert, see {@link enableAgentTeams}) Agent Teams + * bookkeeping flag was previously recorded. + * + * @param settings - Settings object to inspect. + * @returns `true` if the flag is set in `settings.env`. + */ +export function isAgentTeamsEnabled(settings: Settings): boolean { + const env = settings.env as Record | undefined; + return env?.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS === "1"; +} + +/** + * Record a command-based statusline — Claude Code carry-over, inert on Cursor. + * + * @remarks This writes the Claude Code `settings.json` statusLine schema + * (`{type: "command", command, padding}`, a script Claude Code spawns and + * reads stdout from) into this installer's own private store. Cursor has no + * such mechanism in the IDE, and its CLI statusline is controlled purely by + * booleans in `~/.cursor/cli-config.json` + * (`display.showStatusIndicators`, `display.showStatusLineRunningTime` — + * https://cursor.com/docs/cli/reference/configuration), not by spawning an + * external command. So the object written here has no effect on Cursor. + * Kept per explicit "no function removal" mandate and to avoid breaking the + * existing `configureStatusLine` test contract; wiring the real Cursor + * booleans needs a `cli-config.json` reader/writer, out of this file's + * exclusive scope — flagged for a follow-up fix. + * + * @param settings - Settings object mutated in place. + * @param statuslineDir - Directory holding the statusline script to record. + * @returns The same settings, with `statusLine` set if it was absent. + */ +export function configureStatusLine( + settings: Settings, + statuslineDir: string, +): Settings { + if (!settings.statusLine) { + settings.statusLine = { + type: "command", + command: `bun ${statuslineDir}/src/index.ts`, + padding: 0, + }; + } + return settings; +} diff --git a/.cursor-plugin/scripts/src/services/setup-plugins.ts b/.cursor-plugin/scripts/src/services/setup-plugins.ts new file mode 100644 index 0000000..79eae1b --- /dev/null +++ b/.cursor-plugin/scripts/src/services/setup-plugins.ts @@ -0,0 +1,81 @@ +/** + * Plugin setup steps service + * SRP: plugin scanning, dependency installation, statusline wiring and + * CLAUDE.md sync. + */ +import { existsSync } from "node:fs"; +import { join } from "node:path"; +import * as p from "@clack/prompts"; +import { + filesAreEqual, + installPluginDeps, + makeScriptsExecutable, +} from "../utils/fs-helpers"; +import { findPluginDepDirs } from "../utils/dep-scanner"; +import { scanPlugins } from "./plugin-scanner"; +import type { Settings } from "./settings-manager"; +import { configureStatusLine } from "./settings-manager"; + +/** Scan plugins and make scripts executable */ +export async function scanAndPrepare(pluginsDir: string): Promise { + const s = p.spinner(); + s.start("Scanning plugins..."); + const plugins = scanPlugins({ pluginsDir }); + const withHooks = plugins.filter((pl) => pl.hasHooks); + s.stop(`${withHooks.length} plugins with hooks detected`); + + s.start("Making scripts executable..."); + const scriptCount = await makeScriptsExecutable(pluginsDir); + s.stop(`${scriptCount} scripts made executable`); +} + +/** Install CLAUDE.md if newer */ +export async function installClaudeMd( + src: string, + dest: string, +): Promise { + if (!existsSync(src)) return; + const same = await filesAreEqual(src, dest); + if (!same) { + await Bun.write(dest, await Bun.file(src).text()); + p.log.success("CLAUDE.md installed"); + } else { + p.log.info("CLAUDE.md already up to date"); + } +} + +/** Install dependencies for every plugin dir holding a package.json */ +export async function installDeps(pluginsDir: string): Promise { + const s = p.spinner(); + s.start("Installing plugin dependencies..."); + const dirs = findPluginDepDirs(pluginsDir); + let ok = 0; + for (const dir of dirs) { + try { + if (await installPluginDeps(dir)) ok++; + } catch { + p.log.warn(`Dependency install failed: ${dir}`); + } + } + s.stop(`${ok}/${dirs.length} plugin dep dirs installed`); +} + +/** Setup statusline if available */ +export async function setupStatusline( + pluginsDir: string, + settings: Settings, +): Promise { + const statuslineDir = join(pluginsDir, "core-guards/statusline"); + if (!existsSync(statuslineDir)) return settings; + + const s = p.spinner(); + s.start("Installing statusline..."); + try { + await installPluginDeps(statuslineDir); + settings = configureStatusLine(settings, statuslineDir); + s.stop("Statusline configured"); + } catch { + s.stop("Statusline installation failed"); + } + return settings; +} diff --git a/.cursor-plugin/scripts/src/services/setup-runner.ts b/.cursor-plugin/scripts/src/services/setup-runner.ts new file mode 100644 index 0000000..e9462fb --- /dev/null +++ b/.cursor-plugin/scripts/src/services/setup-runner.ts @@ -0,0 +1,98 @@ +/** + * Setup runner service + * Single Responsibility: Orchestrate plugin setup steps + */ +import { dirname, join } from "node:path"; +import * as p from "@clack/prompts"; +import type { SetupPaths } from "../interfaces/setup"; +import { copyLoaderTree } from "../utils/fs-helpers"; +import { installBrowserBinary } from "./browser-binary"; +import { setHarnessRefs } from "./harness-env"; +import { resolveHomeInHooks } from "./harness-path-resolver"; +import { promptHarnessGates } from "./harness-gates"; +import { promptHarnessTuning } from "./harness-tuning"; +import { promptEnforceTtl } from "./enforce-ttl"; +import { promptSolidMaxLines } from "./solid-lines"; +import { configureShell } from "./env-manager"; +import { configureMcpServers } from "./mcp-setup"; +import { promptPerfEnv } from "./perf-env"; +import { purgeFuseEnvVars } from "./settings-env-purge"; +import { + backupSettings, configureDefaults, configureHooks, enableAgentTeams, + isAgentTeamsEnabled, loadSettings, promptLanguage, saveSettings, +} from "./settings-manager"; +import { installDeps, scanAndPrepare, setupStatusline } from "./setup-plugins"; + +/** Run the complete setup process */ +export async function runSetup( + paths: SetupPaths, + skipEnv: boolean, +): Promise { + p.intro("Fusengine Plugins Setup"); + + const pluginsDir = paths.pluginsDir; + // Copy the loader WITH its `src/` tree: hooks-loader.ts imports five relative + // modules (interfaces/hooks, deps-targets, ensure-deps, hook-executor, + // plugin-scanner). Copying the single file leaves it unrunnable — every hook + // wired in ~/.cursor/hooks.json would die on "Cannot find module". + const loaderRoot = join(paths.marketplace, ".fusengine-global/scripts"); + const loaderDest = join(loaderRoot, "hooks-loader.ts"); + await copyLoaderTree(dirname(paths.loaderSrc), loaderRoot); + + await scanAndPrepare(pluginsDir); + + const selectedLanguage = await promptLanguage(); + + const s = p.spinner(); + s.start("Configuring hooks loader..."); + backupSettings(paths.settings); + let settings = await loadSettings(paths.settings); + settings = purgeFuseEnvVars(settings); + settings = configureDefaults(settings, selectedLanguage); + const wired = configureHooks(loaderDest); + s.stop(`Hooks loader configured (${wired} events in ~/.cursor/hooks.json)`); + + // Global instructions are NOT written here: the deploy engine already installs + // ~/.cursor/rules/fuse-global.mdc (frontmatter `alwaysApply: true`, hashed in + // receipt.json). Copying CLAUDE.md.template on top would add a second, inert + // file — it carries no .mdc frontmatter, so Cursor would apply it only on an + // explicit @mention, and its body is Claude Code's prompt, not Cursor's. + // `installClaudeMd` stays available for the project scope. + await installDeps(pluginsDir); + + const homeSpinner = p.spinner(); + homeSpinner.start("Resolving $HOME in hook paths..."); + const resolved = await resolveHomeInHooks(pluginsDir); + homeSpinner.stop(`${resolved} hooks.json paths resolved`); + + settings = await setupStatusline(pluginsDir, settings); + settings = setHarnessRefs(settings, pluginsDir); + + if (isAgentTeamsEnabled(settings)) { + p.log.info( + "CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS already recorded in the Fusengine settings store (bookkeeping only, no effect on Cursor — its multi-agent feature is Subagents, on by default, no flag: https://cursor.com/docs/subagents)", + ); + } else { + settings = enableAgentTeams(settings); + p.log.info( + "Recorded CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS in the Fusengine settings store (bookkeeping only, no effect on Cursor — its multi-agent feature is Subagents, on by default, no flag: https://cursor.com/docs/subagents)", + ); + } + + settings = await promptPerfEnv(settings); + if (!skipEnv) { + await configureShell(); + settings = await promptSolidMaxLines(settings); + settings = await promptEnforceTtl(settings); + settings = await promptHarnessGates(settings); + settings = await promptHarnessTuning(settings); + const selectedMcp = await configureMcpServers(); + if (selectedMcp.includes("fuse-browser")) { + await installBrowserBinary(); + } + } + + await saveSettings(paths.settings, settings); + + p.outro("Setup complete! Reload the Cursor window to apply."); +} diff --git a/.cursor-plugin/scripts/src/services/shell-config.ts b/.cursor-plugin/scripts/src/services/shell-config.ts new file mode 100644 index 0000000..57cd604 --- /dev/null +++ b/.cursor-plugin/scripts/src/services/shell-config.ts @@ -0,0 +1,42 @@ +/** + * Shell configuration service + * Single Responsibility: Configure shell to auto-load .env + */ +import * as p from "@clack/prompts"; +import { detectShell, isShellConfigured } from "./shell-detection"; +import { + installFishConfig, + installPosixConfig, + installPowershellConfig, +} from "./shell-installers"; + +/** + * Configure shell to automatically load .env + */ +export async function configureShell(): Promise { + const shell = detectShell(); + + if (isShellConfigured(shell)) { + p.log.info(`${shell} already configured`); + return; + } + + const shouldConfigure = await p.confirm({ + message: `Configure ${shell} to automatically load API keys?`, + }); + + if (p.isCancel(shouldConfigure) || !shouldConfigure) return; + + switch (shell) { + case "fish": + installFishConfig(); + break; + case "pwsh": + installPowershellConfig(); + break; + default: + installPosixConfig(shell); + } + + p.log.success(`${shell} configured`); +} diff --git a/.cursor-plugin/scripts/src/services/shell-detection.ts b/.cursor-plugin/scripts/src/services/shell-detection.ts new file mode 100644 index 0000000..114e2ac --- /dev/null +++ b/.cursor-plugin/scripts/src/services/shell-detection.ts @@ -0,0 +1,64 @@ +/** + * Shell detection utilities + * Single Responsibility: Detect and check shell configuration + */ +import { existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; + +const HOME = process.env.HOME || process.env.USERPROFILE || ""; +const IS_WINDOWS = process.platform === "win32"; + +/** Detect user's shell */ +export function detectShell(): "bash" | "zsh" | "fish" | "pwsh" { + if (IS_WINDOWS) return "pwsh"; + + const shell = process.env.SHELL?.split("/").pop() || "bash"; + if (shell === "fish") return "fish"; + if (shell === "zsh") return "zsh"; + if (shell === "pwsh" || shell === "powershell") return "pwsh"; + return "bash"; +} + +/** Return PowerShell profile path based on OS */ +export function getPowershellProfilePath(): string { + if (IS_WINDOWS) { + return join( + HOME, + "Documents", + "PowerShell", + "Microsoft.PowerShell_profile.ps1", + ); + } + return join( + HOME, + ".config", + "powershell", + "Microsoft.PowerShell_profile.ps1", + ); +} + +/** Check if shell is already configured */ +export function isShellConfigured(shell: string): boolean { + switch (shell) { + case "fish": { + const fishConf = join( + HOME, + ".config", + "fish", + "conf.d", + "cursor-env.fish", + ); + return existsSync(fishConf); + } + case "pwsh": { + const psProfile = getPowershellProfilePath(); + if (!existsSync(psProfile)) return false; + return readFileSync(psProfile, "utf8").includes("cursor"); + } + default: { + const rcFile = join(HOME, shell === "zsh" ? ".zshrc" : ".bashrc"); + if (!existsSync(rcFile)) return false; + return readFileSync(rcFile, "utf8").includes("cursor"); + } + } +} diff --git a/.cursor-plugin/scripts/src/services/shell-installers.ts b/.cursor-plugin/scripts/src/services/shell-installers.ts new file mode 100644 index 0000000..5b849db --- /dev/null +++ b/.cursor-plugin/scripts/src/services/shell-installers.ts @@ -0,0 +1,76 @@ +/** + * Shell configuration installers + * Single Responsibility: Install shell configs for different shells + */ +import { + copyFileSync, + existsSync, + mkdirSync, + readFileSync, + writeFileSync, +} from "node:fs"; +import { dirname, join } from "node:path"; +import { getPowershellProfilePath } from "./shell-detection"; + +const HOME = process.env.HOME || process.env.USERPROFILE || ""; +const SCRIPT_DIR = dirname(dirname(dirname(import.meta.path))); +const ENV_SHELL_DIR = join(SCRIPT_DIR, "env-shell"); + +/** + * Install fish configuration, plus the BASH_ENV shim it points at. + * The shim is required: cursor-env.fish sets BASH_ENV to it so non-interactive + * bash loads the FUSE_*-filtered loader instead of the raw ~/.cursor/.env. + */ +export function installFishConfig(): void { + const confDir = join(HOME, ".config", "fish", "conf.d"); + mkdirSync(confDir, { recursive: true }); + copyFileSync( + join(ENV_SHELL_DIR, "cursor-env.fish"), + join(confDir, "cursor-env.fish"), + ); + + const claudeDir = join(HOME, ".cursor"); + mkdirSync(claudeDir, { recursive: true }); + copyFileSync( + join(ENV_SHELL_DIR, "bash-env-loader.sh"), + join(claudeDir, "bash-env-loader.sh"), + ); +} + +/** Install PowerShell configuration */ +export function installPowershellConfig(): void { + const profileFile = getPowershellProfilePath(); + mkdirSync(dirname(profileFile), { recursive: true }); + const psConfig = readFileSync(join(ENV_SHELL_DIR, "cursor-env.ps1"), "utf8"); + + if (existsSync(profileFile)) { + const existing = readFileSync(profileFile, "utf8"); + writeFileSync( + profileFile, + `${existing} +${psConfig}`, + ); + } else { + writeFileSync(profileFile, psConfig); + } +} + +/** Install bash/zsh configuration */ +export function installPosixConfig(shell: "bash" | "zsh"): void { + const rcFile = join(HOME, shell === "zsh" ? ".zshrc" : ".bashrc"); + const sourceBlock = readFileSync( + join(ENV_SHELL_DIR, `cursor-env.${shell}`), + "utf8", + ); + + if (existsSync(rcFile)) { + const existing = readFileSync(rcFile, "utf8"); + writeFileSync( + rcFile, + `${existing} +${sourceBlock}`, + ); + } else { + writeFileSync(rcFile, sourceBlock); + } +} diff --git a/.cursor-plugin/scripts/src/services/solid-lines.ts b/.cursor-plugin/scripts/src/services/solid-lines.ts new file mode 100644 index 0000000..3d8f8d7 --- /dev/null +++ b/.cursor-plugin/scripts/src/services/solid-lines.ts @@ -0,0 +1,31 @@ +/** + * SOLID max-lines service + * Single Responsibility: let the user set FUSE_SOLID_MAX_LINES in ~/.cursor/.env. + */ +import * as p from "@clack/prompts"; +import { upsertEnvVar } from "./env-file"; +import type { Settings } from "./settings-manager"; + +const KEY = "FUSE_SOLID_MAX_LINES"; +// First option is the visual default (p.select has no initialValue prop). +const OPTIONS = [ + { value: "100", label: "100 lines (strict SOLID, default)" }, + { value: "120", label: "120 lines" }, + { value: "150", label: "150 lines (Swift-style)" }, + { value: "200", label: "200 lines" }, +] as const; + +/** + * Prompt for the SOLID max lines/file limit and persist it to ~/.cursor/.env. + * @param settings - current settings object (returned unchanged, kept for chaining) + */ +export async function promptSolidMaxLines(settings: Settings): Promise { + const choice = await p.select({ + message: "SOLID max lines per file (file-size enforcement hooks)?", + options: OPTIONS.map((o) => ({ value: o.value, label: o.label })), + }); + if (p.isCancel(choice)) return settings; + upsertEnvVar(KEY, choice as string); + p.log.success(`SOLID limit set to ${choice} lines (${KEY}=${choice})`); + return settings; +} diff --git a/.cursor-plugin/scripts/src/utils/bun-install-runner.ts b/.cursor-plugin/scripts/src/utils/bun-install-runner.ts new file mode 100644 index 0000000..182901c --- /dev/null +++ b/.cursor-plugin/scripts/src/utils/bun-install-runner.ts @@ -0,0 +1,24 @@ +/** + * Fail-open `bun install` runner — built-ins/Bun-global only (no `import "bun"`, + * no node_modules import), so it works even when node_modules is entirely gone. + */ +import type { InstallFn } from "../interfaces/ensure-deps"; + +/** + * Run `bun install --silent` in `cwd`, hard-killing it after `timeoutMs`. + * Never throws on timeout/non-zero exit: a failed or slow install just leaves + * the caller's presence marker missing, and the next hook invocation retries. + * + * @param cwd - Directory holding the package.json to install + * @param timeoutMs - Hard kill timeout in milliseconds + */ +export const runBunInstall: InstallFn = async (cwd, timeoutMs) => { + const proc = Bun.spawn([process.execPath, "install", "--silent"], { + cwd, + stdout: "ignore", + stderr: "ignore", + timeout: timeoutMs, + killSignal: "SIGKILL", + }); + await proc.exited; +}; diff --git a/.cursor-plugin/scripts/src/utils/command-parser.ts b/.cursor-plugin/scripts/src/utils/command-parser.ts new file mode 100644 index 0000000..69a6081 --- /dev/null +++ b/.cursor-plugin/scripts/src/utils/command-parser.ts @@ -0,0 +1,28 @@ +/** + * Hook command parser + * Single Responsibility: split a resolved hook command into a shell-free argv, + * reproducing what `bash -c ""` did for our unquoted hook commands. + */ +import type { ParsedHookCommand } from "../interfaces/hooks"; + +/** Trailing ` || true` (bash swallows any non-zero exit, exit 2 included). */ +const OR_TRUE_SUFFIX = /\s*\|\|\s*true\s*$/; + +/** + * Parse a resolved hook command into argv tokens + an `ignoreExit` flag. + * + * Design (argv[0]): kept pure — argv[0] stays the literal token (e.g. "bun") and the + * executor swaps "bun" -> process.execPath at spawn time. The parser therefore has no + * process-state dependency and is trivially unit-testable. Splitting is plain whitespace + * word-splitting, which matches bash for these unquoted commands (a space-containing path + * splits the same way it already breaks under bash — deliberately left as-is to avoid a + * behaviour change). + * + * @param command Resolved command (absolute paths, `${...}` placeholders already replaced). + * @returns argv tokens and whether a trailing `|| true` was present. + */ +export function parseHookCommand(command: string): ParsedHookCommand { + const ignoreExit = OR_TRUE_SUFFIX.test(command); + const argv = command.replace(OR_TRUE_SUFFIX, "").trim().split(/\s+/); + return { argv, ignoreExit }; +} diff --git a/.cursor-plugin/scripts/src/utils/dep-scanner.ts b/.cursor-plugin/scripts/src/utils/dep-scanner.ts new file mode 100644 index 0000000..6bef6b6 --- /dev/null +++ b/.cursor-plugin/scripts/src/utils/dep-scanner.ts @@ -0,0 +1,42 @@ +/** + * Dependency directory scanner + * Single Responsibility: recursively locate plugin dirs holding a package.json + */ +import { existsSync, readdirSync } from "node:fs"; +import { join } from "node:path"; + +/** Directory names never descended into during the scan */ +const SKIP_DIRS = new Set(["node_modules", ".git", "dist", "build"]); + +/** + * Recursively find every directory under `pluginsDir` that contains a + * `package.json`. Excludes node_modules/.git/dist/build (never descends into + * them). Iterative DFS (explicit stack) avoids stack overflow and prunes + * excluded dirs before descending. Robust: readdir errors are non-fatal. + * + * @param pluginsDir - Root directory to scan + * @returns Sorted, deduplicated absolute paths of dirs with a package.json + */ +export function findPluginDepDirs(pluginsDir: string): string[] { + const results = new Set(); + const stack: string[] = [pluginsDir]; + + while (stack.length > 0) { + const dir = stack.pop(); + if (!dir) continue; + + if (existsSync(join(dir, "package.json"))) results.add(dir); + + try { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + if (!entry.isDirectory()) continue; + if (SKIP_DIRS.has(entry.name)) continue; + stack.push(join(dir, entry.name)); + } + } catch { + // permission denied or broken symlink — non-fatal, skip + } + } + + return [...results].sort(); +} diff --git a/.cursor-plugin/scripts/src/utils/fs-helpers.ts b/.cursor-plugin/scripts/src/utils/fs-helpers.ts new file mode 100644 index 0000000..0d66870 --- /dev/null +++ b/.cursor-plugin/scripts/src/utils/fs-helpers.ts @@ -0,0 +1,92 @@ +/** + * Helpers pour les opérations fichiers + */ +import { copyFileSync, cpSync, existsSync, mkdirSync, rmSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { $ } from "bun"; + +/** + * Copy the hooks loader together with the `src/` tree it imports. + * + * The loader pulls five relative modules; shipping the single file leaves it + * unrunnable, so every hook wired to it would die on "Cannot find module". + * Tests, node_modules and lockfiles are left behind — the loader needs none of + * them, and copying them would bloat the install. + * @param srcDir - Source `scripts/` directory. + * @param destDir - Destination directory (replaced wholesale). + * @returns Whether the tree was copied. + */ +export function copyLoaderTree(srcDir: string, destDir: string): boolean { + if (!existsSync(join(srcDir, "hooks-loader.ts"))) return false; + const skip = new Set(["node_modules", "__tests__", "bun.lock", "package.json", ".DS_Store"]); + rmSync(destDir, { recursive: true, force: true }); + mkdirSync(destDir, { recursive: true }); + cpSync(join(srcDir, "hooks-loader.ts"), join(destDir, "hooks-loader.ts")); + cpSync(join(srcDir, "src"), join(destDir, "src"), { + recursive: true, + filter: (path) => !skip.has(path.split("/").pop() ?? ""), + }); + return true; +} + +/** + * Copie un fichier avec création du dossier destination + */ +export function copyFile(src: string, dest: string): boolean { + if (!existsSync(src)) return false; + + mkdirSync(dirname(dest), { recursive: true }); + copyFileSync(src, dest); + return true; +} + +/** + * Copie un fichier et le rend exécutable + */ +export async function copyExecutable( + src: string, + dest: string, +): Promise { + if (!copyFile(src, dest)) return false; + await $`chmod +x ${dest}`.quiet(); + return true; +} + +/** + * Rend tous les scripts .sh exécutables dans un répertoire + */ +export async function makeScriptsExecutable(dir: string): Promise { + // A missing directory is not an error here: BSD find exits 1 on it, and Bun's + // `$` turns any non-zero exit into a throw, which aborted the whole install + // over a directory that simply has nothing to chmod. + if (!existsSync(dir)) return 0; + const result = await $`find ${dir} -name "*.sh" -type f`.nothrow().quiet(); + const files = result.text().trim().split("\n").filter(Boolean); + for (const file of files) { + await $`chmod +x ${file}`.quiet(); + } + return files.length; +} + +/** + * Install bun dependencies in a plugin directory + */ +export async function installPluginDeps(dir: string): Promise { + if (!existsSync(join(dir, "package.json"))) return false; + const result = await $`cd ${dir} && bun install --silent`.quiet().nothrow(); + return result.exitCode === 0; +} + +/** + * Compare le contenu de deux fichiers + */ +export async function filesAreEqual( + path1: string, + path2: string, +): Promise { + if (!existsSync(path1) || !existsSync(path2)) return false; + + const content1 = await Bun.file(path1).text(); + const content2 = await Bun.file(path2).text(); + return content1 === content2; +} diff --git a/.cursor-plugin/scripts/src/utils/hooks-discovery.ts b/.cursor-plugin/scripts/src/utils/hooks-discovery.ts new file mode 100644 index 0000000..4ce9b67 --- /dev/null +++ b/.cursor-plugin/scripts/src/utils/hooks-discovery.ts @@ -0,0 +1,22 @@ +/** + * Plugin hooks discovery + * Single Responsibility: locate `hooks.json` for every plugin dir + */ +import { existsSync, readdirSync } from "node:fs"; +import { join } from "node:path"; + +/** + * Dynamically lists every `pluginsDir//hooks/hooks.json` — no hardcoded + * plugin names, except `memory-neural`: it's .gitignore'd (owner's dormant WIP, + * out of distribution) and intentionally left on the old ${CURSOR_PLUGIN_ROOT} + * path, so discovery must skip it. + * + * @param pluginsDir - Directory holding plugin subfolders + * @returns Absolute paths of existing plugin hooks.json files + */ +export function discoverHooksFiles(pluginsDir: string): string[] { + return readdirSync(pluginsDir, { withFileTypes: true }) + .filter((entry) => entry.isDirectory() && entry.name !== "memory-neural") + .map((entry) => join(pluginsDir, entry.name, "hooks", "hooks.json")) + .filter((path) => existsSync(path)); +} diff --git a/.cursor-plugin/scripts/src/utils/single-flight-lock.ts b/.cursor-plugin/scripts/src/utils/single-flight-lock.ts new file mode 100644 index 0000000..dc280dd --- /dev/null +++ b/.cursor-plugin/scripts/src/utils/single-flight-lock.ts @@ -0,0 +1,64 @@ +/** + * Single-flight file lock — built-ins only (must survive a node_modules wipe, + * since this is the primitive `ensure-deps.ts` uses to repair node_modules). + * Uses atomic `wx` (O_CREAT|O_EXCL) create as the race-free primitive; a lock + * older than `staleMs` is treated as abandoned and reclaimed once. + */ +import { closeSync, mkdirSync, openSync, readFileSync, rmSync, writeSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +const LOCK_DIR = join(tmpdir(), "fusengine-ensure-deps"); +const DEFAULT_STALE_MS = 120_000; + +function lockPathFor(key: string): string { + const slug = key.replace(/[^a-zA-Z0-9]+/g, "_"); + return join(LOCK_DIR, `${slug}.lock`); +} + +/** Age of an existing lock file in ms; a corrupt/unreadable lock is maximally stale. */ +function readLockAgeMs(lockPath: string): number { + try { + const { ts } = JSON.parse(readFileSync(lockPath, "utf8")) as { ts: number }; + return Date.now() - ts; + } catch { + return Number.POSITIVE_INFINITY; + } +} + +/** Atomically create the lock file; false means another process already holds it. */ +function createLockFile(lockPath: string): boolean { + try { + const fd = openSync(lockPath, "wx"); + writeSync(fd, JSON.stringify({ pid: process.pid, ts: Date.now() })); + closeSync(fd); + return true; + } catch { + return false; + } +} + +/** + * Try to atomically acquire a single-flight lock for `key`. + * Reclaims a stale lock (older than `staleMs`) exactly once before giving up, + * so a crashed holder can never wedge the lock forever. + * + * @param key - Unique identifier for the resource being locked (e.g. a dir path) + * @param staleMs - Age after which an abandoned lock is force-reclaimed + * @returns a release function if acquired, or `null` if another process holds it + */ +export function tryAcquireLock(key: string, staleMs = DEFAULT_STALE_MS): (() => void) | null { + mkdirSync(LOCK_DIR, { recursive: true }); + const lockPath = lockPathFor(key); + + if (createLockFile(lockPath)) { + return () => rmSync(lockPath, { force: true }); + } + if (readLockAgeMs(lockPath) > staleMs) { + rmSync(lockPath, { force: true }); + if (createLockFile(lockPath)) { + return () => rmSync(lockPath, { force: true }); + } + } + return null; +} diff --git a/.cursor-plugin/scripts/tsconfig.json b/.cursor-plugin/scripts/tsconfig.json new file mode 100644 index 0000000..d723d38 --- /dev/null +++ b/.cursor-plugin/scripts/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "bundler", + "types": ["bun"], + "strict": true, + "skipLibCheck": true, + "noEmit": true, + "esModuleInterop": true, + "resolveJsonModule": true + }, + "include": ["*.ts", "src/**/*.ts"] +} diff --git a/.cursor-plugin/scripts/validate-agents.sh b/.cursor-plugin/scripts/validate-agents.sh new file mode 100755 index 0000000..20e09a0 --- /dev/null +++ b/.cursor-plugin/scripts/validate-agents.sh @@ -0,0 +1,136 @@ +#!/bin/bash +# Agent coherence validation script +# Usage: bash scripts/validate-agents.sh + +set -euo pipefail + +PLUGINS_DIR="plugins" +ISSUES_FOUND=0 + +echo "=============================================" +echo " Agent Coherence Validation" +echo "=============================================" +echo "" + +# Colors +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +# Logging functions +log_error() { + echo -e "${RED}❌ ERROR${NC}: $1" + ((ISSUES_FOUND++)) +} + +log_warning() { + echo -e "${YELLOW}⚠️ WARNING${NC}: $1" +} + +log_success() { + echo -e "${GREEN}✓${NC} $1" +} + +# 1. Check YAML frontmatter structure +echo "1. Checking YAML frontmatter structure..." +for agent_file in ${PLUGINS_DIR}/*/agents/*.md; do + if [ -f "$agent_file" ]; then + # Check frontmatter presence + if ! grep -q "^---$" "$agent_file" | head -1; then + log_error "$agent_file: Missing YAML frontmatter" + continue + fi + + # Check required fields + for field in "name:" "description:" "model:" "color:" "tools:"; do + if ! grep -q "^$field" "$agent_file"; then + log_error "$agent_file: Missing '$field' field in frontmatter" + fi + done + fi +done +log_success "Frontmatter structure verified" +echo "" + +# 2. Check 'Forbidden' section presence +echo "2. Checking 'Forbidden' section..." +for agent_file in ${PLUGINS_DIR}/*/agents/*.md; do + if [ -f "$agent_file" ]; then + if ! grep -qi "^## Forbidden" "$agent_file" && ! grep -qi "^## Forbidden Behaviors" "$agent_file"; then + log_warning "$agent_file: Missing 'Forbidden' section" + fi + fi +done +log_success "'Forbidden' sections verified" +echo "" + +# 3. Detect duplicate hook scripts +echo "3. Detecting duplicate hook scripts..." +DUPLICATE_SCRIPTS=0 +for script_name in "track-skill-read.sh" "track-mcp-research.sh" "check-shadcn-install.sh" "validate-solid.sh"; do + script_count=$(find ${PLUGINS_DIR} -name "$script_name" 2>/dev/null | wc -l) + if [ "$script_count" -gt 1 ]; then + log_error "Script '$script_name' duplicated in $script_count plugins" + ((DUPLICATE_SCRIPTS++)) + fi +done +if [ "$DUPLICATE_SCRIPTS" -eq 0 ]; then + log_success "No duplicate hook scripts" +else + log_error "$DUPLICATE_SCRIPTS duplicate script types detected" +fi +echo "" + +# 4. Check Edit/Write tools coherence +echo "4. Checking Edit/Write tools coherence..." +for agent_file in ${PLUGINS_DIR}/*/agents/*.md; do + if [ -f "$agent_file" ]; then + has_edit=$(grep "^tools:" "$agent_file" | grep -c "Edit" || true) + has_write=$(grep "^tools:" "$agent_file" | grep -c "Write" || true) + + if [ "$has_edit" -gt 0 ] && [ "$has_write" -eq 0 ]; then + log_warning "$agent_file: Has 'Edit' but not 'Write' (inconsistent)" + fi + fi +done +log_success "Edit/Write coherence verified" +echo "" + +# 5. Check valid model presence +echo "5. Checking valid models..." +VALID_MODELS="sonnet|haiku|opus" +for agent_file in ${PLUGINS_DIR}/*/agents/*.md; do + if [ -f "$agent_file" ]; then + model=$(grep "^model:" "$agent_file" | sed 's/model: //' || true) + if [ -n "$model" ] && ! echo "$model" | grep -qE "^($VALID_MODELS)$"; then + log_error "$agent_file: Invalid model '$model' (expected: sonnet, haiku, opus)" + fi + fi +done +log_success "Valid models verified" +echo "" + +# 6. Detect agents without workflow +echo "6. Detecting agents without structured workflow..." +for agent_file in ${PLUGINS_DIR}/*/agents/*.md; do + if [ -f "$agent_file" ]; then + if ! grep -qi "workflow" "$agent_file" && ! grep -qi "protocol" "$agent_file" && ! grep -qi "process" "$agent_file"; then + log_warning "$agent_file: No workflow/protocol/process detected" + fi + fi +done +log_success "Workflows verified" +echo "" + +# Summary +echo "=============================================" +echo " Summary" +echo "=============================================" +if [ "$ISSUES_FOUND" -eq 0 ]; then + log_success "No critical errors detected" + exit 0 +else + log_error "$ISSUES_FOUND critical errors detected" + exit 1 +fi diff --git a/.cursor-plugin/scripts/verify.ts b/.cursor-plugin/scripts/verify.ts new file mode 100755 index 0000000..d829618 --- /dev/null +++ b/.cursor-plugin/scripts/verify.ts @@ -0,0 +1,19 @@ +#!/usr/bin/env bun +/** + * verify.ts - Read-only verification entry point for an installed project + * @description Ported 1:1 from project-verify.mjs. `verify-project.sh` in the usage message + * below is a pre-existing naming quirk from the original script, preserved verbatim. + * READ-ONLY: never writes to the target project. + */ +import { verifyProject } from "./src/services/project-verify"; + +const argument = process.argv[2]; +if (!argument) { + process.stderr.write("Usage: verify-project.sh \n"); + process.exit(1); +} + +const failures = verifyProject(argument); +for (const failure of failures) process.stderr.write(`FAIL ${failure}\n`); +if (failures.length > 0) process.exit(1); +process.stdout.write("VERDICT project installation verified\n"); diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..9644bff --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,155 @@ +name: CI + +on: + pull_request: + branches: [main] + push: + branches: [main] + +# A newer push to the same branch cancels the previous run. +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + installer: + name: Installer (typecheck + unit tests) + runs-on: ubuntu-latest + defaults: + run: + working-directory: .cursor-plugin/scripts + steps: + - uses: actions/checkout@v4 + + - uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + # The shell-loader tests spawn zsh and fish, neither of which ships on + # ubuntu-latest; without them those cases skip themselves and the loaders + # go untested. fish lives in universe, enabled by default on this image. + - name: Install the shells under test + run: sudo apt-get update && sudo apt-get install -y zsh fish + working-directory: . + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Typecheck + run: bunx tsc --noEmit + + # No test may skip here: a skip means a shell is missing from the runner, + # which is exactly the silent coverage loss the step above prevents. + - name: Unit tests + run: | + set -euo pipefail + bun test 2>&1 | tee /tmp/bun-test.log + if grep -qE '^ *[0-9]+ skip' /tmp/bun-test.log; then + echo "::error::a unit test skipped on CI — a required shell is missing from the runner" + grep -E '\(skip\)' /tmp/bun-test.log || true + exit 1 + fi + + integration: + name: Install flows (sandboxed HOME) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Install installer dependencies + run: bun install --frozen-lockfile + working-directory: .cursor-plugin/scripts + + # Every suite drives install.sh against a throwaway HOME, so nothing + # touches the runner's own configuration. + - name: Shell test suites + run: | + set -euo pipefail + failed=0 + for suite in tests/*.test.sh; do + echo "::group::$suite" + if bash "$suite"; then + echo "PASS $suite" + else + echo "::error file=$suite::suite failed" + failed=1 + fi + echo "::endgroup::" + done + exit "$failed" + + - name: Dry run writes nothing under ~/.cursor + run: | + set -euo pipefail + SANDBOX="$(mktemp -d)" + HOME="$SANDBOX" ./install.sh --dry-run + if [ -e "$SANDBOX/.cursor" ]; then + echo "::error::--dry-run created $SANDBOX/.cursor" + find "$SANDBOX/.cursor" | head -20 + exit 1 + fi + echo "PASS: dry-run left ~/.cursor untouched" + + - name: Real install deploys every plugin + run: | + set -euo pipefail + SANDBOX="$(mktemp -d)" + HOME="$SANDBOX" ./install.sh --skip-env < /dev/null + expected=$(node -e "console.log(require('./.cursor-plugin/marketplace.json').plugins.length)") + actual=$(ls -1 "$SANDBOX/.cursor/plugins/local" | wc -l | tr -d ' ') + echo "plugins: $actual / $expected" + [ "$actual" = "$expected" ] || { echo "::error::plugin count mismatch"; exit 1; } + test -f "$SANDBOX/.cursor/rules/fuse-global.mdc" || { echo "::error::global user rule missing"; exit 1; } + test -f "$SANDBOX/.cursor/.fusengine-global/receipt.json" || { echo "::error::receipt missing"; exit 1; } + # The deployed loader must start from its destination, with its src/ tree. + HOME="$SANDBOX" bun "$SANDBOX/.cursor/.fusengine-global/scripts/hooks-loader.ts" sessionStart < /dev/null + echo "PASS: install deployed $actual plugins, rule, receipt and a runnable loader" + + plugins: + name: Plugin manifests and hooks + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Every plugin.json and hooks.json parses + run: | + set -euo pipefail + for f in .cursor-plugin/marketplace.json \ + .cursor-plugin/plugins/*/.cursor-plugin/plugin.json \ + .cursor-plugin/plugins/*/hooks/hooks.json; do + node -e "JSON.parse(require('fs').readFileSync('$f','utf8'))" \ + || { echo "::error file=$f::invalid JSON"; exit 1; } + done + echo "PASS: all manifests parse" + + - name: Hook commands stay relative, wrappers executable + run: | + set -euo pipefail + if grep -rn 'npx -y @fusengine/harness\|bun \$HOME' .cursor-plugin/plugins/*/hooks/hooks.json; then + echo "::error::hook commands must call ./scripts/hook.sh (relative), per the Cursor submission checklist" + exit 1 + fi + for w in .cursor-plugin/plugins/*/scripts/hook.sh; do + sh -n "$w" || { echo "::error file=$w::syntax error"; exit 1; } + test -x "$w" || { echo "::error file=$w::not executable"; exit 1; } + done + echo "PASS: $(ls -1 .cursor-plugin/plugins/*/scripts/hook.sh | wc -l | tr -d ' ') wrappers valid" + + - name: Entry points resolve + run: | + set -euo pipefail + bash -n install.sh + bash -n verify.sh + bash -n verify-project.sh + for target in .cursor-plugin/scripts/install-hooks.ts \ + .cursor-plugin/scripts/hooks-loader.ts \ + .cursor-plugin/scripts/verify.ts \ + .cursor-plugin/scripts/src/services/global-install.ts \ + .cursor-plugin/scripts/src/services/project-install.ts; do + test -f "$target" || { echo "::error::dead entry point: $target"; exit 1; } + done + echo "PASS: shell entry points valid, every target exists" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1b28d31 --- /dev/null +++ b/.gitignore @@ -0,0 +1,29 @@ +# Dependencies — regenerated by the installer (`bun install`), never fetched from git. +node_modules/ + +# Secrets and local environment +.env +.env.* +!.env.example + +# Logs and caches +*.log +npm-debug.log* +.cache/ +.eslintcache +*.tsbuildinfo + +# Coverage +coverage/ + +# OS noise +.DS_Store + +# Agent scratch state — session-local, not part of the distributed plugin +.harness/ +.impeccable/ +.codex/apex/ +.claude/ + +# bun.lock IS committed on purpose (bun docs: "Should it be committed to git? Yes."), +# so `--frozen-lockfile` installs stay reproducible. Do not add it here. diff --git a/CHANGELOG.md b/CHANGELOG.md index accd591..9852d73 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,41 @@ # Changelog +## [0.1.1] - 01-09-2026 + +- refactor(installer)!: installer engine ported to TypeScript/bun — no `.mjs` left under + `.cursor-plugin/scripts/`. Single entry point `install-hooks.ts`, which spawns the deploy engine + (`src/services/global-install.ts` or `project-install.ts`) then runs the configuration stage +- chore(installer)!: prerequisite changed from Node.js to Bun (`curl -fsSL https://bun.sh/install | bash`); + CLI options unchanged (`--dry-run`, `--uninstall`, `--project `, `--skip-env`), as are the + transactional guarantees (rollback, per-plugin hashes in `receipt.json`, ownership preflight, symlink guard) +- fix(mcp): MCP servers are now written directly to `~/.cursor/mcp.json` (non-destructive merge, + API keys via `${env:NAME}`) — the port previously shelled out to `claude mcp add-json`, which is + Claude Code's CLI, not Cursor's, so it installed nothing +- fix(hooks): the loader is wired into `~/.cursor/hooks.json` with camelCase Cursor events, verified + against the Cursor 3.18.25 bundle — it previously targeted a `settings.json` Cursor never reads +- fix(rules): global instructions ship as `~/.cursor/rules/fuse-global.mdc` (`alwaysApply: true`, + hashed in `receipt.json`). Cursor only recognises `AGENTS.md` at the root of a workspace folder, + so a copy under `~/.cursor/` was never read +- feat(harness): every plugin embeds `scripts/hook.sh`, invoked through a relative path as the Cursor + submission checklist requires; it resolves the shared harness (vendored binary → self-heal + `bun install` → pinned `npx`) and tracks the `latest` release, so the plugin also works when + installed from the marketplace, where no installer ever runs +- fix(installer): `--project` no longer exits 1 on a fresh `$HOME`. It used to fall through to the + global setup stage, which scans `~/.cursor/plugins/local`; on a missing directory `find` exits 1 + and Bun's `$` turns that into an uncaught throw, so the command failed even though the project + deploy had already succeeded. Project mode now stops after its own deploy, and a missing directory + is no longer an error for `makeScriptsExecutable` +- fix(uninstall): `--uninstall` now removes the whole `~/.cursor/.fusengine-global` control root. + It used to leave ~50 files behind for good — the deployed hook loader and its `src/` tree, plus the + harness `node_modules` the wrapper self-installs there: the receipt only tracks plugins and the rule, + so nothing could reclaim them, and a non-recursive `rmdir` failed silently on the non-empty directory. + User plugins, a modified rule, `~/.cursor/.env` and the hooks/MCP config are untouched, as before +- fix(installer): `.DS_Store` is no longer copied into the deployed loader tree +- fix(env): `FUSE_HARNESS_REFS` now scans `~/.cursor/plugins/local` — it was one level short and + silently produced an empty list +- chore(repo): added `.gitignore` (`node_modules` ignored, `bun.lock` committed) and the missing + `.mdc` frontmatter on 12 plugin rules that were therefore never auto-applied + ## [1.39.26] - 01-09-2026 - Fixed marketplace sparse checkout installation (24 plugin versions bumped) diff --git a/MEMORY/LESSON.md b/MEMORY/LESSON.md new file mode 100644 index 0000000..a2ee1cc --- /dev/null +++ b/MEMORY/LESSON.md @@ -0,0 +1,19 @@ +# Lessons — cursor-plugins + +- [2026-09-01 20:34] Le proprio a demandé un **portage** de l'installeur Claude Code ; j'ai livré une adaptation maison en `.mjs` (modules `harness-*`, `env-*`, `mcp-*` écrits from scratch) au lieu de cloner `claude-plugins/scripts/` (TS + bun, 46 fichiers) et d'en transposer les chemins. Deux heures perdues, trois agents lancés pour rien. → **Porter = cloner la source puis substituer, jamais réécrire dans une autre techno.** Le `.mjs` préexistant du repo n'était pas une convention à honorer, c'était justement ce qu'il fallait remplacer. Quand la demande dit « comme X », la première commande est `rsync` depuis X, pas un brief d'agent. +- [2026-09-01 20:34] Après le clone, `mcp-installer.ts:24` shellait encore `` $`claude mcp add-json --scope=user` `` : le service portait le bon nom, compilait, et n'aurait **rien installé** sous Cursor. Le typecheck était vert. → Après tout portage, **grep les invocations du binaire de l'outil SOURCE** (`claude`, `claude mcp`, `$`…``) et les placeholders (`CLAUDE_PROJECT_DIR`, `CLAUDE_CODE_*`) : un renommage de chemins ne les voit pas, et `tsc` non plus. Même piège pour `settings.json` — Cursor ne le lit pas, donc tout `configureHooks` qui y écrit travaille dans le vide. +- [2026-09-01 20:34] J'ai prouvé que les hooks se déclenchaient via `~/.cursor/hooks.json` (canal **user-level**) puis conclu que les 62 hooks des plugins fonctionnaient — alors que le canal **plugin** (`/hooks/hooks.json`) n'a jamais été mesuré : sonde posée 3 fois, log resté vide, y compris sur un témoin en chemin absolu. → Un plugin **chargé** (`loadUserLocalPlugins ... 24 plugins loaded`) ne prouve pas que ses hooks **s'exécutent**. Toute sonde de hook exige les DEUX canaux armés en parallèle sur le même script, un argument différent par canal ; sans ce contrôle négatif, un log vide ne distingue pas « canal mort » de « aucun événement déclenché ». +- [2026-09-01 20:45] J'ai remplacé les 20 `.cursor-plugin/scripts/*.mjs` par le clone TS/bun sans toucher `install.sh`, resté sur `exec node .../install.mjs` — **fichier supprimé**. L'installeur était donc totalement inutilisable, et `bunx tsc --noEmit` + `bun test` étaient **verts** (182 pass) parce qu'aucun test ne franchit le point d'entrée shell. Détecté uniquement par un `test -f` manuel sur la cible du `exec`. → Après tout remplacement de moteur, **suivre la chaîne depuis le point d'entrée utilisateur** (`install.sh`, `install.ps1`, `verify*.sh`, `tests/*.sh`, `docs/`) et vérifier l'EXISTENCE de chaque cible : `grep -rn "\.mjs" install.sh tests/ docs/`. Vert au typecheck ne prouve rien sur ce qui n'est pas typé — le shell d'entrée est hors du graphe TypeScript. +- [2026-09-01 20:53] En clonant l'installeur amont par-dessus, j'ai effacé ce que la CIBLE avait **en propre et que la source n'a jamais eu** : déploiement transactionnel avec rollback (`commitGlobalSnapshot`), `receipt.json` à hash par plugin, `preflightOwned`, et les options `--dry-run` / `--uninstall` / `--project`. Claude Code n'ayant aucun de ces mécanismes, le clone les a supprimés en silence — et rien ne l'a signalé, ni `tsc` ni les 182 tests. → Avant de cloner par-dessus, **inventorier les capacités de la cible qui n'existent pas dans la source** (`git show HEAD:` + liste des options CLI acceptées) ; ce qui est unique à la cible se RESTAURE (`git checkout -- `), il ne se remplace pas. Bon découpage final : deux moteurs côte à côte — le `.mjs` transactionnel garde le déploiement, le TS cloné prend la configuration. +- [2026-09-01 20:59] `setup-runner` copiait `hooks-loader.ts` **seul** vers `~/.cursor/scripts/`, alors qu'il importe 5 modules relatifs (`./src/services/…`). Les 62 hooks câblés dessus mouraient sur `Cannot find module './src/services/deps-targets'` — invisible au `tsc` (le fichier compile très bien à sa place d'origine) et aux 182 tests. Trouvé seulement en **exécutant la copie isolée**. Même famille : `setHarnessRefs(join(marketplace,"plugins"))` visait un niveau trop haut → `FUSE_HARNESS_REFS` vide, 0 dossier au lieu de 14. → Un artefact **déplacé** doit être **exécuté depuis sa destination**, jamais validé depuis sa source : copier un fichier n'emporte pas son graphe d'imports, et un chemin dérivé par `join()` se vérifie par `existsSync` + comptage, pas par relecture. Règle : après toute copie ou tout chemin construit, `bun ` ou `ls ` — un compte de 0 est un défaut, pas un résultat. +- [2026-09-01 21:08] Portage `.mjs` → TS mené par 4 agents en parallèle : chacun a créé ses propres types dans `src/interfaces/marketplace.ts` et écrasé ceux du voisin, et deux shims de compatibilité (`legacy-mjs.d.ts` en racine ET dans `interfaces/`) ont maintenu un `tsc` VERT alors que 4 fichiers TS importaient encore des `.mjs` censés disparaître. Le vert masquait un portage inachevé. → Sur un portage multi-agents, **le fichier de types partagé appartient à UN SEUL owner** nommé dans les briefs, et **interdire les shims de compatibilité** : ils font passer le typecheck sans que le portage soit fini. Contrôle de fin obligatoire, dans cet ordre : `grep -rn 'from "[^"]*\.mjs"' src/` = 0, puis `ls *.d.ts` pour débusquer les shims, puis seulement `ls *.mjs` = 0. Et une référence dans un `.sh`/`.ps1` ne se voit ni au `tsc` ni aux tests — la vérifier séparément par `test -f` sur chaque cible d'`exec`. +- [2026-09-01 21:15] Portage `project-verify.mjs` → `.ts` déclaré « fidèle 1:1 », diff ligne à ligne parfait : `spawnSync(process.execPath, [".cursor/fusengine/load-plugins.mjs"])` était **identique au caractère près**. Mais le shebang passait de `#!/usr/bin/env node` à `#!/usr/bin/env bun`, donc `process.execPath` valait désormais le binaire bun — alors que le hook déployé lance `node .cursor/fusengine/load-plugins.mjs` (`project-hooks.ts::baseCommand`). La vérification testait un interpréteur que la production n'utilise jamais ; ni `tsc` ni les 182 tests ne pouvaient le voir. → Lors d'un changement de runtime, **les API dépendantes de l'environnement changent de valeur sans changer de texte** : auditer nommément `process.execPath`, `process.argv[0]`, `import.meta.*`, `__dirname`, `process.platform`. Quand le code doit REPRODUIRE une commande externe, écrire l'interpréteur en dur (`"node"`) plutôt que d'hériter du sien. Corollaire : un diff propre ne prouve pas l'équivalence sémantique — comparer les VALEURS mesurées des deux côtés, pas les lignes. +- [2026-09-01 21:25] J'ai fait écrire à l'installeur `~/.cursor/AGENTS.md` comme canal d'instructions global, en transposant `~/.claude/CLAUDE.md`. Le challenger l'avait marqué « NON VÉRIFIÉ » et je l'ai quand même livré et documenté. Le propriétaire a douté ; mesure dans le binaire Cursor 3.18.25 : `getWorkspaceFolder(uri); if(!c) return !1; l === "/AGENTS.md"` — **AGENTS.md n'est reconnu qu'à la racine d'un workspace folder**, donc un fichier sous `~/.cursor/` n'est jamais lu. Le vrai canal global est `~/.cursor/rules/` (`getRuleTargetDirectory` → `joinPath(userHome, ".cursor", "rules")`), ce que l'ancien installeur faisait déjà avec `fuse-global.mdc`. → Un « NON VÉRIFIÉ » du challenger est une dette bloquante, pas une note de bas de page : le lever AVANT de livrer, et jamais l'écrire dans la doc entre-temps. Pour un chemin lu par un binaire fermé, la doc éditeur ne suffit pas — `grep` le bundle (`Cursor.app/Contents/Resources/app/out/**/*.js`) et lire la condition réelle tranche en une minute. +- [2026-09-01 21:31] Pendant toute la refonte j'ai validé l'installeur au `tsc` + `bun test` (182 pass) sans jamais l'exécuter — or aucun de ces 182 tests ne franchit `install.sh`. Le seul test concluant a été un run réel avec `HOME=/tmp/fake ./install.sh --skip-env` : il a prouvé en 30 s les 24 plugins déployés, le receipt à 24 hashes, `fuse-global.mdc` posée, le loader qui démarre depuis sa destination et les 21 wrappers exécutables. → Un installeur se vérifie **en l'installant dans un HOME jetable**, jamais par lecture : `HOME=$(mktemp -d) ./install.sh [--dry-run]` est sans risque pour l'environnement réel et c'est le seul contrôle qui couvre le chemin d'entrée shell, les chemins construits et les artefacts copiés. À faire AVANT d'annoncer quoi que ce soit, pas après. Corollaire : dans ce genre de sandbox, `~/Library/Caches/bun/**` apparaît et n'est PAS une écriture de l'installeur — compter sous `~/.cursor/` uniquement avant de crier au bug. +- [2026-09-01 22:02] Deux agents ont diagnostiqué le crash de `install.sh --project` sur `$HOME` vierge ; l'un a livré ses suites VERTES en pré-créant `~/.cursor/plugins/local` dans son propre test, avec un commentaire honnête « bug de production, hors de mon lot ». Vert au tableau de bord, défaut intact en production, et le contournement aurait survécu au commit. → Un agent cantonné à un lot de fichiers ne peut PAS corriger un défaut situé hors de son lot : quand son rapport dit « contourné dans le test », c'est une dette à ENCAISSER immédiatement au niveau du lead — corriger la source, puis RETIRER le contournement et re-mesurer. Un test qui pré-crée l'état que la production est censée créer ne teste plus rien. +- [2026-09-01 22:02] Assertion « l'install projet n'écrit rien dans `$HOME` » en échec permanent : le coupable n'était pas l'installeur mais le cache de transpilation de **Bun** (`$HOME/Library/Caches/bun/@t@/*.pile` sur macOS, `~/.cache/bun` sur Linux), semé au premier `bun` lancé avec un HOME neuf. → Après un passage à Bun, tout test qui compare un instantané de `$HOME` doit neutraliser le runtime lui-même (`BUN_RUNTIME_TRANSPILER_CACHE_PATH=0`, vérifié par contrôle négatif) plutôt que d'élargir l'assertion : scoper à `$HOME/.cursor` affaiblit la garantie, neutraliser la conserve. Vaut pour toute migration de runtime — le nouvel interpréteur écrit chez l'utilisateur, l'ancien non. +- [2026-09-01 22:52] `--uninstall` laissait 64 fichiers (`~/.cursor/.fusengine-global/scripts/**`) chez l'utilisateur, définitivement. Pire que le défaut : l'assertion de test avait été **relâchée pour l'entériner** — `test ! -e .fusengine-global` remplacé par le seul `receipt.json`, avec un commentaire citant le `/* preserved if non-empty */` du code comme s'il s'agissait du contrat. Un commentaire de code décrit ce que le code FAIT, il ne prouve pas que ce soit voulu. → Quand une assertion doit être affaiblie pour passer, la question n'est jamais « comment la formuler » mais « quel défaut suis-je en train de graver dans le contrat » ; le relâchement se remonte au propriétaire, il ne se commente pas. Corollaire appliqué ici : avant de réécrire une assertion de suppression, **lui donner des dents** en vérifiant d'abord que la chose à supprimer EXISTE (`test -d .../scripts` avant l'uninstall) — sinon `test ! -e` passe sur un répertoire jamais peuplé et ne teste rien. +- [2026-09-01 22:52] Cause réelle, contre-intuitive : le `rmdirSync` non récursif n'était pas le bug. `receipt.json` est le registre de propriété (hash par plugin, `ruleHash`) et `scripts/` n'y figurait pas — écrit par `setup-runner.ts`, un module que le moteur de déploiement n'importe jamais. Aucun chemin de code ne pouvait donc le réclamer. → Sur un installeur transactionnel, un artefact déployé par un module hors du registre est **orphelin par construction** : lister les écritures RÉELLES sous le répertoire de contrôle (`find` après un install en HOME jetable) plutôt que de se fier au registre, qui ne connaît que ce que son propre module a écrit. +- [2026-09-01 22:35] Premier run du CI rouge sur UN test (`zsh loader`, absent d'`ubuntu-latest`). Le vrai défaut était ailleurs : le cas `fish` du même fichier portait déjà `test.skipIf(!Bun.which("fish"))` et se **skippait silencieusement** en CI depuis toujours — un CI vert me l'aurait caché indéfiniment. → Une garde `skipIf(outil absent)` protège la machine du contributeur mais **supprime la couverture là où elle compte le plus**. Sur le runner, installer l'outil (`sudo apt-get install -y zsh fish` ; ni l'un ni l'autre n'est préinstallé sur Ubuntu 24.04) ET faire échouer le job sur tout `skip` — sinon la garde transforme un test mort en test vert. Corollaire d'API : `bun:test` n'a pas de skip par ligne dans `test.each` (oven-sh/bun#4469 ouverte) — passer par une boucle `for`, et vérifier la garde par contrôle négatif (PATH privé du binaire → `1 skip / 0 fail`, contre `1 fail` avant). +- [2026-09-01 22:35] J'ai annoncé au proprio « `runSetup` n'écrit que sous `~/.cursor` » pour justifier un correctif — le challenger a mesuré des `append` dans `~/.zshrc` / `~/.bashrc` et un `conf.d/*.fish` (`shell-installers.ts:25,60,68-74`). Le correctif restait bon, mon argument était faux, et il minimisait ce qu'il évitait. → Un périmètre d'écriture s'énonce après un `grep` des primitives d'écriture du module (`writeFileSync`, `appendFileSync`, `spawn`), jamais d'après le nom des chemins qu'on a en tête. Se tromper « en faveur » de sa propre conclusion reste une erreur — et ici la vérité renforçait le correctif. +- [2026-09-01 22:02] `tests/marketplace-sparse-install.test.sh` compare le worktree à `git archive HEAD` : un `git rm` non commité le fait échouer en ENOENT sur un fichier que HEAD contient encore — échec purement mécanique, sans rapport avec le défaut testé. → Face à une suite qui se réfère à HEAD, tout changement d'INDEX non commité est un faux échec : trancher dans un `git clone . /tmp/x` où l'on peut commiter localement, jamais en commitant sur la branche pour « voir si ça passe ». diff --git a/README.md b/README.md index c1f7ce9..a7f05af 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Fusengine Cursor Plugins -![version](https://img.shields.io/badge/version-1.39.26-blue?style=flat-square) +![version](https://img.shields.io/badge/version-0.1.1-blue?style=flat-square) ![plugins](https://img.shields.io/badge/plugins-24-brightgreen?style=flat-square) ![runtime](https://img.shields.io/badge/runtime-Cursor%20CLI-black?style=flat-square) ![License: MIT](https://img.shields.io/badge/License-MIT-yellow?style=flat-square) @@ -34,7 +34,8 @@ installs them globally for the current user. Its default target is `~/.cursor/` on macOS/Linux or `%USERPROFILE%\.cursor\` on Windows. The `Cursor marketplace snapshot` supplies the complete installer and all 24 plugin sources. -**Prerequisites:** Cursor CLI, Git, and Node.js available to Cursor. For project +**Prerequisites:** Cursor CLI, Git, and Bun available to Cursor (install with +`curl -fsSL https://bun.sh/install | bash`). For project scope, detailed installation, and filesystem layouts, see [Installation and scope](docs/getting-started/installation.md). diff --git a/docs/getting-started/installation.md b/docs/getting-started/installation.md index 52564a2..e50d2d0 100644 --- a/docs/getting-started/installation.md +++ b/docs/getting-started/installation.md @@ -5,7 +5,12 @@ - Cursor - Cursor CLI (`agent`) - Git -- Node.js available to Cursor +- Bun — required; `install.sh` / `install.ps1` exec straight into + `.cursor-plugin/scripts/install-hooks.ts`, which deploys the plugins (the + global or project engine under `src/services/`) and then runs the + configuration stage (hooks, `.env`, MCP servers, shell loaders, vendored + harness). Install it with + `curl -fsSL https://bun.sh/install | bash`. ## Marketplace registration and global installation diff --git a/docs/reference/global-installation.md b/docs/reference/global-installation.md index 743a7d2..7368691 100644 --- a/docs/reference/global-installation.md +++ b/docs/reference/global-installation.md @@ -44,6 +44,8 @@ directory. ## Runtime boundary -Global Cursor discovery needs no project `workspaceOpen` loader. Plugin hook commands may invoke -`npx -y @fusengine/harness hook cursor` after discovery. That command executes hook mechanics; it -does not install plugins. The installer does not modify the harness. +Global Cursor discovery needs no project `workspaceOpen` loader. Plugin hook commands invoke +`./scripts/hook.sh`, the wrapper each plugin ships, after discovery. It resolves the harness from +the shared vendored install under `$HOME/.cursor/.fusengine-global`, self-heals it once if missing, +and falls back to pinned `npx`. That command executes hook mechanics; it does not install plugins. +The installer vendors the shared harness but never modifies it. diff --git a/docs/reference/port-audit.md b/docs/reference/port-audit.md index 6623e63..0440587 100644 --- a/docs/reference/port-audit.md +++ b/docs/reference/port-audit.md @@ -19,7 +19,9 @@ maintainers, not installation guidance. validation failure. - MCP matchers use `MCP:` rather than the `mcp____` source form. - Empty matchers are omitted. -- Hook commands use `npx -y @fusengine/harness hook cursor`; this is runtime, not installation. +- Hook commands invoke each plugin's own relative `./scripts/hook.sh`, which resolves the harness + itself (vendored binary → self-heal `bun install` → `npx -y @fusengine/harness hook cursor` as + last resort); this is runtime, not installation. - Cursor-native events must live in Cursor-native hook files rather than compatibility-shaped files. ## Path invariants diff --git a/docs/reference/project-loader.md b/docs/reference/project-loader.md index 1768ee5..93afa9a 100644 --- a/docs/reference/project-loader.md +++ b/docs/reference/project-loader.md @@ -71,11 +71,17 @@ is project-scoped because it lives below the target repository's `.cursor/rules/ Individual plugins carry `hooks/hooks.json`. Their commands invoke: ```text -npx -y @fusengine/harness hook cursor +./scripts/hook.sh ``` -This is runtime execution after Cursor has discovered a plugin. It is not installation. `npx -y` -may download the package on first use and resolves an unpinned published version, so network and +Each plugin ships that wrapper. It resolves the harness itself, in order: the shared vendored +install under `$HOME/.cursor/.fusengine-global`, then a one-shot self-heal `bun install` of that +same directory, then pinned `npx` as a last resort. The path stays relative to the plugin, as the +Cursor submission checklist requires, and the plugin therefore works when installed from the +marketplace, where no installer ever runs. + +This is runtime execution after Cursor has discovered a plugin. It is not installation. The `npx` +fallback may download the package on first use, so network and package availability can affect the first hook execution. The harness is maintained in a separate repository. This installer neither edits nor deploys its diff --git a/docs/reference/responsibility-parity.md b/docs/reference/responsibility-parity.md index bf36cc4..c70bf84 100644 --- a/docs/reference/responsibility-parity.md +++ b/docs/reference/responsibility-parity.md @@ -4,34 +4,35 @@ This matrix verifies that the adjacent Claude implementation was ported by respo used only as a visual reference. `ADAPTED` means the responsibility is present with Cursor-specific mechanics. No Claude path, event name, global setting, or runtime assumption is copied unchanged. -All 22 reviewed responsibilities are listed: 11 are `ADAPTED` and 11 are `NOT APPLICABLE` to this -Cursor installer. A non-applicable row records the verified boundary and user-facing -target instead of silently omitting the source responsibility. +All 22 reviewed responsibilities are listed: 15 are `ADAPTED`, 6 are `NOT APPLICABLE` to this +Cursor installer, and one (`Home placeholder resolution`) is scope-dependent — `ADAPTED` for +global scope, `NOT APPLICABLE` for project scope. A non-applicable row records the verified +boundary and user-facing target instead of silently omitting the source responsibility. | Responsibility | Adjacent Claude source read | Cursor implementation | Status and adaptation | | --- | --- | --- | --- | | Public README role | `../claude-plugins/README.md` | [`README.md`](../../README.md) | **ADAPTED** — concise global quickstart, explicit project alternative, execution flow, and documentation links for Cursor. | | Documentation index and hierarchy | `../claude-plugins/docs/README.md` | [`docs/README.md`](../README.md), [`docs/getting-started/installation.md`](../getting-started/installation.md), [`docs/reference/global-installation.md`](global-installation.md), [`docs/reference/project-loader.md`](project-loader.md) | **ADAPTED** — focused Getting Started, global and project architecture, Compatibility, and plugin documentation sections. | -| Thin root bootstrap | `../claude-plugins/setup.sh` | [`install.sh`](../../install.sh) | **ADAPTED** — checks Node and delegates to the installer service; it does not contain installation logic. | -| Installer service boundaries | `../claude-plugins/scripts/install-hooks.ts`, `../claude-plugins/scripts/src/services/setup-runner.ts` | [`install.mjs`](../../.cursor-plugin/scripts/install.mjs), [`global-install.mjs`](../../.cursor-plugin/scripts/global-install.mjs), [`global-install-transaction.mjs`](../../.cursor-plugin/scripts/global-install-transaction.mjs), [`project-install.mjs`](../../.cursor-plugin/scripts/project-install.mjs) | **ADAPTED** — a thin dispatcher selects global default or explicit project orchestration; guarded file and transaction services remain separate. | -| Marketplace inventory and source resolution | `../claude-plugins/.claude-plugin/marketplace.json`, `../claude-plugins/scripts/src/services/setup-plugins.ts` | [`.cursor-plugin/marketplace.json`](../../.cursor-plugin/marketplace.json), [`global-install.mjs`](../../.cursor-plugin/scripts/global-install.mjs), [`project-plugin-inventory.mjs`](../../.cursor-plugin/scripts/project-plugin-inventory.mjs) | **ADAPTED** — one manifest drives 24 immediate global plugin copies or 24 validated project loader paths; the marketplace root is never treated as one plugin. | -| Hook registration, merge, and ownership | `../claude-plugins/scripts/src/services/settings-manager.ts`, `../claude-plugins/scripts/src/services/hook-merge.ts` | [`project-hooks.mjs`](../../.cursor-plugin/scripts/project-hooks.mjs), [`project-uninstall.mjs`](../../.cursor-plugin/scripts/project-uninstall.mjs) | **ADAPTED** — records whether this installer added an entry, preserves matching foreign entries and multiplicity, and removes at most one exact owned object. | -| Rules delivery and scope | `../claude-plugins/scripts/src/services/setup-plugins.ts` | [`global-install.mjs`](../../.cursor-plugin/scripts/global-install.mjs), [`project-install.mjs`](../../.cursor-plugin/scripts/project-install.mjs) | **ADAPTED** — default installation copies the user rule to `~/.cursor/rules/fuse-global.mdc`; explicit project mode generates `.cursor/rules/fusengine.mdc`. | -| Dry-run, idempotence, and uninstall | `../claude-plugins/scripts/src/__tests__/settings-env-purge-hooks-sandbox.test.ts` | [`global-install.mjs`](../../.cursor-plugin/scripts/global-install.mjs), [`project-install.mjs`](../../.cursor-plugin/scripts/project-install.mjs), [`tests/global-install.test.sh`](../../tests/global-install.test.sh) | **ADAPTED** — both scopes support dry-run, repeat installation, ownership receipts, guarded refresh, and conservative uninstall. | +| Thin root bootstrap | `../claude-plugins/setup.sh` | [`install.sh`](../../install.sh) | **ADAPTED** — checks Bun and delegates to the installer service; it does not contain installation logic. | +| Installer service boundaries | `../claude-plugins/scripts/install-hooks.ts`, `../claude-plugins/scripts/src/services/setup-runner.ts` | [`install-hooks.ts`](../../.cursor-plugin/scripts/install-hooks.ts), [`global-install.ts`](../../.cursor-plugin/scripts/src/services/global-install.ts), [`global-transaction.ts`](../../.cursor-plugin/scripts/src/services/global-transaction.ts), [`project-install.ts`](../../.cursor-plugin/scripts/src/services/project-install.ts) | **ADAPTED** — a thin dispatcher selects global default or explicit project orchestration; guarded file and transaction services remain separate. | +| Marketplace inventory and source resolution | `../claude-plugins/.claude-plugin/marketplace.json`, `../claude-plugins/scripts/src/services/setup-plugins.ts` | [`.cursor-plugin/marketplace.json`](../../.cursor-plugin/marketplace.json), [`global-install.ts`](../../.cursor-plugin/scripts/src/services/global-install.ts), [`marketplace-sources.ts`](../../.cursor-plugin/scripts/src/services/marketplace-sources.ts), [`plugin-inventory.ts`](../../.cursor-plugin/scripts/src/services/plugin-inventory.ts) | **ADAPTED** — one manifest drives 24 immediate global plugin copies or 24 validated project loader paths; the marketplace root is never treated as one plugin. | +| Hook registration, merge, and ownership | `../claude-plugins/scripts/src/services/settings-manager.ts`, `../claude-plugins/scripts/src/services/hook-merge.ts` | [`project-hooks.ts`](../../.cursor-plugin/scripts/src/services/project-hooks.ts), [`project-uninstall.ts`](../../.cursor-plugin/scripts/src/services/project-uninstall.ts) | **ADAPTED** — records whether this installer added an entry, preserves matching foreign entries and multiplicity, and removes at most one exact owned object. | +| Rules delivery and scope | `../claude-plugins/scripts/src/services/setup-plugins.ts` | [`global-install.ts`](../../.cursor-plugin/scripts/src/services/global-install.ts), [`project-install.ts`](../../.cursor-plugin/scripts/src/services/project-install.ts) | **ADAPTED** — default installation copies the user rule to `~/.cursor/rules/fuse-global.mdc`; explicit project mode generates `.cursor/rules/fusengine.mdc`. | +| Dry-run, idempotence, and uninstall | `../claude-plugins/scripts/src/__tests__/settings-env-purge-hooks-sandbox.test.ts` | [`global-install.ts`](../../.cursor-plugin/scripts/src/services/global-install.ts), [`project-install.ts`](../../.cursor-plugin/scripts/src/services/project-install.ts), [`tests/global-install.test.sh`](../../tests/global-install.test.sh) | **ADAPTED** — both scopes support dry-run, repeat installation, ownership receipts, guarded refresh, and conservative uninstall. | | Platform wrappers | `../claude-plugins/setup.sh`, `../claude-plugins/setup.ps1` | [`install.sh`](../../install.sh), [`install.ps1`](../../install.ps1) | **ADAPTED** — both wrappers default to user-global scope and select project scope only when an explicit target is supplied. | -| Validation and tests | `../claude-plugins/scripts/src/__tests__/settings-env-purge-hooks-sandbox.test.ts`, `../claude-plugins/scripts/src/__tests__/settings-manager.load-save.test.ts` | [`tests/global-install.test.sh`](../../tests/global-install.test.sh), [`tests/project-install.test.sh`](../../tests/project-install.test.sh), [`tests/project-install-transaction.test.sh`](../../tests/project-install-transaction.test.sh), [`project-verify.mjs`](../../.cursor-plugin/scripts/project-verify.mjs) | **ADAPTED** — fake-home and temp-project suites exercise real global and project code, containment, ownership, rollback, concurrency, and verification. | +| Validation and tests | `../claude-plugins/scripts/src/__tests__/settings-env-purge-hooks-sandbox.test.ts`, `../claude-plugins/scripts/src/__tests__/settings-manager.load-save.test.ts` | [`tests/global-install.test.sh`](../../tests/global-install.test.sh), [`tests/project-install.test.sh`](../../tests/project-install.test.sh), [`tests/project-install-transaction.test.sh`](../../tests/project-install-transaction.test.sh), [`verify.ts`](../../.cursor-plugin/scripts/verify.ts) | **ADAPTED** — fake-home and temp-project suites exercise real global and project code, containment, ownership, rollback, concurrency, and verification. | | Agent Teams enablement | `../claude-plugins/scripts/src/services/settings-manager.ts` (`enableAgentTeams`) | [`fuse-ai-pilot/agents/`](../../.cursor-plugin/plugins/fuse-ai-pilot/agents/) | **NOT APPLICABLE** — the source toggles a Claude-specific experimental setting; Cursor discovers native project plugin agents without that global flag. | | Status-line settings | `../claude-plugins/scripts/src/services/settings-manager.ts` (`configureStatusLine`) | [`core-guards/README.md`](../../.cursor-plugin/plugins/core-guards/README.md) | **NOT APPLICABLE** — Cursor IDE has no plugin-declared target for the source `statusLine` setting; the verified loss is documented rather than simulated. | | Status-line dependency setup | `../claude-plugins/scripts/src/services/setup-plugins.ts` (`setupStatusline`) | [`core-guards/README.md`](../../.cursor-plugin/plugins/core-guards/README.md) | **NOT APPLICABLE** — the status-line subtree is deliberately absent because no Cursor IDE plugin mechanism can launch it. | | Plugin dependency installation | `../claude-plugins/scripts/src/services/setup-plugins.ts` (`installDeps`) | [`fuse-seo/README.md`](../../.cursor-plugin/plugins/fuse-seo/README.md) | **NOT APPLICABLE** — neither installer scope executes package managers; the one copied Bun subproject documents its explicit manual dependency prerequisite. | -| Environment and API-key setup | `../claude-plugins/scripts/src/services/env-file.ts`, `../claude-plugins/scripts/src/services/shell-config.ts`, `../claude-plugins/scripts/src/services/mcp-key-prompt.ts` | [`docs/getting-started/installation.md`](../getting-started/installation.md), [`core-guards/README.md`](../../.cursor-plugin/plugins/core-guards/README.md) | **NOT APPLICABLE** — plugin installation neither reads secrets nor writes environment or shell files; Cursor hook environment limitations are documented. | -| MCP server setup | `../claude-plugins/scripts/src/services/mcp-setup.ts` | [`docs/reference/migration-compatibility.md`](migration-compatibility.md) | **NOT APPLICABLE** — the adjacent operation installs global servers and keys, while this repository contains no Cursor MCP configuration source to merge and the project installer may not invent or mutate it. | +| Environment and API-key setup | `../claude-plugins/scripts/src/services/env-file.ts`, `../claude-plugins/scripts/src/services/shell-config.ts`, `../claude-plugins/scripts/src/services/mcp-key-prompt.ts` | [`env-file.ts`](../../.cursor-plugin/scripts/src/services/env-file.ts), [`env-manager.ts`](../../.cursor-plugin/scripts/src/services/env-manager.ts), [`mcp-key-prompt.ts`](../../.cursor-plugin/scripts/src/services/mcp-key-prompt.ts) | **ADAPTED** — the configuration stage (`install-hooks.ts` → `runSetup`) writes `FUSE_*` variables to `~/.cursor/.env`, configures shell loaders, and prompts for MCP API keys; global scope only, skippable with `--skip-env`. | +| MCP server setup | `../claude-plugins/scripts/src/services/mcp-setup.ts` | [`mcp-setup.ts`](../../.cursor-plugin/scripts/src/services/mcp-setup.ts), [`mcp-installer.ts`](../../.cursor-plugin/scripts/src/services/mcp-installer.ts), [`mcp-defaults.ts`](../../.cursor-plugin/scripts/src/services/mcp-defaults.ts) | **ADAPTED** — installs/merges selected MCP servers into `~/.cursor/mcp.json` non-destructively; API keys are referenced as `${env:NAME}` and never written in clear. | | Response language and defaults | `../claude-plugins/scripts/src/services/settings-language.ts` (`promptLanguage`, `configureDefaults`) | [`AGENTS.md`](../../AGENTS.md) | **NOT APPLICABLE** — those functions write Claude-specific global language and attribution settings; Cursor project rules already require replying in the owner's language and the installer does not mutate editor preferences. | -| Global instruction delivery | `../claude-plugins/scripts/src/services/setup-plugins.ts` (`installClaudeMd`) | [`global-install.mjs`](../../.cursor-plugin/scripts/global-install.mjs), [`docs/getting-started/installation.md`](../getting-started/installation.md) | **ADAPTED** — the source responsibility maps to Cursor's user-global `~/.cursor/rules/fuse-global.mdc`; explicit project mode remains separately scoped. | -| Home placeholder resolution | `../claude-plugins/scripts/src/services/harness-path-resolver.ts` (`resolveHomeInHooks`) | [`project-install-transaction.mjs`](../../.cursor-plugin/scripts/project-install-transaction.mjs), [`docs/reference/project-loader.md`](project-loader.md) | **NOT APPLICABLE** — installed Cursor hooks contain no `$HOME` placeholder and retain project-relative commands; runtime-specific resolution belongs to the hook runtime. | -| Harness reference environment | `../claude-plugins/scripts/src/services/harness-env.ts` (`setHarnessRefs`) | [`docs/reference/project-loader.md`](project-loader.md) | **NOT APPLICABLE** — the adjacent function writes a user-global harness environment file; this installer is forbidden from configuring the separate harness and only deploys plugin-local references. | -| Performance environment prompts | `../claude-plugins/scripts/src/services/perf-env.ts` (`promptPerfEnv`) | [`core-guards/README.md`](../../.cursor-plugin/plugins/core-guards/README.md) | **NOT APPLICABLE** — every offered variable is Claude-specific and Cursor exposes no equivalent project plugin setting to write. | -| Managed browser binary | `../claude-plugins/scripts/src/services/browser-binary.ts` (`installBrowserBinary`) | [`docs/reference/migration-compatibility.md`](migration-compatibility.md) | **NOT APPLICABLE** — browser installation is conditional on the adjacent global MCP selection; this project installer neither configures global MCP servers nor downloads executables. | +| Global instruction delivery | `../claude-plugins/scripts/src/services/setup-plugins.ts` (`installClaudeMd`) | [`install-hooks.ts`](../../.cursor-plugin/scripts/install-hooks.ts), [`setup-plugins.ts`](../../.cursor-plugin/scripts/src/services/setup-plugins.ts) (`installClaudeMd`) | **ADAPTED** — global instructions ship as `~/.cursor/rules/fuse-global.mdc` (`alwaysApply: true`, hashed in `receipt.json`), written by the deploy engine. No `~/.cursor/AGENTS.md` is produced: verified in Cursor 3.18.25, `AGENTS.md` is only recognised at the root of a workspace folder (`getWorkspaceFolder(uri)` returns false outside one), so a copy under `~/.cursor/` would never be read. The user-rules directory is the documented global channel (`getRuleTargetDirectory` → `joinPath(userHome, ".cursor", "rules")`). | +| Home placeholder resolution | `../claude-plugins/scripts/src/services/harness-path-resolver.ts` (`resolveHomeInHooks`) | [`harness-path-resolver.ts`](../../.cursor-plugin/scripts/src/services/harness-path-resolver.ts) (`resolveHomeInHooks`) | **ADAPTED for global scope, NOT APPLICABLE for project scope** — the configuration stage resolves `$HOME` in copied global plugin hook files; installed project-scope hooks contain no `$HOME` placeholder and retain project-relative commands. | +| Harness reference environment | `../claude-plugins/scripts/src/services/harness-env.ts` (`setHarnessRefs`) | [`harness-env.ts`](../../.cursor-plugin/scripts/src/services/harness-env.ts) (`setHarnessRefs`) | **ADAPTED** — writes `FUSE_HARNESS_REFS` (every plugin's SOLID `references/` dir) to `~/.cursor/.env`, the file the vendored harness loads directly; global scope only. | +| Performance environment prompts | `../claude-plugins/scripts/src/services/perf-env.ts` (`promptPerfEnv`) | [`perf-env.ts`](../../.cursor-plugin/scripts/src/services/perf-env.ts) (`promptPerfEnv`) | **NOT APPLICABLE** — the function is implemented and persists to this installer's own `~/.cursor/.fusengine-global/settings.json`, but every offered variable is Claude-specific; Cursor's CLI and IDE config surfaces expose no equivalent key, so the write is currently inert for Cursor. | +| Managed browser binary | `../claude-plugins/scripts/src/services/browser-binary.ts` (`installBrowserBinary`) | [`browser-binary.ts`](../../.cursor-plugin/scripts/src/services/browser-binary.ts) (`installBrowserBinary`) | **ADAPTED** — conditional on selecting the browser MCP server during the configuration stage, installs the version-matched Chromium build for `fuse-browser`; non-fatal and idempotent. | ## Cursor-specific boundary @@ -41,4 +42,6 @@ its loader returns one absolute path per declared plugin root. Hook runtime may `@fusengine/harness`; that runtime command does not install plugins. The adjacent Claude repository was inspected read-only. Its global `~/.claude` paths, hook event -model, Bun bootstrap, and settings format are deliberately not part of this Cursor port. +model, and settings format are deliberately not part of this Cursor port. This installer now also +runs on Bun (`install.sh`/`install.ps1` exec into `install-hooks.ts`), but that is a parallel, +independently-adopted choice, not a copy of the adjacent repository's bootstrap. diff --git a/install.ps1 b/install.ps1 index 7106894..6373ad3 100644 --- a/install.ps1 +++ b/install.ps1 @@ -1,20 +1,30 @@ -# Install globally by default, or into one explicit Cursor project. -# Usage: .\install.ps1 [-Project ] [-DryRun] [-Uninstall] +# Install the Fusengine plugin suite for Cursor. +# +# install-hooks.ts is the single entry point: it spawns the matching deployment +# engine (global by default, project-local with -Project) and then runs the +# configuration stage - hooks.json, .env, mcp.json, AGENTS.md, shell loaders, +# vendored harness. +# +# Usage: .\install.ps1 [-Project ] [-DryRun] [-Uninstall] [-SkipEnv] param( [string]$Project, [switch]$DryRun, - [switch]$Uninstall + [switch]$Uninstall, + [switch]$SkipEnv ) $ErrorActionPreference = "Stop" $Root = Split-Path -Parent $MyInvocation.MyCommand.Path -if (-not (Get-Command node -ErrorAction SilentlyContinue)) { - throw "node is required because Cursor hooks and the installer use it" + +if (-not (Get-Command bun -ErrorAction SilentlyContinue)) { + throw "bun is required to run the installer. Install it with: powershell -c ""irm bun.sh/install.ps1 | iex""" } -$Arguments = @((Join-Path $Root ".cursor-plugin/scripts/install.mjs")) +$Arguments = @((Join-Path $Root ".cursor-plugin/scripts/install-hooks.ts")) if ($Project) { $Arguments += @("--project", $Project) } if ($DryRun) { $Arguments += "--dry-run" } if ($Uninstall) { $Arguments += "--uninstall" } -& node @Arguments +if ($SkipEnv) { $Arguments += "--skip-env" } + +& bun @Arguments exit $LASTEXITCODE diff --git a/install.sh b/install.sh index 8af8ead..fd7c154 100755 --- a/install.sh +++ b/install.sh @@ -1,11 +1,20 @@ #!/usr/bin/env bash -# Install globally by default, or into one explicit Cursor project. -# Usage: ./install.sh [--dry-run] [--uninstall] [--project ] +# Install the Fusengine plugin suite for Cursor. +# +# install-hooks.ts is the single entry point: it spawns the matching deployment +# engine (global by default, project-local with --project) and then runs the +# configuration stage — hooks.json, .env, mcp.json, AGENTS.md, shell loaders, +# vendored harness. +# +# Usage: ./install.sh [--dry-run] [--uninstall] [--project ] [--skip-env] set -euo pipefail ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)" -command -v node >/dev/null 2>&1 || { - printf 'error: node is required because Cursor hooks and the installer use it\n' >&2 + +command -v bun >/dev/null 2>&1 || { + printf 'error: bun is required to run the installer\n' >&2 + printf ' install it with: curl -fsSL https://bun.sh/install | bash\n' >&2 exit 1 } -exec node "$ROOT/.cursor-plugin/scripts/install.mjs" "$@" + +exec bun "$ROOT/.cursor-plugin/scripts/install-hooks.ts" "$@" diff --git a/tests/global-install.test.sh b/tests/global-install.test.sh index 27f8e02..2a9085e 100755 --- a/tests/global-install.test.sh +++ b/tests/global-install.test.sh @@ -19,9 +19,19 @@ global_install() { HOME="$HOME_DIR" NODE_ENV=test FUSE_INSTALL_TEST_SOURCE_ROOT="$SOURCE" "$ROOT/install.sh" "$@" } -BEFORE_DRY="$(find "$HOME_DIR" -print | sort)" +# Bun keeps two caches under $HOME: transpiled sources (Library/Caches/bun on +# macOS, .cache/bun on Linux) and auto-installed packages ($HOME/.bun/install/ +# cache, populated on a checkout with no node_modules). Both are the runtime's +# own bookkeeping, not installer state. Redirecting them out of the sandbox +# keeps the snapshot below an unscoped assertion over the whole fake HOME +# rather than a narrower one over ~/.cursor alone. +export BUN_RUNTIME_TRANSPILER_CACHE_PATH=0 +export BUN_INSTALL_CACHE_DIR="$TMP/bun-install-cache" +snapshot_home() { find "$HOME_DIR" -print | sort; } +BEFORE_DRY="$(snapshot_home)" (cd "$PROJECT" && global_install --dry-run) -test "$BEFORE_DRY" = "$(find "$HOME_DIR" -print | sort)" +test "$BEFORE_DRY" = "$(snapshot_home)" +test ! -e "$HOME_DIR/.cursor" test ! -e "$PROJECT/.cursor" (cd "$PROJECT" && global_install) @@ -78,11 +88,21 @@ if global_install 2>"$TMP/modified.error"; then exit 1 fi grep -q 'refusing to overwrite modified owned' "$TMP/modified.error" +# Guard the guard: the removal assertion below is only meaningful if the +# configuration stage actually deployed the loader tree here first. Without +# this, `test ! -e .fusengine-global` could pass on a directory that was never +# populated. +test -d "$HOME_DIR/.cursor/.fusengine-global/scripts" global_install --uninstall grep -q USER_PLUGIN_EDIT "$HOME_DIR/.cursor/plugins/local/core-guards/README.md" grep -q USER_RULE_EDIT "$HOME_DIR/.cursor/rules/fuse-global.mdc" test -f "$HOME_DIR/.cursor/plugins/local/foreign-plugin/keep.txt" test ! -e "$HOME_DIR/.cursor/plugins/local/fuse-ai-pilot" +# The control root belongs to the installer in full, so uninstall takes all of +# it: the receipt, the ownership marker, and the vendored hook runtime the +# configuration stage writes under scripts/. Asserting on the whole directory +# rather than on receipt.json alone is deliberate -- the narrower assertion used +# to pass while ~50 loader files stayed behind for good. test ! -e "$HOME_DIR/.cursor/.fusengine-global" COLLISION_HOME="$TMP/collision-home" @@ -105,6 +125,6 @@ if HOME="$SYMLINK_HOME" NODE_ENV=test FUSE_INSTALL_TEST_SOURCE_ROOT="$SOURCE" "$ fi test -z "$(find "$EXTERNAL" -mindepth 1 -print -quit)" -grep -q '.cursor-plugin/scripts/install.mjs' "$ROOT/install.ps1" +grep -q '.cursor-plugin/scripts/install-hooks.ts' "$ROOT/install.ps1" grep -Fq "if (\$Project)" "$ROOT/install.ps1" printf 'PASS default global install, refresh, rollback, refusal, and uninstall\n' diff --git a/tests/marketplace-sparse-install.test.sh b/tests/marketplace-sparse-install.test.sh index 295fc2d..e5833a3 100755 --- a/tests/marketplace-sparse-install.test.sh +++ b/tests/marketplace-sparse-install.test.sh @@ -16,11 +16,17 @@ IGNORED_PATHS="$TMP/ignored-paths" FIXTURE_PATHS="$TMP/fixture-paths" mkdir -p "$ORIGIN_WORK" "$FAKE_HOME" "$PROJECT" "$BASELINE" -if (cd "$ROOT" && find .cursor-plugin/plugins .cursor-plugin/scripts -type l -print -quit | grep -q .); then - printf 'FAIL nested marketplace material contains a symlink\n' >&2 +# Scoped to git-TRACKED material only: a cone-sparse checkout ships exactly +# `git ls-files`, never whatever a prior `bun install` happened to leave on +# this dev machine's disk (node_modules/, .harness/, .impeccable/, .DS_Store +# are all gitignored on purpose -- `bun install` regenerates node_modules, +# see ensure-harness-deps.ts). A raw `find` over the working tree would flag +# that untracked, intentionally-ignored runtime cruft as a false failure. +if git -C "$ROOT" ls-files -s .cursor-plugin/plugins .cursor-plugin/scripts | grep -q '^120000'; then + printf 'FAIL nested marketplace material contains a tracked symlink\n' >&2 exit 1 fi -(cd "$ROOT" && find .cursor-plugin/plugins .cursor-plugin/scripts -type f -print | sed 's#^./##' | sort) > "$REQUIRED_PATHS" +git -C "$ROOT" ls-files .cursor-plugin/plugins .cursor-plugin/scripts | sort > "$REQUIRED_PATHS" if git -C "$ROOT" check-ignore --no-index --stdin < "$REQUIRED_PATHS" > "$IGNORED_PATHS"; then printf 'FAIL nested marketplace material is ignored:\n' >&2 cat "$IGNORED_PATHS" >&2 @@ -80,7 +86,7 @@ for (const relative of priorRuntime) { const nestedFile = path.join(root, '.cursor-plugin/scripts', relative); requireRegular(nestedFile); } -requireRegular(path.join(root, '.cursor-plugin/scripts/install.mjs')); +requireRegular(path.join(root, '.cursor-plugin/scripts/install-hooks.ts')); NODE cp -R "$ROOT/.cursor-plugin" "$ORIGIN_WORK/.cursor-plugin" @@ -105,8 +111,8 @@ git -C "$CACHE" sparse-checkout set .cursor-plugin git -C "$CACHE" checkout -q --detach HEAD test ! -e "$CACHE/scripts" -if ! test -f "$CACHE/.cursor-plugin/scripts/install.mjs"; then - printf 'FAIL sparse checkout omits .cursor-plugin/scripts/install.mjs\n' >&2 +if ! test -f "$CACHE/.cursor-plugin/scripts/install-hooks.ts"; then + printf 'FAIL sparse checkout omits .cursor-plugin/scripts/install-hooks.ts\n' >&2 exit 1 fi diff --git a/tests/project-install.test.sh b/tests/project-install.test.sh index 16a6526..a457072 100755 --- a/tests/project-install.test.sh +++ b/tests/project-install.test.sh @@ -20,6 +20,14 @@ cat > "$PROJECT/.cursor/hooks.json" <<'JSON' } JSON printf 'keep\n' > "$PROJECT/.cursor/keep.txt" +# Bun keeps two caches under $HOME: transpiled sources (Library/Caches/bun on +# macOS, .cache/bun on Linux) and auto-installed packages ($HOME/.bun/install/ +# cache, populated on a checkout with no node_modules). Both are the runtime's +# own bookkeeping, not installer output, and either would defeat the "project +# install writes nothing to HOME" assertion below. Redirect them out of the +# sandbox so the assertion stays a strict, unscoped snapshot of $FAKE_HOME. +export BUN_RUNTIME_TRANSPILER_CACHE_PATH=0 +export BUN_INSTALL_CACHE_DIR="$TMP/bun-install-cache" HOME_BEFORE="$(find "$FAKE_HOME" -print | sort)" HOME="$FAKE_HOME" "$ROOT/install.sh" --project "$PROJECT" @@ -107,7 +115,7 @@ HOME="$FAKE_HOME" "$ROOT/install.sh" --project "$PROJECT" >/dev/null test -f "$TMP/external-plugins/core-guards/.cursor-plugin/plugin.json" printf 'PASS loader rejects external plugin symlink escapes\n' -grep -q 'install.mjs' "$ROOT/install.ps1" +grep -q 'install-hooks.ts' "$ROOT/install.ps1" grep -q -- '--project' "$ROOT/install.ps1" grep -q 'DryRun' "$ROOT/install.ps1" grep -q 'Uninstall' "$ROOT/install.ps1" diff --git a/tests/project-rule-update.test.sh b/tests/project-rule-update.test.sh index 81d0422..be4899f 100755 --- a/tests/project-rule-update.test.sh +++ b/tests/project-rule-update.test.sh @@ -8,8 +8,17 @@ trap 'rm -rf "$TMP"' EXIT PROJECT="$TMP/project" FAKE_HOME="$TMP/home" mkdir -p "$PROJECT" "$FAKE_HOME" +# $FAKE_HOME stays deliberately empty: `install.sh --project` must succeed on a +# fresh HOME, without any global install having ever run. -if rg -n 'fs\.writeFileSync\((rulePath|hooksPath)' "$ROOT/.cursor-plugin/scripts/project-install.mjs"; then +INSTALLER="$ROOT/.cursor-plugin/scripts/src/services/project-install.ts" +# Guard the guard: a missing target would make `rg` fail the same way as "no +# match", silently turning this check into a no-op. +if [ ! -f "$INSTALLER" ]; then + printf 'missing installer under test: %s\n' "$INSTALLER" >&2 + exit 1 +fi +if rg -n 'writeFileSync\((rulePath|hooksPath)' "$INSTALLER"; then printf 'direct finalization writes remain\n' >&2 exit 1 fi diff --git a/tests/project-uninstall-regressions.test.sh b/tests/project-uninstall-regressions.test.sh index f9eef9a..500aac6 100755 --- a/tests/project-uninstall-regressions.test.sh +++ b/tests/project-uninstall-regressions.test.sh @@ -7,6 +7,8 @@ TMP="$(mktemp -d)" trap 'rm -rf "$TMP"' EXIT FAKE_HOME="$TMP/home" mkdir -p "$FAKE_HOME" +# $FAKE_HOME stays deliberately empty: `install.sh --project` must succeed on a +# fresh HOME, without any global install having ever run. MULTI="$TMP/multiple-hooks" mkdir -p "$MULTI/.cursor" diff --git a/verify-project.sh b/verify-project.sh index 9399fb2..cbef132 100755 --- a/verify-project.sh +++ b/verify-project.sh @@ -4,8 +4,9 @@ set -euo pipefail ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)" -command -v node >/dev/null 2>&1 || { - printf 'error: node is required\n' >&2 +command -v bun >/dev/null 2>&1 || { + printf 'error: bun is required\n' >&2 + printf ' install it with: curl -fsSL https://bun.sh/install | bash\n' >&2 exit 1 } -exec node "$ROOT/.cursor-plugin/scripts/project-verify.mjs" "$@" +exec bun "$ROOT/.cursor-plugin/scripts/verify.ts" "$@" diff --git a/verify.sh b/verify.sh index 3e4bb1d..311bed7 100755 --- a/verify.sh +++ b/verify.sh @@ -24,7 +24,6 @@ esac ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)" LOCAL_DIR="$HOME/.cursor/plugins/local" -LINK_NAME="fusengine" MARKETPLACE="$ROOT/.cursor-plugin/marketplace.json" EXPECTED_PLUGINS=24 SOLID_MAX_LINES="${FUSE_SOLID_MAX_LINES:-200}" @@ -452,48 +451,34 @@ if [ "$REPOSITORY_ONLY" = true ]; then pass "repository-only mode — installed Cursor configuration checks skipped" else -resolve_link() { - local l="$1" t - t="$(readlink "$l")" || return 1 - case "$t" in - /*) : ;; - *) t="$(dirname "$l")/$t" ;; - esac - ( cd "$t" >/dev/null 2>&1 && pwd -P ) || printf '%s' "$t" -} - +# install.sh's deployment engine (.cursor-plugin/scripts/src/services/global-install.ts) +# lays each marketplace plugin out as an immediate flat child of $LOCAL_DIR — it never +# symlinks the repo in. Check that layout instead of a symlink. if [ ! -d "$LOCAL_DIR" ]; then fail "$LOCAL_DIR does not exist — the marketplace is not installed. Run ./install.sh" -else - linked_as="" - wrong_target="" - for entry in "$LOCAL_DIR"/*; do - [ -L "$entry" ] || continue - target="$(resolve_link "$entry" || true)" - if [ "$target" = "$ROOT" ]; then - linked_as="$(basename "$entry")" - break - fi - if [ "$(basename "$entry")" = "$LINK_NAME" ]; then - wrong_target="$target" - fi - done - if [ -n "$linked_as" ]; then - pass "$LOCAL_DIR/$linked_as is a symlink to this repo" - [ "$linked_as" = "$LINK_NAME" ] || warn "installed under the name '$linked_as', install.sh uses '$LINK_NAME' — harmless, but two runs would create two entries" - elif [ -n "$wrong_target" ]; then - fail "$LOCAL_DIR/$LINK_NAME points to ${wrong_target:-}, not to $ROOT" - elif [ -e "$LOCAL_DIR/$LINK_NAME" ]; then - fail "$LOCAL_DIR/$LINK_NAME exists but is not a symlink to this repo" +elif command -v node >/dev/null 2>&1; then + missing="$(FUSE_MARKETPLACE="$MARKETPLACE" FUSE_LOCAL="$LOCAL_DIR" node -e ' + const fs = require("fs"); + const path = require("path"); + const market = JSON.parse(fs.readFileSync(process.env.FUSE_MARKETPLACE, "utf8")); + const missing = market.plugins + .map((p) => p.name) + .filter((name) => !fs.existsSync(path.join(process.env.FUSE_LOCAL, name, ".cursor-plugin", "plugin.json"))); + process.stdout.write(missing.join(" ")); + ')" + if [ -z "$missing" ]; then + pass "$LOCAL_DIR holds a flat copy of every marketplace plugin" else - fail "no symlink in $LOCAL_DIR resolves to $ROOT — the marketplace is not installed. Run ./install.sh" + fail "$LOCAL_DIR is missing plugin copies: $missing — Run ./install.sh" fi +else + warn "node not on PATH — skipped the flat plugin-copy check under $LOCAL_DIR" fi if command -v npx >/dev/null 2>&1; then - pass "npx on PATH — hooks can run 'npx -y @fusengine/harness hook cursor'" + pass "npx on PATH — the hook.sh wrapper can fall back to 'npx -y @fusengine/harness hook cursor'" else - fail "npx not on PATH — every hook command would fail" + fail "npx not on PATH — the hook.sh fallback would fail if the vendored harness is missing" fi RULE_SRC="$ROOT/.cursor-plugin/plugins/fuse-rules/user-rules/fuse-global.mdc" @@ -505,7 +490,9 @@ elif [ ! -e "$RULE_DST" ]; then elif diff -q "$RULE_SRC" "$RULE_DST" >/dev/null 2>&1; then pass "$RULE_DST is identical to the repo copy" else - fail "$RULE_DST differs from $RULE_SRC — stale or hand-edited. Run ./install.sh --force to replace it." + fail "$RULE_DST differs from $RULE_SRC — stale or hand-edited." + printf ' global-install.ts refuses to overwrite a modified owned rule; run\n' + printf ' ./install.sh --uninstall then ./install.sh to replace it.\n' diff "$RULE_SRC" "$RULE_DST" | head -20 | sed 's/^/ /' fi fi