From cde748934d672a8359225183ce201a4b18cd5545 Mon Sep 17 00:00:00 2001 From: Bruno Azoulay Date: Tue, 1 Sep 2026 21:36:15 +0200 Subject: [PATCH 01/12] refactor(installer): port installer engine to TypeScript/bun Replace the 20 Node .mjs installer scripts under .cursor-plugin/scripts/ with a TypeScript/bun port of the Claude Code installer, cloned then adapted rather than rewritten from scratch. Single entry point install-hooks.ts (bun) spawns the deployment engine (global-install.ts / project-install.ts) then chains configuration (runSetup). Prerequisite changes from Node.js to Bun; install.sh, install.ps1 and verify-project.sh are recabled accordingly. Preserved: --dry-run, --uninstall, --project , --skip-env, transactional deploy with rollback, per-plugin hashed receipt.json, preflightOwned, install lock, anti-symlink guard. Cursor hooks: all 62 hook commands switched to relative path ./scripts/hook.sh (marketplace submission requirement), with 21 embedded wrappers that resolve the shared harness at runtime (vendored binary -> self-heal bun install -> npx). Events verified camelCase against Cursor CLI 3.18.25. Vendored harness manifest and wrappers pinned to the latest tag. Fixes: mcp-installer now writes ~/.cursor/mcp.json (was shelling out to Claude Code's `claude mcp add-json`); hook configuration now targets ~/.cursor/hooks.json (was writing to a settings.json Cursor never reads); global instructions now ship as ~/.cursor/rules/fuse-global.mdc instead of ~/.cursor/AGENTS.md (Cursor only recognises AGENTS.md at a workspace root); FUSE_HARNESS_REFS scan depth corrected; loader now copied with its full src/ tree; spawnSync reverted to a hardcoded "node" where process.execPath now resolves to bun. Adds .gitignore (node_modules ignored, bun.lock versioned) and missing frontmatter on 12 plugin rule files. Validated: bunx tsc --noEmit clean, bun test 182/182 pass, and a real install exercised in a sandboxed HOME (24 plugins deployed, 24-hash receipt + ruleHash, fuse-global.mdc deployed with alwaysApply: true, loader starts from its destination, 21 wrappers executable, --dry-run writes nothing). --- .codex/apex/task.json | 14 +- .cursor-plugin/harness/package.json | 9 + .cursor-plugin/marketplace.json | 2 +- .cursor-plugin/mcp/catalog.json | 237 ++++++++++++++++ .cursor-plugin/plugin-runtime/hook.sh | 45 +++ .../load-project-plugins.mjs | 0 .../marketplace-sources.mjs | 0 .../project-plugin-inventory.mjs | 0 .../plugins/core-guards/hooks/hooks.json | 22 +- .../plugins/core-guards/scripts/hook.sh | 45 +++ .../plugins/fuse-ai-pilot/hooks/hooks.json | 10 +- .../plugins/fuse-ai-pilot/scripts/hook.sh | 45 +++ .../fuse-cartographer/agents/cartographer.md | 3 +- .../fuse-cartographer/hooks/hooks.json | 4 +- .../plugins/fuse-cartographer/scripts/hook.sh | 45 +++ .../plugins/fuse-changelog/hooks/hooks.json | 4 +- .../plugins/fuse-changelog/scripts/hook.sh | 45 +++ .../fuse-commit-pro/agents/commit-detector.md | 3 +- .../plugins/fuse-design/hooks/hooks.json | 10 +- .../plugins/fuse-design/rules/design-rules.md | 5 + .../rules/framework-integration.md | 5 + .../fuse-design/rules/gemini-design.md | 5 + .../plugins/fuse-design/scripts/hook.sh | 45 +++ .../plugins/fuse-go/hooks/hooks.json | 4 +- .../plugins/fuse-go/scripts/hook.sh | 45 +++ .../plugins/fuse-laravel/hooks/hooks.json | 4 +- .../plugins/fuse-laravel/scripts/hook.sh | 45 +++ .../fuse-lessons/.cursor-plugin/plugin.json | 3 +- .../plugins/fuse-lessons/hooks/hooks.json | 10 +- .../plugins/fuse-lessons/scripts/hook.sh | 45 +++ .../plugins/fuse-nextjs/hooks/hooks.json | 4 +- .../plugins/fuse-nextjs/scripts/hook.sh | 45 +++ .../plugins/fuse-php/hooks/hooks.json | 4 +- .../plugins/fuse-php/scripts/hook.sh | 45 +++ .../plugins/fuse-react/hooks/hooks.json | 4 +- .../plugins/fuse-react/scripts/hook.sh | 45 +++ .../plugins/fuse-rules/hooks/hooks.json | 6 +- .../fuse-rules/rules/00-critical-rules.md | 5 + .../fuse-rules/rules/01-project-detection.md | 5 + .../fuse-rules/rules/02-apex-workflow.md | 5 + .../fuse-rules/rules/03-agent-teams.md | 5 + .../fuse-rules/rules/04-solid-dry-rules.md | 5 + .../fuse-rules/rules/05-frontend-rules.md | 5 + .../fuse-rules/rules/06-tooling-rules.md | 5 + .../fuse-rules/rules/07-state-management.md | 5 + .../fuse-rules/rules/08-subagent-conduct.md | 5 + .../plugins/fuse-rules/scripts/hook.sh | 45 +++ .../plugins/fuse-rust/hooks/hooks.json | 4 +- .../plugins/fuse-rust/scripts/hook.sh | 45 +++ .../plugins/fuse-security/hooks/hooks.json | 6 +- .../plugins/fuse-security/scripts/hook.sh | 45 +++ .../fuse-seo/.cursor-plugin/plugin.json | 3 +- .../plugins/fuse-seo/hooks/hooks.json | 2 +- .../plugins/fuse-seo/scripts/hook.sh | 45 +++ .../plugins/fuse-shadcn-ui/hooks/hooks.json | 4 +- .../plugins/fuse-shadcn-ui/scripts/hook.sh | 45 +++ .../plugins/fuse-solid/hooks/hooks.json | 6 +- .../plugins/fuse-solid/scripts/hook.sh | 45 +++ .../fuse-swift-apple-expert/hooks/hooks.json | 4 +- .../fuse-swift-apple-expert/scripts/hook.sh | 45 +++ .../plugins/fuse-tailwindcss/hooks/hooks.json | 4 +- .../plugins/fuse-tailwindcss/scripts/hook.sh | 45 +++ .../fuse-tanstack-start/hooks/hooks.json | 4 +- .../fuse-tanstack-start/scripts/hook.sh | 45 +++ .../plugins/fuse-typescript/hooks/hooks.json | 4 +- .../plugins/fuse-typescript/scripts/hook.sh | 45 +++ .cursor-plugin/scripts/bun.lock | 36 +++ .cursor-plugin/scripts/ensure-harness-deps.ts | 24 ++ .../scripts/env-shell/bash-env-loader.sh | 33 +++ .../scripts/env-shell/cursor-env.bash | 33 +++ .../scripts/env-shell/cursor-env.fish | 37 +++ .../scripts/env-shell/cursor-env.ps1 | 24 ++ .../scripts/env-shell/cursor-env.zsh | 33 +++ .../scripts/env-shell/install-env.ps1 | 68 +++++ .../scripts/env-shell/install-env.sh | 247 +++++++++++++++++ .cursor-plugin/scripts/global-artifacts.mjs | 32 --- .../scripts/global-install-transaction.mjs | 123 --------- .cursor-plugin/scripts/global-install.mjs | 160 ----------- .cursor-plugin/scripts/hooks-loader.ts | 95 +++++++ .cursor-plugin/scripts/install-hooks.ts | 68 +++++ .cursor-plugin/scripts/install.mjs | 4 - .../scripts/load-project-plugins.ts | 15 + .cursor-plugin/scripts/mcp/mcp.json | 235 ++++++++++++++++ .cursor-plugin/scripts/package.json | 21 ++ .cursor-plugin/scripts/project-hooks.mjs | 56 ---- .../scripts/project-install-files.d.mts | 9 + .../scripts/project-install-files.mjs | 54 ---- .../scripts/project-install-transaction.mjs | 124 --------- .cursor-plugin/scripts/project-install.mjs | 156 ----------- .cursor-plugin/scripts/project-uninstall.mjs | 97 ------- .cursor-plugin/scripts/project-verify.mjs | 51 ---- .../scripts/src/__tests__/api-keys.test.ts | 70 +++++ .../src/__tests__/deps-targets.test.ts | 39 +++ .../scripts/src/__tests__/ensure-deps.test.ts | 105 +++++++ .../src/__tests__/ensure-harness-deps.test.ts | 29 ++ .../src/__tests__/env-file-load.test.ts | 79 ++++++ .../__tests__/env-file-writer-sandbox.test.ts | 197 +++++++++++++ .../env-loader-fuse-filter-sandbox.test.ts | 106 +++++++ .../scripts/src/__tests__/env-manager.test.ts | 70 +++++ .../src/__tests__/fixtures/env-writer-cli.ts | 29 ++ .../__tests__/fixtures/fish-install-cli.ts | 14 + .../src/__tests__/fixtures/hook-fixture.mjs | 38 +++ .../scripts/src/__tests__/fs-helpers.test.ts | 203 ++++++++++++++ .../__tests__/harness-path-resolver.test.ts | 52 ++++ .../src/__tests__/helpers/hook-fixture.ts | 30 ++ .../__tests__/helpers/shell-loader-probe.ts | 98 +++++++ .../__tests__/hook-executor-collect.test.ts | 64 +++++ .../hook-executor-equivalence.test.ts | 51 ++++ .../src/__tests__/hook-executor.test.ts | 82 ++++++ .../scripts/src/__tests__/hook-merge.test.ts | 96 +++++++ .../hooks-harness-exclusive.helpers.ts | 18 ++ .../__tests__/hooks-harness-exclusive.test.ts | 96 +++++++ .../__tests__/install-hooks-config.test.ts | 150 ++++++++++ .../src/__tests__/install-hooks.test.ts | 99 +++++++ .../loader-self-heal-integration.test.ts | 78 ++++++ .../scripts/src/__tests__/mcp-catalog.test.ts | 28 ++ .../src/__tests__/mcp-defaults.test.ts | 40 +++ .../src/__tests__/mcp-installer.test.ts | 73 +++++ .../src/__tests__/mcp-test-fixtures.ts | 31 +++ .../plugin-scanner-home-resolution.test.ts | 61 ++++ .../src/__tests__/plugin-scanner.test.ts | 261 ++++++++++++++++++ .../settings-env-purge-hooks-sandbox.test.ts | 92 ++++++ .../settings-manager.configure.test.ts | 103 +++++++ .../settings-manager.load-save.test.ts | 110 ++++++++ .../src/__tests__/settings-manager.test.ts | 11 + .../src/__tests__/single-flight-lock.test.ts | 68 +++++ .cursor-plugin/scripts/src/config/api-keys.ts | 72 +++++ .../scripts/src/interfaces/ensure-deps.ts | 17 ++ .cursor-plugin/scripts/src/interfaces/env.ts | 8 + .../scripts/src/interfaces/global-install.ts | 80 ++++++ .../scripts/src/interfaces/hook-types.ts | 55 ++++ .../scripts/src/interfaces/hooks.ts | 72 +++++ .../scripts/src/interfaces/marketplace.ts | 39 +++ .cursor-plugin/scripts/src/interfaces/mcp.ts | 57 ++++ .../scripts/src/interfaces/project-install.ts | 116 ++++++++ .../scripts/src/interfaces/setup.ts | 18 ++ .../scripts/src/interfaces/verify.ts | 23 ++ .../scripts/src/services/browser-binary.ts | 37 +++ .../scripts/src/services/deps-targets.ts | 33 +++ .../scripts/src/services/enforce-ttl.ts | 31 +++ .../scripts/src/services/ensure-deps.ts | 48 ++++ .../scripts/src/services/env-file.ts | 96 +++++++ .../scripts/src/services/env-manager.ts | 10 + .../scripts/src/services/env-route.ts | 35 +++ .../scripts/src/services/global-artifacts.ts | 49 ++++ .../scripts/src/services/global-install.ts | 156 +++++++++++ .../scripts/src/services/global-inventory.ts | 27 ++ .../scripts/src/services/global-paths.ts | 52 ++++ .../scripts/src/services/global-receipt.ts | 61 ++++ .../src/services/global-transaction.ts | 167 +++++++++++ .../scripts/src/services/harness-env.ts | 51 ++++ .../scripts/src/services/harness-gates.ts | 57 ++++ .../src/services/harness-path-resolver.ts | 45 +++ .../scripts/src/services/harness-tuning.ts | 69 +++++ .../scripts/src/services/hook-executor.ts | 200 ++++++++++++++ .../scripts/src/services/hook-merge.ts | 68 +++++ .../src/services/load-project-plugins.ts | 25 ++ .../src/services/marketplace-sources.ts | 66 +++++ .../scripts/src/services/mcp-config.ts | 68 +++++ .../scripts/src/services/mcp-defaults.ts | 54 ++++ .../scripts/src/services/mcp-installer.ts | 88 ++++++ .../scripts/src/services/mcp-key-prompt.ts | 45 +++ .../scripts/src/services/mcp-setup.ts | 71 +++++ .../scripts/src/services/merge-utils.ts | 54 ++++ .../scripts/src/services/perf-env.ts | 171 ++++++++++++ .../scripts/src/services/plugin-inventory.ts | 63 +++++ .../scripts/src/services/plugin-scanner.ts | 96 +++++++ .../scripts/src/services/project-files.ts | 78 ++++++ .../scripts/src/services/project-hooks.ts | 87 ++++++ .../scripts/src/services/project-install.ts | 167 +++++++++++ .../src/services/project-transaction.ts | 162 +++++++++++ .../scripts/src/services/project-uninstall.ts | 118 ++++++++ .../scripts/src/services/project-verify.ts | 71 +++++ .../src/services/settings-env-purge.ts | 24 ++ .../scripts/src/services/settings-language.ts | 54 ++++ .../scripts/src/services/settings-manager.ts | 163 +++++++++++ .../scripts/src/services/setup-plugins.ts | 81 ++++++ .../scripts/src/services/setup-runner.ts | 98 +++++++ .../scripts/src/services/shell-config.ts | 42 +++ .../scripts/src/services/shell-detection.ts | 64 +++++ .../scripts/src/services/shell-installers.ts | 76 +++++ .../scripts/src/services/solid-lines.ts | 31 +++ .../scripts/src/utils/bun-install-runner.ts | 24 ++ .../scripts/src/utils/command-parser.ts | 28 ++ .../scripts/src/utils/dep-scanner.ts | 42 +++ .../scripts/src/utils/fs-helpers.ts | 88 ++++++ .../scripts/src/utils/hooks-discovery.ts | 22 ++ .../scripts/src/utils/single-flight-lock.ts | 64 +++++ .cursor-plugin/scripts/tsconfig.json | 14 + .cursor-plugin/scripts/validate-agents.sh | 136 +++++++++ .cursor-plugin/scripts/verify.ts | 19 ++ .gitignore | 29 ++ CHANGELOG.md | 25 ++ MEMORY/LESSON.md | 12 + README.md | 5 +- docs/getting-started/installation.md | 7 +- docs/reference/global-installation.md | 8 +- docs/reference/port-audit.md | 4 +- docs/reference/project-loader.md | 12 +- docs/reference/responsibility-parity.md | 39 +-- install.ps1 | 24 +- install.sh | 19 +- tests/project-rule-update.test.sh | 9 +- verify-project.sh | 7 +- verify.sh | 59 ++-- 205 files changed, 9707 insertions(+), 1009 deletions(-) create mode 100644 .cursor-plugin/harness/package.json create mode 100644 .cursor-plugin/mcp/catalog.json create mode 100644 .cursor-plugin/plugin-runtime/hook.sh rename .cursor-plugin/{scripts => plugin-runtime}/load-project-plugins.mjs (100%) mode change 100755 => 100644 rename .cursor-plugin/{scripts => plugin-runtime}/marketplace-sources.mjs (100%) rename .cursor-plugin/{scripts => plugin-runtime}/project-plugin-inventory.mjs (100%) create mode 100755 .cursor-plugin/plugins/core-guards/scripts/hook.sh create mode 100755 .cursor-plugin/plugins/fuse-ai-pilot/scripts/hook.sh create mode 100755 .cursor-plugin/plugins/fuse-cartographer/scripts/hook.sh create mode 100755 .cursor-plugin/plugins/fuse-changelog/scripts/hook.sh create mode 100755 .cursor-plugin/plugins/fuse-design/scripts/hook.sh create mode 100755 .cursor-plugin/plugins/fuse-go/scripts/hook.sh create mode 100755 .cursor-plugin/plugins/fuse-laravel/scripts/hook.sh create mode 100755 .cursor-plugin/plugins/fuse-lessons/scripts/hook.sh create mode 100755 .cursor-plugin/plugins/fuse-nextjs/scripts/hook.sh create mode 100755 .cursor-plugin/plugins/fuse-php/scripts/hook.sh create mode 100755 .cursor-plugin/plugins/fuse-react/scripts/hook.sh create mode 100755 .cursor-plugin/plugins/fuse-rules/scripts/hook.sh create mode 100755 .cursor-plugin/plugins/fuse-rust/scripts/hook.sh create mode 100755 .cursor-plugin/plugins/fuse-security/scripts/hook.sh create mode 100755 .cursor-plugin/plugins/fuse-seo/scripts/hook.sh create mode 100755 .cursor-plugin/plugins/fuse-shadcn-ui/scripts/hook.sh create mode 100755 .cursor-plugin/plugins/fuse-solid/scripts/hook.sh create mode 100755 .cursor-plugin/plugins/fuse-swift-apple-expert/scripts/hook.sh create mode 100755 .cursor-plugin/plugins/fuse-tailwindcss/scripts/hook.sh create mode 100755 .cursor-plugin/plugins/fuse-tanstack-start/scripts/hook.sh create mode 100755 .cursor-plugin/plugins/fuse-typescript/scripts/hook.sh create mode 100644 .cursor-plugin/scripts/bun.lock create mode 100644 .cursor-plugin/scripts/ensure-harness-deps.ts create mode 100644 .cursor-plugin/scripts/env-shell/bash-env-loader.sh create mode 100644 .cursor-plugin/scripts/env-shell/cursor-env.bash create mode 100644 .cursor-plugin/scripts/env-shell/cursor-env.fish create mode 100644 .cursor-plugin/scripts/env-shell/cursor-env.ps1 create mode 100644 .cursor-plugin/scripts/env-shell/cursor-env.zsh create mode 100644 .cursor-plugin/scripts/env-shell/install-env.ps1 create mode 100755 .cursor-plugin/scripts/env-shell/install-env.sh delete mode 100644 .cursor-plugin/scripts/global-artifacts.mjs delete mode 100644 .cursor-plugin/scripts/global-install-transaction.mjs delete mode 100644 .cursor-plugin/scripts/global-install.mjs create mode 100755 .cursor-plugin/scripts/hooks-loader.ts create mode 100755 .cursor-plugin/scripts/install-hooks.ts delete mode 100644 .cursor-plugin/scripts/install.mjs create mode 100755 .cursor-plugin/scripts/load-project-plugins.ts create mode 100644 .cursor-plugin/scripts/mcp/mcp.json create mode 100644 .cursor-plugin/scripts/package.json delete mode 100644 .cursor-plugin/scripts/project-hooks.mjs create mode 100644 .cursor-plugin/scripts/project-install-files.d.mts delete mode 100644 .cursor-plugin/scripts/project-install-files.mjs delete mode 100644 .cursor-plugin/scripts/project-install-transaction.mjs delete mode 100755 .cursor-plugin/scripts/project-install.mjs delete mode 100644 .cursor-plugin/scripts/project-uninstall.mjs delete mode 100755 .cursor-plugin/scripts/project-verify.mjs create mode 100644 .cursor-plugin/scripts/src/__tests__/api-keys.test.ts create mode 100644 .cursor-plugin/scripts/src/__tests__/deps-targets.test.ts create mode 100644 .cursor-plugin/scripts/src/__tests__/ensure-deps.test.ts create mode 100644 .cursor-plugin/scripts/src/__tests__/ensure-harness-deps.test.ts create mode 100644 .cursor-plugin/scripts/src/__tests__/env-file-load.test.ts create mode 100644 .cursor-plugin/scripts/src/__tests__/env-file-writer-sandbox.test.ts create mode 100644 .cursor-plugin/scripts/src/__tests__/env-loader-fuse-filter-sandbox.test.ts create mode 100644 .cursor-plugin/scripts/src/__tests__/env-manager.test.ts create mode 100644 .cursor-plugin/scripts/src/__tests__/fixtures/env-writer-cli.ts create mode 100644 .cursor-plugin/scripts/src/__tests__/fixtures/fish-install-cli.ts create mode 100644 .cursor-plugin/scripts/src/__tests__/fixtures/hook-fixture.mjs create mode 100644 .cursor-plugin/scripts/src/__tests__/fs-helpers.test.ts create mode 100644 .cursor-plugin/scripts/src/__tests__/harness-path-resolver.test.ts create mode 100644 .cursor-plugin/scripts/src/__tests__/helpers/hook-fixture.ts create mode 100644 .cursor-plugin/scripts/src/__tests__/helpers/shell-loader-probe.ts create mode 100644 .cursor-plugin/scripts/src/__tests__/hook-executor-collect.test.ts create mode 100644 .cursor-plugin/scripts/src/__tests__/hook-executor-equivalence.test.ts create mode 100644 .cursor-plugin/scripts/src/__tests__/hook-executor.test.ts create mode 100644 .cursor-plugin/scripts/src/__tests__/hook-merge.test.ts create mode 100644 .cursor-plugin/scripts/src/__tests__/hooks-harness-exclusive.helpers.ts create mode 100644 .cursor-plugin/scripts/src/__tests__/hooks-harness-exclusive.test.ts create mode 100644 .cursor-plugin/scripts/src/__tests__/install-hooks-config.test.ts create mode 100644 .cursor-plugin/scripts/src/__tests__/install-hooks.test.ts create mode 100644 .cursor-plugin/scripts/src/__tests__/loader-self-heal-integration.test.ts create mode 100644 .cursor-plugin/scripts/src/__tests__/mcp-catalog.test.ts create mode 100644 .cursor-plugin/scripts/src/__tests__/mcp-defaults.test.ts create mode 100644 .cursor-plugin/scripts/src/__tests__/mcp-installer.test.ts create mode 100644 .cursor-plugin/scripts/src/__tests__/mcp-test-fixtures.ts create mode 100644 .cursor-plugin/scripts/src/__tests__/plugin-scanner-home-resolution.test.ts create mode 100644 .cursor-plugin/scripts/src/__tests__/plugin-scanner.test.ts create mode 100644 .cursor-plugin/scripts/src/__tests__/settings-env-purge-hooks-sandbox.test.ts create mode 100644 .cursor-plugin/scripts/src/__tests__/settings-manager.configure.test.ts create mode 100644 .cursor-plugin/scripts/src/__tests__/settings-manager.load-save.test.ts create mode 100644 .cursor-plugin/scripts/src/__tests__/settings-manager.test.ts create mode 100644 .cursor-plugin/scripts/src/__tests__/single-flight-lock.test.ts create mode 100644 .cursor-plugin/scripts/src/config/api-keys.ts create mode 100644 .cursor-plugin/scripts/src/interfaces/ensure-deps.ts create mode 100644 .cursor-plugin/scripts/src/interfaces/env.ts create mode 100644 .cursor-plugin/scripts/src/interfaces/global-install.ts create mode 100644 .cursor-plugin/scripts/src/interfaces/hook-types.ts create mode 100644 .cursor-plugin/scripts/src/interfaces/hooks.ts create mode 100644 .cursor-plugin/scripts/src/interfaces/marketplace.ts create mode 100644 .cursor-plugin/scripts/src/interfaces/mcp.ts create mode 100644 .cursor-plugin/scripts/src/interfaces/project-install.ts create mode 100644 .cursor-plugin/scripts/src/interfaces/setup.ts create mode 100644 .cursor-plugin/scripts/src/interfaces/verify.ts create mode 100644 .cursor-plugin/scripts/src/services/browser-binary.ts create mode 100644 .cursor-plugin/scripts/src/services/deps-targets.ts create mode 100644 .cursor-plugin/scripts/src/services/enforce-ttl.ts create mode 100644 .cursor-plugin/scripts/src/services/ensure-deps.ts create mode 100644 .cursor-plugin/scripts/src/services/env-file.ts create mode 100644 .cursor-plugin/scripts/src/services/env-manager.ts create mode 100644 .cursor-plugin/scripts/src/services/env-route.ts create mode 100644 .cursor-plugin/scripts/src/services/global-artifacts.ts create mode 100644 .cursor-plugin/scripts/src/services/global-install.ts create mode 100644 .cursor-plugin/scripts/src/services/global-inventory.ts create mode 100644 .cursor-plugin/scripts/src/services/global-paths.ts create mode 100644 .cursor-plugin/scripts/src/services/global-receipt.ts create mode 100644 .cursor-plugin/scripts/src/services/global-transaction.ts create mode 100644 .cursor-plugin/scripts/src/services/harness-env.ts create mode 100644 .cursor-plugin/scripts/src/services/harness-gates.ts create mode 100644 .cursor-plugin/scripts/src/services/harness-path-resolver.ts create mode 100644 .cursor-plugin/scripts/src/services/harness-tuning.ts create mode 100644 .cursor-plugin/scripts/src/services/hook-executor.ts create mode 100644 .cursor-plugin/scripts/src/services/hook-merge.ts create mode 100644 .cursor-plugin/scripts/src/services/load-project-plugins.ts create mode 100644 .cursor-plugin/scripts/src/services/marketplace-sources.ts create mode 100644 .cursor-plugin/scripts/src/services/mcp-config.ts create mode 100644 .cursor-plugin/scripts/src/services/mcp-defaults.ts create mode 100644 .cursor-plugin/scripts/src/services/mcp-installer.ts create mode 100644 .cursor-plugin/scripts/src/services/mcp-key-prompt.ts create mode 100644 .cursor-plugin/scripts/src/services/mcp-setup.ts create mode 100644 .cursor-plugin/scripts/src/services/merge-utils.ts create mode 100644 .cursor-plugin/scripts/src/services/perf-env.ts create mode 100644 .cursor-plugin/scripts/src/services/plugin-inventory.ts create mode 100644 .cursor-plugin/scripts/src/services/plugin-scanner.ts create mode 100644 .cursor-plugin/scripts/src/services/project-files.ts create mode 100644 .cursor-plugin/scripts/src/services/project-hooks.ts create mode 100755 .cursor-plugin/scripts/src/services/project-install.ts create mode 100644 .cursor-plugin/scripts/src/services/project-transaction.ts create mode 100644 .cursor-plugin/scripts/src/services/project-uninstall.ts create mode 100644 .cursor-plugin/scripts/src/services/project-verify.ts create mode 100644 .cursor-plugin/scripts/src/services/settings-env-purge.ts create mode 100644 .cursor-plugin/scripts/src/services/settings-language.ts create mode 100644 .cursor-plugin/scripts/src/services/settings-manager.ts create mode 100644 .cursor-plugin/scripts/src/services/setup-plugins.ts create mode 100644 .cursor-plugin/scripts/src/services/setup-runner.ts create mode 100644 .cursor-plugin/scripts/src/services/shell-config.ts create mode 100644 .cursor-plugin/scripts/src/services/shell-detection.ts create mode 100644 .cursor-plugin/scripts/src/services/shell-installers.ts create mode 100644 .cursor-plugin/scripts/src/services/solid-lines.ts create mode 100644 .cursor-plugin/scripts/src/utils/bun-install-runner.ts create mode 100644 .cursor-plugin/scripts/src/utils/command-parser.ts create mode 100644 .cursor-plugin/scripts/src/utils/dep-scanner.ts create mode 100644 .cursor-plugin/scripts/src/utils/fs-helpers.ts create mode 100644 .cursor-plugin/scripts/src/utils/hooks-discovery.ts create mode 100644 .cursor-plugin/scripts/src/utils/single-flight-lock.ts create mode 100644 .cursor-plugin/scripts/tsconfig.json create mode 100755 .cursor-plugin/scripts/validate-agents.sh create mode 100755 .cursor-plugin/scripts/verify.ts create mode 100644 .gitignore create mode 100644 MEMORY/LESSON.md 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/.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..f008199 --- /dev/null +++ b/.cursor-plugin/scripts/install-hooks.ts @@ -0,0 +1,68 @@ +#!/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); + if (argv.includes("--dry-run") || argv.includes("--uninstall")) 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..8e5183e --- /dev/null +++ b/.cursor-plugin/scripts/src/__tests__/env-loader-fuse-filter-sandbox.test.ts @@ -0,0 +1,106 @@ +/** + * 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", () => { + test.each([ + ["bash", ["bash", "--noprofile", "--norc", "-c"], "cursor-env.bash"], + ["zsh", ["zsh", "-f", "-c"], "cursor-env.zsh"], + ["sh (POSIX)", ["sh", "-c"], "cursor-env.bash"], + ])("%s loader skips FUSE_* and keeps every other key verbatim", async (_n, argv, file) => { + 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..23e4884 --- /dev/null +++ b/.cursor-plugin/scripts/src/services/global-install.ts @@ -0,0 +1,156 @@ +/** + * 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 }); + try { + fs.rmdirSync(paths.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 ${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..c184b33 --- /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 { installClaudeMd, 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..3cefe28 --- /dev/null +++ b/.cursor-plugin/scripts/src/utils/fs-helpers.ts @@ -0,0 +1,88 @@ +/** + * 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"]); + 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 { + const result = await $`find ${dir} -name "*.sh" -type f`.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/.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..4744b70 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,30 @@ # 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(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..d188f43 --- /dev/null +++ b/MEMORY/LESSON.md @@ -0,0 +1,12 @@ +# 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. 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/project-rule-update.test.sh b/tests/project-rule-update.test.sh index 81d0422..465e1d3 100755 --- a/tests/project-rule-update.test.sh +++ b/tests/project-rule-update.test.sh @@ -9,7 +9,14 @@ PROJECT="$TMP/project" FAKE_HOME="$TMP/home" mkdir -p "$PROJECT" "$FAKE_HOME" -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/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 From ca37c9e8f77b1d517a50edf6ded191a8f1d723f5 Mon Sep 17 00:00:00 2001 From: Bruno Azoulay Date: Tue, 1 Sep 2026 22:18:44 +0200 Subject: [PATCH 02/12] fix(installer): --project no longer exits 1 on a fresh $HOME main() only skipped runSetup() (the global configuration stage) for --dry-run/--uninstall, so --project fell through to it too. That stage scans ~/.cursor/plugins/local: on a missing directory BSD find exits 1 and Bun's $ turns that into an uncaught throw, failing the command even though the project deploy had already succeeded. - install-hooks.ts: --project now returns before runSetup - fs-helpers.ts: makeScriptsExecutable no longer treats a missing directory as an error (existsSync guard + .nothrow()) --- .cursor-plugin/scripts/install-hooks.ts | 5 ++++- .cursor-plugin/scripts/src/utils/fs-helpers.ts | 6 +++++- CHANGELOG.md | 5 +++++ 3 files changed, 14 insertions(+), 2 deletions(-) diff --git a/.cursor-plugin/scripts/install-hooks.ts b/.cursor-plugin/scripts/install-hooks.ts index f008199..f574f93 100755 --- a/.cursor-plugin/scripts/install-hooks.ts +++ b/.cursor-plugin/scripts/install-hooks.ts @@ -58,7 +58,10 @@ function runDeployEngine(argv: string[]): void { async function main(): Promise { const argv = process.argv.slice(2); runDeployEngine(argv); - if (argv.includes("--dry-run") || argv.includes("--uninstall")) return; + // 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")); } diff --git a/.cursor-plugin/scripts/src/utils/fs-helpers.ts b/.cursor-plugin/scripts/src/utils/fs-helpers.ts index 3cefe28..2297e7c 100644 --- a/.cursor-plugin/scripts/src/utils/fs-helpers.ts +++ b/.cursor-plugin/scripts/src/utils/fs-helpers.ts @@ -56,7 +56,11 @@ export async function copyExecutable( * Rend tous les scripts .sh exécutables dans un répertoire */ export async function makeScriptsExecutable(dir: string): Promise { - const result = await $`find ${dir} -name "*.sh" -type f`.quiet(); + // 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(); diff --git a/CHANGELOG.md b/CHANGELOG.md index 4744b70..65572d9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,11 @@ 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(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 From 348f2609968a0440b9d93429b8cfc780a9eaabb4 Mon Sep 17 00:00:00 2001 From: Bruno Azoulay Date: Tue, 1 Sep 2026 22:23:21 +0200 Subject: [PATCH 03/12] ci: add GitHub Actions workflow for installer CI Three jobs cover what previously had no automated gate: typecheck + bun unit tests for the installer engine, the shell integration suites run against a sandboxed HOME, and manifest/hook validation across every plugin (JSON parses, hook commands stay relative, wrapper scripts are executable, shell entry points resolve to existing targets). --- .github/workflows/ci.yml | 139 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 139 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..c5efdff --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,139 @@ +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 + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Typecheck + run: bunx tsc --noEmit + + - name: Unit tests + run: bun test + + 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" From ec9563a3463297f49f283d26bb6dc7376f5024f0 Mon Sep 17 00:00:00 2001 From: Bruno Azoulay Date: Tue, 1 Sep 2026 22:23:39 +0200 Subject: [PATCH 04/12] test: harden HOME-isolation assertions and drop install.mjs references Bun's own bookkeeping (BUN_RUNTIME_TRANSPILER_CACHE_PATH, BUN_INSTALL_CACHE_DIR) writes under $HOME on first run, which was fighting the "install writes nothing to $HOME" checks in global-install.test.sh and project-install.test.sh. Redirect both caches out of the sandbox instead of narrowing the assertion to ~/.cursor, so it keeps covering the whole fake HOME and runs against a genuinely fresh $HOME (the bug fixed in eadef61). marketplace-sparse-install.test.sh now scans git-tracked paths via `git ls-files` instead of walking the working tree, so untracked, gitignored build artifacts (node_modules, .harness, .impeccable) can no longer be mistaken for missing or symlinked marketplace material. global-install.test.sh's uninstall check now asserts on the receipt-managed artifacts (receipt.json, .managed-by-fusengine) instead of the whole .fusengine-global directory, matching uninstallGlobal()'s documented behavior of preserving the vendored scripts/ tree when non-empty. Remaining references to the retired install.mjs entry point are updated to install-hooks.ts across all affected suites. --- tests/global-install.test.sh | 25 +++++++++++++++++---- tests/marketplace-sparse-install.test.sh | 18 ++++++++++----- tests/project-install.test.sh | 10 ++++++++- tests/project-rule-update.test.sh | 2 ++ tests/project-uninstall-regressions.test.sh | 2 ++ 5 files changed, 46 insertions(+), 11 deletions(-) diff --git a/tests/global-install.test.sh b/tests/global-install.test.sh index 27f8e02..c50da90 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) @@ -83,7 +93,14 @@ 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" -test ! -e "$HOME_DIR/.cursor/.fusengine-global" +# uninstallGlobal() only reverses what the receipt tracks (receipt.json, +# .managed-by-fusengine, and the rule when unmodified); it deliberately +# preserves .fusengine-global/scripts (the vendored shared hook runtime the +# separate configuration stage writes there) when the directory is not +# empty -- see global-install.ts's `/* preserved if non-empty */`. So the +# receipt-managed artifacts must be gone, not the whole control root. +test ! -e "$HOME_DIR/.cursor/.fusengine-global/receipt.json" +test ! -e "$HOME_DIR/.cursor/.fusengine-global/.managed-by-fusengine" COLLISION_HOME="$TMP/collision-home" mkdir -p "$COLLISION_HOME/.cursor/plugins/local/core-guards" @@ -105,6 +122,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 465e1d3..be4899f 100755 --- a/tests/project-rule-update.test.sh +++ b/tests/project-rule-update.test.sh @@ -8,6 +8,8 @@ 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. 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 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" From f4f23233906dd4be1750e11eb845650bf324a8f3 Mon Sep 17 00:00:00 2001 From: Bruno Azoulay Date: Tue, 1 Sep 2026 22:23:48 +0200 Subject: [PATCH 05/12] chore(fuse-seo): remove tracked APEX scratch log .claude/apex/docs/task-1-generic.md is a 5-line agent research note matching the .gitignore .claude/ rule, tracked by mistake in a public plugin. The plugin's own README already documents it as excluded from the source tree; removing it makes that documentation accurate. No code, test, or manifest references this file. --- .../plugins/fuse-seo/.claude/apex/docs/task-1-generic.md | 5 ----- 1 file changed, 5 deletions(-) delete mode 100644 .cursor-plugin/plugins/fuse-seo/.claude/apex/docs/task-1-generic.md 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 From a42cdf1d98acff302ccebdb6e1bc5a3071b33727 Mon Sep 17 00:00:00 2001 From: Bruno Azoulay Date: Tue, 1 Sep 2026 22:23:48 +0200 Subject: [PATCH 06/12] docs(memory): record installer-port lessons on HOME-isolation testing Captures why a test-side workaround for a missing ~/.cursor/plugins/local must never mask a production bug, why Bun's runtime caches need neutralizing rather than narrowing the $HOME assertion, and why an uncommitted git rm produces a false failure against a HEAD-relative suite. --- MEMORY/LESSON.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/MEMORY/LESSON.md b/MEMORY/LESSON.md index d188f43..376c9e1 100644 --- a/MEMORY/LESSON.md +++ b/MEMORY/LESSON.md @@ -10,3 +10,6 @@ - [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: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 ». From f6c8b6143a18cbbed3eb4377ef30126f19743bfc Mon Sep 17 00:00:00 2001 From: Bruno Azoulay Date: Tue, 1 Sep 2026 22:44:54 +0200 Subject: [PATCH 07/12] test(installer): fail CI on any skipped shell loader test instead of hiding it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit zsh/sh test.each cases spawned argv[0] directly, so ubuntu-latest (no zsh/fish) made the suite FAIL with ENOENT instead of skipping — and the fish case, which already carried a manual skip guard, had been silently skipping in CI all along with nothing to flag the lost coverage. - Replace the test.each block with a for loop + test.skipIf(!Bun.which(shell)) per case (bun:test has no per-row skip in test.each: oven-sh/bun#4469, open) - Install zsh and fish on the CI runner (fish ships in universe, enabled by default on ubuntu-latest) so all three loaders actually run - Fail the Unit tests step if bun test reports any skip: a skip in CI means a missing shell on the runner, never an acceptable outcome Verified: bunx tsc --noEmit clean; bun test 182 pass/0 fail/0 skip; negative control with zsh stripped from PATH gives 7 pass/1 skip/0 fail (vs 1 fail before the fix) and the CI grep guard matches bun's " 1 skip" line. --- .../env-loader-fuse-filter-sandbox.test.ts | 25 ++++++++++++++----- .github/workflows/ci.yml | 18 ++++++++++++- 2 files changed, 36 insertions(+), 7 deletions(-) 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 index 8e5183e..70db6d9 100644 --- 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 @@ -37,15 +37,28 @@ afterEach(() => { }); describe("shell loaders: FUSE_* filtered, API keys and quoting preserved", () => { - test.each([ + // 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"], - ])("%s loader skips FUSE_* and keeps every other key verbatim", async (_n, argv, file) => { - const loader = join(ENV_SHELL_DIR, file); - const got = await runProbe([...argv, `. "${loader}"\n${PROBE}`], tmpHome); - expect(got).toEqual(EXPECTED); - }); + ]; + + 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"); diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c5efdff..9644bff 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,14 +25,30 @@ jobs: 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: bun test + 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) From 20133dfe0bdcaa2192ec1cd7377798b23afcce40 Mon Sep 17 00:00:00 2001 From: Bruno Azoulay Date: Tue, 1 Sep 2026 22:45:01 +0200 Subject: [PATCH 08/12] docs(memory): record CI silent-skip and shell-writes-scope lessons MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two lessons from the shell-loader CI fix session: - a skipIf(tool absent) guard protects a contributor's machine but hides lost coverage on CI if the tool is never installed there - a claimed write-scope ("only writes under ~/.cursor") must come from grepping the module's write primitives, not from memory of the paths — challenger caught rc-file appends the claim had missed --- MEMORY/LESSON.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/MEMORY/LESSON.md b/MEMORY/LESSON.md index 376c9e1..2e60465 100644 --- a/MEMORY/LESSON.md +++ b/MEMORY/LESSON.md @@ -12,4 +12,6 @@ - [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: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 ». From 778a3abf3daa61fa1215f3e98dbfdf4bccfb792b Mon Sep 17 00:00:00 2001 From: Bruno Azoulay Date: Tue, 1 Sep 2026 22:59:59 +0200 Subject: [PATCH 09/12] fix(installer): --uninstall removes the whole control root recursively uninstallGlobal() only reversed what receipt.json tracked (plugin hashes and ruleHash), while the deployed hook loader and its src/ tree - written by setup-runner.ts, a module the deployment engine never imports - could never be reclaimed by any code path. The non-recursive rmdirSync then failed silently on ENOTEMPTY inside an empty catch, reporting success while leaving ~64 files (loader tree + the harness node_modules the wrapper self-installs there) behind for good. The control root belongs to the installer in full (readGlobalReceipt already refuses to act without the ownership marker, validatePathChain forbids symlinks), so uninstallGlobal now removes it recursively via fs.rmSync, guarded by an lstatSync TOCTOU check placed immediately before the destructive call. User plugins, a modified rule, ~/.cursor/.env and the hooks/MCP config are preserved as before. Test: the uninstall assertion is tightened from receipt.json alone to the strict `test ! -e .fusengine-global`, preceded by `test -d .../scripts` so the assertion has teeth (proves the directory existed before uninstall, not just that it's gone). --- .../scripts/src/services/global-install.ts | 14 ++++++++++---- tests/global-install.test.sh | 19 +++++++++++-------- 2 files changed, 21 insertions(+), 12 deletions(-) diff --git a/.cursor-plugin/scripts/src/services/global-install.ts b/.cursor-plugin/scripts/src/services/global-install.ts index 23e4884..c4ae25d 100644 --- a/.cursor-plugin/scripts/src/services/global-install.ts +++ b/.cursor-plugin/scripts/src/services/global-install.ts @@ -134,11 +134,17 @@ function uninstallGlobal(): void { ]; if (removeRule) finalFiles.push({ path: paths.rulePath, content: null }); commitGlobalSnapshot({ cursorRoot: paths.cursorRoot, stageRoot: stage, finalFiles }); - try { - fs.rmdirSync(paths.controlRoot); - } catch { - /* preserved if non-empty */ + // 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; diff --git a/tests/global-install.test.sh b/tests/global-install.test.sh index c50da90..2a9085e 100755 --- a/tests/global-install.test.sh +++ b/tests/global-install.test.sh @@ -88,19 +88,22 @@ 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" -# uninstallGlobal() only reverses what the receipt tracks (receipt.json, -# .managed-by-fusengine, and the rule when unmodified); it deliberately -# preserves .fusengine-global/scripts (the vendored shared hook runtime the -# separate configuration stage writes there) when the directory is not -# empty -- see global-install.ts's `/* preserved if non-empty */`. So the -# receipt-managed artifacts must be gone, not the whole control root. -test ! -e "$HOME_DIR/.cursor/.fusengine-global/receipt.json" -test ! -e "$HOME_DIR/.cursor/.fusengine-global/.managed-by-fusengine" +# 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" mkdir -p "$COLLISION_HOME/.cursor/plugins/local/core-guards" From 26b5fd21ca358d12ca85977a36868ea5ccacc5c8 Mon Sep 17 00:00:00 2001 From: Bruno Azoulay Date: Tue, 1 Sep 2026 23:00:12 +0200 Subject: [PATCH 10/12] chore(installer): drop dead import and exclude .DS_Store from loader tree - setup-runner.ts: remove the unused installClaudeMd import; the function stays exported for project-scope callers, it was just never referenced from this module. - fs-helpers.ts: copyLoaderTree now excludes .DS_Store from the files it copies into the deployed loader tree - it was being shipped to users' ~/.cursor/.fusengine-global/scripts alongside the real loader files. --- .cursor-plugin/scripts/src/services/setup-runner.ts | 2 +- .cursor-plugin/scripts/src/utils/fs-helpers.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.cursor-plugin/scripts/src/services/setup-runner.ts b/.cursor-plugin/scripts/src/services/setup-runner.ts index c184b33..e9462fb 100644 --- a/.cursor-plugin/scripts/src/services/setup-runner.ts +++ b/.cursor-plugin/scripts/src/services/setup-runner.ts @@ -21,7 +21,7 @@ import { backupSettings, configureDefaults, configureHooks, enableAgentTeams, isAgentTeamsEnabled, loadSettings, promptLanguage, saveSettings, } from "./settings-manager"; -import { installClaudeMd, installDeps, scanAndPrepare, setupStatusline } from "./setup-plugins"; +import { installDeps, scanAndPrepare, setupStatusline } from "./setup-plugins"; /** Run the complete setup process */ export async function runSetup( diff --git a/.cursor-plugin/scripts/src/utils/fs-helpers.ts b/.cursor-plugin/scripts/src/utils/fs-helpers.ts index 2297e7c..0d66870 100644 --- a/.cursor-plugin/scripts/src/utils/fs-helpers.ts +++ b/.cursor-plugin/scripts/src/utils/fs-helpers.ts @@ -18,7 +18,7 @@ import { $ } from "bun"; */ 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"]); + 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")); From c2636ca431e941d80e1f2074e0e7f3acbf16494c Mon Sep 17 00:00:00 2001 From: Bruno Azoulay Date: Tue, 1 Sep 2026 23:01:09 +0200 Subject: [PATCH 11/12] docs(changelog): record uninstall control-root fix and .DS_Store exclusion No version bump - suite version stays frozen at 0.1.1 per owner decision. --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 65572d9..9852d73 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,12 @@ 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 From 2947e2aa89fda78e150cefbf3ea757975e10ac60 Mon Sep 17 00:00:00 2001 From: Bruno Azoulay Date: Tue, 1 Sep 2026 23:01:21 +0200 Subject: [PATCH 12/12] docs(memory): log the uninstall control-root leak lessons Two lessons from this fix: an assertion relaxed to entrench a leak instead of flagging it (test ! -e receipt.json standing in for the whole control root, justified by citing the code's own "preserved if non-empty" comment as if it were the contract), and the counter-intuitive root cause - an artifact written outside the receipt registry is orphaned by construction, not a non-recursive rmdir. --- MEMORY/LESSON.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/MEMORY/LESSON.md b/MEMORY/LESSON.md index 2e60465..a2ee1cc 100644 --- a/MEMORY/LESSON.md +++ b/MEMORY/LESSON.md @@ -12,6 +12,8 @@ - [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 ».