Multi-host installer: Cursor, Windsurf, OpenCode, Claude#254
Merged
Multi-host installer: Cursor, Windsurf, OpenCode, Claude#254
Conversation
There was a problem hiding this comment.
Pull request overview
This PR replaces the legacy OpenCode-only bash installer with a stdlib-only Python installer project under scripts/install/ that supports OpenCode, Cursor, Windsurf, and Claude Code (marketplace wrapper), adds Windows-friendly entrypoints, and updates docs/CI accordingly.
Changes:
- Introduces a new
scripts/installPython project (cq_install) with idempotent primitives (JSON merge, hook entries, markdown blocks, manifest-tracked copies) and per-host adapters. - Updates plugin bootstrap/runtime behavior to use a shared per-user runtime cache and adds a Cursor hook helper + tests.
- Refreshes Makefile targets, docs, pre-commit hooks, and adds an “Installer CI” workflow.
Reviewed changes
Copilot reviewed 50 out of 54 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
| server/backend/tests/test_store.py | Minor test formatting change (single-line SQL fetch). |
| scripts/test-install-opencode.sh | Removes legacy bash-based OpenCode installer test script. |
| scripts/install/uv.lock | Adds installer uv lockfile for reproducible dev deps. |
| scripts/install/tests/test_opencode_commands.py | Unit tests for OpenCode command frontmatter transform. |
| scripts/install/tests/test_hosts_windsurf.py | Tests Windsurf host install/uninstall behavior. |
| scripts/install/tests/test_hosts_registry.py | Tests host registry and feature flags. |
| scripts/install/tests/test_hosts_opencode.py | Tests OpenCode host install/uninstall behavior + schema seeding/merge behavior. |
| scripts/install/tests/test_hosts_cursor.py | Tests Cursor host config/rule/hooks install/uninstall behavior. |
| scripts/install/tests/test_hosts_claude.py | Tests Claude marketplace wrapper (subprocess calls + dry-run). |
| scripts/install/tests/test_context.py | Tests core installer datatypes and shared-skills dedup logic. |
| scripts/install/tests/test_common_symlink.py | Tests symlink_tree primitive. |
| scripts/install/tests/test_common_markdown.py | Tests markdown block upsert/remove primitives. |
| scripts/install/tests/test_common_json.py | Tests JSON merge-not-replace upsert/remove primitives. |
| scripts/install/tests/test_common_hooks.py | Tests hooks.json entry upsert/remove primitives. |
| scripts/install/tests/test_common_file.py | Tests write-if-missing and remove-owned-file primitives. |
| scripts/install/tests/test_common_copy.py | Tests manifest-tracked copy/remove tree primitives. |
| scripts/install/tests/test_cli.py | Tests installer CLI dispatch, targeting, dry-run, and multi-target dedup. |
| scripts/install/tests/conftest.py | Shared fixtures incl. XDG isolation + fake plugin tree. |
| scripts/install/tests/init.py | Marks installer tests package. |
| scripts/install/src/cq_install/runtime.py | Shared runtime root resolution helpers (XDG + Windows fallbacks). |
| scripts/install/src/cq_install/opencode_commands.py | Implements OpenCode command transform (strip name:, add agent: build). |
| scripts/install/src/cq_install/manifest.py | Manifest read/write + sha256 hashing helpers. |
| scripts/install/src/cq_install/hosts/windsurf.py | Windsurf host adapter (mcp_config.json + skills/runtime handling). |
| scripts/install/src/cq_install/hosts/opencode.py | OpenCode host adapter (opencode.json merge + AGENTS.md + command generation). |
| scripts/install/src/cq_install/hosts/cursor.py | Cursor host adapter (mcp.json, hooks.json, rules file, runtime bundle). |
| scripts/install/src/cq_install/hosts/claude.py | Claude host adapter (shells out to claude plugin marketplace). |
| scripts/install/src/cq_install/hosts/base.py | Defines HostDef interface and capability flags. |
| scripts/install/src/cq_install/hosts/init.py | Host registry + lookup helper. |
| scripts/install/src/cq_install/context.py | Defines Action/ChangeResult/InstallContext and RunState dedup. |
| scripts/install/src/cq_install/content.py | Shared content blobs + platform Python command + binary name helper. |
| scripts/install/src/cq_install/common.py | Core idempotent primitives (copy manifests, json merge, hooks, markdown blocks, etc.). |
| scripts/install/src/cq_install/cli.py | Installer CLI parser, target resolution, execution and result printing. |
| scripts/install/src/cq_install/main.py | python -m cq_install entrypoint. |
| scripts/install/src/cq_install/init.py | Package marker. |
| scripts/install/ruff.toml | Ruff configuration scoped to installer project. |
| scripts/install/pyproject.toml | Installer project metadata + dev dependency groups + pytest config. |
| scripts/install.ps1 | PowerShell wrapper to run installer via uv on Windows. |
| scripts/install-opencode.sh | Removes legacy OpenCode-only bash installer. |
| README.md | Documents multi-host installer usage, Windows instructions, and env vars. |
| plugins/cq/uv.lock | Adds pytest (and transitive deps) to plugin dev dependencies. |
| plugins/cq/tests/test_cursor_hook.py | Adds tests for Cursor lifecycle hook helper script. |
| plugins/cq/tests/test_bootstrap.py | Adds tests for bootstrap runtime path/version resolution behaviors. |
| plugins/cq/tests/conftest.py | Session-scoped loader fixture for non-package hook script. |
| plugins/cq/tests/init.py | Marks plugin tests package. |
| plugins/cq/scripts/bootstrap.py | Switches bootstrap to shared per-user runtime bin cache + metadata-driven required version. |
| plugins/cq/scripts/bootstrap.json | New bootstrap metadata file containing required CLI version. |
| plugins/cq/pyproject.toml | Adds pytest group and pytest config for plugin tests. |
| plugins/cq/hooks/cursor/cq_cursor_hook.py | New stdlib-only Cursor lifecycle hook helper. |
| plugins/cq/.claude-plugin/plugin.json | Removes cliVersion field in favor of bootstrap metadata. |
| Makefile | Replaces bash installer usage with cq_install targets; adds install/uninstall for Cursor/Windsurf/all; adds lint/test targets for installer + plugin tests. |
| docs/architecture.md | Documents the new multi-host installer architecture at a high level. |
| DEVELOPMENT.md | Updates repo structure and prerequisites (drops jq; adds scripts/install). |
| .pre-commit-config.yaml | Adds installer ty-check + uv lock check hooks. |
| .github/workflows/ci-install.yaml | New CI workflow to lint/test installer and plugin hook tests. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Add empty uv-managed Python project at scripts/install/ with the package skeleton, ruff config, and pytest wiring. Follow-up tasks implement the primitive library, hosts, and CLI.
Define the shared types every primitive and host returns or consumes: Action enum, frozen ChangeResult, InstallContext with all resolved paths, and RunState for in-run dedup of shared steps.
JSON merge-not-replace primitive that preserves user-added sibling fields (e.g. `env`) when updating managed keys (`command`, `args`). Generalises the #238 stale-replace fix into the primitive contract.
upsert_hook_entry handles per-hook arrays in hooks.json with legacy command cleanup and extra field merging. remove_hook_entry matches by command string and prunes empty hook lists.
Arrange public primitives alphabetically (remove_hook_entry, remove_json_entry, upsert_hook_entry, upsert_json_entry), then private helpers alphabetically (_load_json, _walk_or_create, _write_json), so it's obvious where to insert new primitives.
upsert_markdown_block / remove_markdown_block manage delimited sections in shared markdown files (e.g. the CQ block in AGENTS.md) without disturbing surrounding content. Skipped result is returned when start/end markers are imbalanced rather than guessing.
copy_tree records hash-tracked manifest entries so re-runs detect unchanged files, refresh stale ones, and clean up files removed from the source. remove_copied_tree skips files modified by the user since install (hash mismatch) with a SKIPPED result.
Three small primitives that round out common.py: symlink_tree for opt-out skill installs, write_if_missing for the Cursor rule file (user-editable, never overwritten), and remove_owned_file which compares content hash on uninstall and skips user edits.
Skeleton host adapters and a name->instance registry. install / uninstall remain NotImplementedError; subsequent tasks port the actual logic per host.
Host-specific strings belong beside the host that uses them, not in a shared content module that turns into a junk drawer as new IDEs are added. content.py now only holds genuinely shared blobs (the AGENTS.md CQ block + markers). CURSOR_RULE_CONTENT moves to hosts/cursor.py, establishing the pattern for future hosts.
Hosts call ctx.run_state.ensure_shared_skills(ctx) at the top of install(); the second and subsequent calls within the same invocation are no-ops, so multi-target runs install the shared skill commons exactly once.
Replace the shell-based logic in scripts/install-opencode.sh with a Python host adapter that exercises every primitive: shared skills (or host-isolated copy), MCP merge-not-replace, command file generation, AGENTS.md block management. The shell installer itself is removed in a later task once parity is verified locally.
argparse-driven `python -m cq_install (install|uninstall)` with repeatable --target, --global/--project mutual exclusion, --host-isolated-skills, and --dry-run. Targets are processed in order, fail-fast on first error.
Two changes surfaced by the Task 12 parity diff: 1. Seed $schema on fresh opencode.json creation. The URL gives OpenCode autocomplete and validation; write it when creating the file from scratch but never rewrite on existing files so user overrides survive re-installs. 2. Replace sys.executable with PYTHON_COMMAND in the MCP config. sys.executable captures the installer's own venv python under `uv run`, which is tied to the repo location and gets stale if the user moves or deletes the repo. PYTHON_COMMAND is a literal name (`python3` on POSIX, `python` on Windows per python.org docs) that PATH-resolves at OpenCode's invocation time. Detection uses platform.system() == "Windows" to match the existing bootstrap.py idiom. PYTHON_COMMAND lives in cq_install.content so Cursor, Windsurf, and Claude host adapters (Tasks 13-15) reuse the same source of truth. OPENCODE_SCHEMA_URL is a local constant in hosts/opencode.py. Parity diff against scripts/install-opencode.sh is now byte-identical (opencode.json, AGENTS.md, commands/).
CursorHost writes three things: - mcp.json: mcpServers.cq entry with PYTHON_COMMAND + bootstrap. - rules/cq.mdc: created on first install via write_if_missing, never overwritten on re-runs (user-editable rule file). - hooks.json: four lifecycle hooks (sessionStart, postToolUse, postToolUseFailure, stop) each pointing at cq_cursor_hook.py with --mode and a per-target --state-dir flag. _hook_command produces the shell-executable string Cursor wants by picking subprocess.list2cmdline on Windows and shlex.join on POSIX, so the quoting matches each platform's shell rules. Also flips the xfailed CLI multi-target test to assert success: installing opencode + cursor in one invocation now succeeds, and the shared skill commons is installed exactly once via the RunState.ensure_shared_skills dedup helper.
WindsurfHost writes mcpServers.cq to mcp_config.json under the Windsurf global config dir (~/.codeium/windsurf/, confirmed on both POSIX and Windows 11). Per Windsurf docs there is no per-project MCP config, so supports_project = False and the class raises NotImplementedError from project_target. Shared skills install via RunState.ensure_shared_skills; the --host-isolated-skills opt-out copies into <target>/skills. Closes #154.
Unlike the other hosts, Claude Code ships its own plugin marketplace so this adapter is a thin shell-out rather than a filesystem writer. install runs `claude plugin marketplace add` followed by `claude plugin install`; uninstall runs `claude plugin marketplace remove`. Dry-run skips subprocess invocation. Refuses to run if `claude` is not on PATH and prints a clear pointer. global_target returns a sentinel path that is never read, and both supports_project and supports_host_isolated are False since neither apply to the marketplace flow.
Stdlib-only Python script invoked by Cursor's session-start, post-tool-use, post-tool-use-failure, and stop hooks. Captures failed tool calls per session and emits a summary on stop. State dir is passed explicitly via --state-dir, scoped per install target. Sweeps state files older than 24h on session start. Tested with 14 pytest cases via a session-scoped `hook` fixture in conftest.py that loads the script once per test session (importlib.util.spec_from_file_location since the hook isn't a package member). Coverage: _truncate edges, _format_tool_input for Shell/Edit/Write/Bash/Read/unknown tools, session-start state creation and TTL sweep, post-tool-use-failure happy path and isInterrupt short-circuit, stop with and without prior failure state. pytest is added to the plugin's dev dependency group so the tests run under `make test-plugin` once that target lands in Task 17.
Wire `cd plugins/cq && uv run pytest` into the aggregate test target so the Cursor hook tests run under `make test`. Also list it in the help block alongside test-cli, test-sdk-*, etc.
Wire scripts/install/ into the aggregate setup, lint, and test targets so `make lint && make test` covers the new installer. Help block updated to list the new targets.
cli.py's default plugin root resolver was using parents[3] which walks to `scripts/` not the repo root, so it resolved to `scripts/plugins/cq` (non-existent). Should be parents[4]. Caught by smoke-testing `make install-opencode PROJECT=/tmp/x`: commands/ wasn't being generated because the plugin tree couldn't be found at the stale path. Tests missed it because every CLI test sets CQ_INSTALL_PLUGIN_ROOT explicitly via the fake_repo fixture, so the fallback path was never exercised. Adds a unit test that calls _resolve_plugin_root() directly with the env var unset and asserts the result contains the plugin's bootstrap script, commands dir, and skills dir.
install-opencode no longer shells out to install-opencode.sh; it now calls `python -m cq_install install --target opencode`. Added install-cursor, install-windsurf, install-claude, and install-all with matching uninstall-* counterparts, all following the same PROJECT=/path pattern. Help block lists the new targets grouped by host. Windsurf has no per-project MCP config, so install-windsurf / uninstall-windsurf always run with --global and print a note if the caller passed PROJECT=. install-all with PROJECT= routes the project-capable hosts through --project and then runs Windsurf separately with --global, prefixed by the same note.
Mirrors the existing checks for plugins/cq, sdk/python, and server/backend. ty-check-install runs `uvx ty check src/cq_install` in the installer's uv venv; uv-lock-check-install enforces lockfile freshness on any change to pyproject.toml / uv.lock.
Switch code comments and docstrings to US English for consistency (docs and PR bodies stay whatever the author writes). Also capitalize "Markdown" as a proper noun. - common.py: "markdown" -> "Markdown" and "synthesise" -> "synthesize" in upsert_markdown_block / remove_markdown_block docstrings. - opencode_commands.py: "flavoured" -> "flavored". - test_common_markdown.py: "markdown" -> "Markdown" in module docstring. - cq_cursor_hook.py: "Initialise" -> "Initialize" in module docstring.
No behavior change: the remove_* calls in CursorHost.uninstall and WindsurfHost.uninstall build a single list literal instead of append()ing one at a time. CursorHost's hook-loop appends stay as-is since they run inside a for loop.
Replace magic strings inline in the four host adapters with module-level constants so future changes have a single touch point and reading the install/uninstall flow doesn't require mental substitution. Per-host constants live in each hosts/<host>.py since the values are host-specific (with a little copying preferred over premature unification when two hosts happen to share the same value). One shared constant CQ_MCP_KEY = "cq" lives in content.py because all three MCP-writing hosts genuinely use the same leaf key name for the cq entry (only the wrapping key differs: "mcp" vs "mcpServers"). OpenCode gains a second behaviour change: global_target() now honours the OPENCODE_CONFIG_DIR environment variable the same way OpenCode itself does (https://opencode.ai/docs/config/). If a user has set it, the installer writes to the overridden location so the installer and OpenCode agree on where opencode.json lives. Default path stays ~/.config/opencode. Two new tests cover the default and env-override branches. Also fixes a misleading "XDG-style" comment: OpenCode does not honour XDG_CONFIG_HOME. Extracted constants, by host: - content.py: CQ_MCP_KEY - opencode.py: OPENCODE_AGENTS_FILE, OPENCODE_COMMANDS_DIR, OPENCODE_CONFIG_DIR_ENV, OPENCODE_CONFIG_FILE, OPENCODE_GLOBAL_TARGET, OPENCODE_MCP_KEY, OPENCODE_PROJECT_TARGET, OPENCODE_SKILLS_DIR - cursor.py: CURSOR_HOOKS_FILE, CURSOR_HOOK_SCRIPT_RELPATH, CURSOR_MCP_FILE, CURSOR_MCP_SERVERS_KEY, CURSOR_RULE_RELPATH, CURSOR_SKILLS_DIR, CURSOR_STATE_DIR, CURSOR_TARGET_DIR - windsurf.py: WINDSURF_GLOBAL_TARGET, WINDSURF_MCP_FILE, WINDSURF_MCP_SERVERS_KEY, WINDSURF_SKILLS_DIR - claude.py: CLAUDE_CLI, CLAUDE_PLUGIN_NAME, CLAUDE_PLUGIN_PACKAGE
Lint and test scripts/install/ and the plugin's Cursor hook tests on changes to either component. Mirrors ci-plugin.yaml shape: setup-uv then make setup-install/setup-plugin then lint-install/test-install/test-plugin. Ubuntu runner only; Windows verification is a manual step in Task 24.
scripts/install.ps1 forwards arguments to `uv run python -m cq_install` from the scripts/install directory, giving Windows users the same one-liner ergonomics as `make install-*` on POSIX (Windows doesn't ship make). Errors out early if uv is not on PATH. Pushes into the installer dir before running so python -m cq_install resolves the package correctly, and pops back on exit regardless of success or failure. Returns the installer's exit code.
…skills - README: drop jq prereq; add Python 3.11+ to the prereq line; add Cursor, Windsurf, Shared skills, and Windows sections to the installation guide. Windows section lists the PowerShell wrapper and the home-relative config paths. - DEVELOPMENT.md: drop jq from prereqs; add scripts/install to the repository structure table. - docs/architecture.md: append a short "Multi-host installer" note to section 5 explaining scripts/install's shape and extension point (one new file per host under cq_install/hosts/).
scripts/install-opencode.sh and its test harness are superseded by the cq_install Python installer at scripts/install/. Task 12's parity diff confirmed byte-identical output against the shell version on macOS (opencode.json, AGENTS.md, commands/), and the Makefile install-opencode target now routes through python -m cq_install, so nothing references the shell scripts any more. Windows support is a bonus from the rewrite: the shell script never worked natively on Windows, only under WSL or Git Bash.
`claude plugin marketplace remove` takes the derived marketplace name (the repo portion of the GitHub slug), not the full slug. Add succeeds with `mozilla-ai/cq` because it parses the source; remove fails with "Marketplace 'mozilla-ai/cq' not found" and needs the short name `cq` instead. Split the existing CLAUDE_PLUGIN_PACKAGE constant into CLAUDE_MARKETPLACE_SOURCE (used by add) and CLAUDE_MARKETPLACE_NAME (used by remove). Removing the marketplace also unregisters any plugins installed from it, so uninstall stays at one CLI call. Surfaced by manually running `make uninstall-claude` against a real Claude Code install during the Task 24 verification pass.
…ed XDG runtime * copy runtime assets for Cursor/Windsurf/OpenCode into a shared per-user runtime path * point non-Claude MCP/hook commands to the shared runtime bootstrap path * align runtime path resolution with XDG_DATA_HOME semantics across platforms * make Claude bootstrap prefer CLAUDE_PLUGIN_ROOT for installed plugin resolution * reuse valid symlinked cq binaries in bootstrap cache fast-path (no unnecessary relink) * add and update tests for shared runtime paths, bootstrap root resolution, and symlink cache reuse * document installer/plugin environment variables, including Windows-specific fallbacks in a separate table
…talls * store shared runtime version metadata in scripts/bootstrap.json * remove non-Claude runtime dependence on .claude-plugin/plugin.json * keep Claude manifest focused on Claude plugin config only * make installer output include per-hook details for Cursor hooks.json updates * reuse typed bootstrap helpers and keep bootstrap metadata in snake_case * update tests for neutral bootstrap metadata and shared runtime expectations
Replace Python bootstrap wrapper with direct binary launch for Cursor, Windsurf, and OpenCode to fix MCP startup timeouts on Windows. The bootstrap wrapper was causing stdin/stdout buffering issues. - Add cq_binary_name() helper for platform-specific binary name - Update MCP configs to invoke binary directly with "mcp" argument - Fix Windows line ending mismatch in text file writes to preserve hashes - Update host adapter tests accordingly Claude plugin behavior unchanged.
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Move ensure_binary/download/check_version/path helpers out of bootstrap.py into a sibling stdlib-only cq_binary.py so the installer can reuse the same code without a subprocess dance. bootstrap.py shrinks to a ~37-line Claude launcher that imports cq_binary and execvp's into `cq mcp`. plugin.json still points at bootstrap.py, so the launch path is unchanged from Claude's perspective. load_required_version now takes an explicit metadata_path so tests can isolate with tmp_path instead of mutating the real plugins/cq/scripts/bootstrap.json in place. ensure_binary drops its system parameter (callers do not need it); download still takes one for independent testability. Tests move to plugins/cq/tests/test_cq_binary.py covering XDG fallback on every platform, version helpers, and the fast / system / download paths. test_bootstrap.py shrinks to a single smoke test of the main() wrapper.
Cursor, Windsurf, and OpenCode had their MCP configs pointing at runtime_root/bin/cq but nothing in the installer ever placed a binary there. On a clean machine the MCP server failed to start. Add a shared fetch step that dynamically loads plugins/cq/scripts/cq_binary.py via importlib.util and calls its ensure_binary before any host writes its MCP config. The fetch runs once per installer invocation via RunState so install-all does not re-fetch per host. Under --dry-run the step reports "would fetch cq vX.Y.Z" without touching disk. Non-Claude runtime bundles shrink to only what each host actually needs: Windsurf and OpenCode no longer copy anything into runtime_root, and Cursor's bundle reduces to just the lifecycle hook script. Stale bootstrap.py/bootstrap.json copies from previous installs are cleaned up by the existing manifest-tracked copy_tree diff on the next Cursor install. Cleanup bundled in: - InstallContext.bootstrap_path removed (dead field). - Three _runtime_root(ctx) stubs in host adapters deleted. - _copy_selected_paths / _CQ_RUNTIME_MANIFEST renamed to drop the underscore (they were imported across modules). - _CQ_RUNTIME_BASE_RELPATHS deleted (no longer needed).
Five defensive fixes: - cli.py: --host-isolated-skills is now rejected with a clear error when any target host declares supports_host_isolated = False, instead of being silently ignored. - common._load_json: wraps json.JSONDecodeError as RuntimeError naming the file, so malformed user config surfaces an actionable message instead of a traceback. - common.upsert_json_entry: raises ValueError naming the file and dotted path when the existing leaf is not a dict, instead of crashing at dict(existing). - common.upsert_hook_entry: validates that the top level is an object, hooks is an object, and hooks[<name>] is a list; raises ValueError with the file and offending key on mismatch. - hosts/claude.py: _run_each now captures stdout/stderr via subprocess.run(..., capture_output=True, text=True) and includes returncode + stderr (+ stdout if present) in the raised RuntimeError, so subprocess failures are diagnosable in CI.
b9130ae to
f21d507
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 54 out of 58 changed files in this pull request and generated 6 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Six fixes from the second round of Copilot review on PR #254: - remove_hook_entry: returns SKIPPED with a detail message when the file contains non-dict top level or non-dict hooks key, instead of crashing during uninstall. - symlink_tree: passes target_is_directory to Path.symlink_to so directory symlinks are created correctly on Windows. - upsert_markdown_block: returns UPDATED (not CREATED) when appending a block to an existing file. - write_if_missing: docstring corrected to match the actual newline parameter value. - _walk_or_create: raises ValueError naming the file and dotted path when an intermediate key exists but is not a dict, instead of silently overwriting user config. - _uninstall_commands (OpenCode): compares file content against expected generated output before deleting; user-edited command files are left in place with a SKIPPED result, consistent with remove_owned_file and remove_copied_tree.
This was referenced Apr 9, 2026
Closed
1 task
Copilot AI
pushed a commit
that referenced
this pull request
Apr 9, 2026
feat(installer): multi-host installer for Cursor, Windsurf, OpenCode, and Claude
Replace the legacy OpenCode-only shell installer with a stdlib-only Python installer at scripts/install/ supporting four hosts. Drops jq as a prerequisite.
Installer core:
- Idempotent primitives for JSON merge, hook entries, Markdown blocks,
manifest-tracked file copies, and symlinks. --dry-run across all.
- Per-host adapters under cq_install/hosts/ with a shared RunState for
dedup (shared skills installed once, binary fetched once).
- Config write hardening: malformed JSON, non-dict leaves, and
unexpected hook shapes raise clear errors naming the file.
Binary management:
- New plugins/cq/scripts/cq_binary.py (stdlib-only) owns the binary
download, version check, and cache logic. Single source of truth
consumed by both the Claude plugin bootstrap and the installer.
- Non-Claude hosts invoke the cq binary directly for MCP, fixing
startup timeouts on Windows caused by Python stdio buffering.
- bootstrap.py shrinks to a thin Claude-only launcher.
Hosts:
- Cursor: mcp.json, rules/cq.mdc, four lifecycle hooks with
platform-aware shell escaping, shared runtime hook script.
- Windsurf: mcp_config.json, shared skills.
- OpenCode: opencode.json with schema seeding, AGENTS.md block,
generated commands with user-edit protection on uninstall.
- Claude: thin marketplace wrapper (add/install/remove).
Build:
- Makefile targets for all hosts plus install-all/uninstall-all.
- PowerShell wrapper for Windows.
- Installer CI workflow, pre-commit ty and uv-lock checks.
Co-authored-by: Jonathan Kingston (#166)
Closes #154
Co-authored-by: peteski22 <487783+peteski22@users.noreply.github.com>
Copilot AI
pushed a commit
that referenced
this pull request
Apr 9, 2026
Commit 3454b2e squash-merged PR #254 with a malformed co-author trailer. This empty commit corrects the attribution for Jonathan Kingston's contribution to the multi-host installer (see #166). Co-authored-by: Jonathan Kingston <338988+jonathanKingston@users.noreply.github.com> Co-authored-by: peteski22 <487783+peteski22@users.noreply.github.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Replaces
scripts/install-opencode.shwith a stdlib-only Python installer atscripts/install/that supports four hosts: OpenCode, Cursor, Windsurf, and Claude Code (via the marketplace). Dropsjqas a prereq, generalises the merge-not-replace MCP config behaviour into a primitive contract, and adds first-class Windows support.Closes #154.
Picks up the Cursor + hook-helper direction from #166 (co-authored).
Key changes
Installer core (
scripts/install/)common.py) for JSON merge, hook entries, Markdown blocks, manifest-tracked file copies, symlinks, write-if-missing, remove-owned-file. Per-host adapters undercq_install/hosts/.python -m cq_install (install|uninstall) --target <host>... [--global | --project <path>] [--host-isolated-skills] [--dry-run]. Repeatable--targetruns with shared-skills dedup viaRunState.ensure_shared_skills.--host-isolated-skillsis validated against each host'ssupports_host_isolatedflag; unsupported combinations are rejected with a clear error._load_jsonwrapsJSONDecodeErrorwith the file path,upsert_json_entryraisesValueErroron non-dict leaf values,upsert_hook_entryvalidates top-level shape (object, hooks object, entries list). Claude subprocess failures now surfacereturncode+stderrin the raisedRuntimeError.Binary fetch at install time
plugins/cq/scripts/cq_binary.py: new stdlib-only module containing the binary download, version check, and cache logic previously inlined inbootstrap.py. Single source of truth consumed by both the plugin bootstrap (for Claude) and the installer (for all other hosts).cq_install.binary.ensure_cq_binary: dynamically loadscq_binary.pyfrom the plugin source tree viaimportlib.utiland calls itsensure_binary. Runs once per installer invocation viaRunState.ensure_cq_binaryso multi-target installs (install-all) fetch the binary exactly once.cqbinary directly (runtime_root/bin/cq mcp) instead of going throughpython bootstrap.py, fixing MCP startup timeouts on Windows caused by Python stdio buffering.plugins/cq/scripts/bootstrap.pyis now a ~37-line launcher that importscq_binaryandexecvps intocq mcp. Claude'splugin.jsonstill points at it; the launch path is unchanged from Claude's perspective.Runtime and metadata
$XDG_DATA_HOME/cq/runtime/(Windows:%LOCALAPPDATA%/cq/runtime/). Binary cache atruntime/bin/.plugins/cq/scripts/bootstrap.jsonstores the required CLI version (cli_version) independently of.claude-plugin/plugin.json, so non-Claude hosts do not depend on the Claude plugin manifest.cq_cursor_hook.py.Path.home()-relative target dirs work cross-platform;OPENCODE_CONFIG_DIRenv var honoured the same way OpenCode itself reads it.Cursor host
mcp.json,rules/cq.mdc(write-if-missing, never overwritten), and four lifecycle hooks (sessionStart,postToolUse,postToolUseFailure,stop) with platform-aware shell escaping (shlex.joinPOSIX,subprocess.list2cmdlineWindows).plugins/cq/hooks/cursor/cq_cursor_hook.py.Build and CI
install-cursor,install-windsurf,install-claude,install-allplus matchinguninstall-*.install-windsurf PROJECT=...prints a note and falls back to global since Windsurf has no per-project MCP config.scripts/install.ps1for Windows users (nomakerequired).ty-check-installanduv-lock-check-install; newInstaller CIworkflow.jqdropped from prereqs; env var reference table.Test plan
make lint && make test(156 tests: 120 installer + 36 plugin)make install-opencode PROJECT=/tmp/xthen re-run; idempotentscripts/install-opencode.sh(byte-identical: opencode.json, AGENTS.md, commands/)make install-cursoragainst real~/.cursorwith pre-existing mcpd MCP entry; mcpd preserved through install + uninstallenvfield hand-added to MCP entry survives re-install (merge-not-replace)$schemapreserved on existing files; seeded on fresh creation onlymake install-windsurfagainst real~/.codeium/windsurf(live install + uninstall, idempotent)--target opencode --target cursorinstalls shared skills exactly oncemake uninstall-cursor/opencode/windsurfcleanly removes only cq entries; user-added siblings preservedmake install-claude/make uninstall-clauderound-trip (blocked locally by an unrelatedclaude plugin installcache cleanup bug from a stale uv venv; the marketplace name fix is verified independently via direct CLI).\scripts\install.ps1 install --target ...)Co-authored-by: Jonathan Kingston 338988+jonathanKingston@users.noreply.github.com