Skip to content

feat(memory-cli): cross-platform IDE detection for memory init#1251

Open
bplatz wants to merge 3 commits into
mainfrom
feature/x-platform-memory
Open

feat(memory-cli): cross-platform IDE detection for memory init#1251
bplatz wants to merge 3 commits into
mainfrom
feature/x-platform-memory

Conversation

@bplatz
Copy link
Copy Markdown
Contributor

@bplatz bplatz commented May 21, 2026

fluree memory init used to detect installed IDEs primarily via macOS-specific /Applications/<App>.app probes, with fallbacks limited to ~/.<app> markers. That left Linux and Windows in a "must have launched the IDE once" hole, and made Claude Code's detection (no .app bundle) weaker than the others on every OS.

This PR makes detection multi-signal and cross-platform, hardens the IDE config installers against clobbering, and corrects Claude Code's project-root handling so running fluree memory init from a subdirectory behaves identically to running it from the repo root.

What changed

Detection refactor (fluree-db-cli/src/commands/memory.rs)

  • Factored all filesystem/env probing behind a DetectionEnv trait. Production code uses RealEnv (delegates to std::env, dirs, std::fs); tests use FakeEnv.
  • Each IDE is now detected via four signal classes — any one is enough:
    1. Binary on PATH (claude, code, cursor, windsurf, zed). claude is now a first-class signal — previously Claude Code only matched ~/.claude/.
    2. macOS app bundle in /Applications or ~/Applications.
    3. Home-dir marker (~/.claude, ~/.cursor, ~/.vscode, ~/.codeium/windsurf, ~/.zed, ~/.config/zed, ~/.claude.json).
    4. User-config dir under dirs::config_dir()~/.config/Code on Linux, ~/Library/Application Support/Cursor on macOS, %APPDATA%\Windsurf on Windows, etc. This is what closes the "launched once" gap on Linux/Windows.

Claude Code project-root handling

claude_code_already_configured used the canonicalized cwd as the lookup key — wrong when init is run from a subdirectory, since Claude stores its projects map keyed by the repo root. Now:

  • Probes canonical(project_root) → project_root → canonical(cwd) in order.
  • install_claude_code spawns claude mcp add with .current_dir(project_root).
  • The CLAUDE.md append target is <project_root>/CLAUDE.md, not the cwd-relative Path::new("CLAUDE.md").

Refuse-to-clobber for IDE configs

read_or_default() silently replaced any unreadable/unparsable JSON with a fresh default — fine for missing files, dangerous for JSONC, hand-edits, invalid UTF-8, or permission errors. Replaced with load_config() returning:

  • Default — file missing (ErrorKind::NotFound) or empty.
  • Parsed — valid JSON.
  • Unsafe { reason } — any other state. Installer prints a snippet to paste manually and returns InstallOutcome::Skipped.

This now applies to Cursor, VS Code, Windsurf, and Zed (Zed was already safe; the others now match).

Skipped vs installed accounting

Previously, install_tool() -> Ok(()) covered both "actually installed" and "refused to overwrite, printed snippet." So the user would see "Configured N tools" with N including refused targets. Fixed with a new InstallOutcome { Installed, Skipped } enum; run_init only counts Installed and now prints a separate "Skipped N tools (see manual snippet above)" line when needed. Claude Code's failed claude mcp add (binary missing, non-zero exit) also reports Skipped.

Windows PATHEXT fallback

The PATH probe previously checked nothing if PATHEXT was unset or empty. Now falls back to .COM / .EXE / .BAT / .CMD and also tries the bare name (so an extensionless file still matches).

macOS app-probe tidying

macos_app_installed now early-returns when system_applications_dir is None, so ~/Applications is no longer probed on Linux/Windows (matches the docs).

canonicalize moved behind DetectionEnv

claude_code_already_configured no longer reaches into std::fs::canonicalize directly — exposes the test seam fully.

Tests

21 unit tests in commands::memory::tests:

  • project_root_walks_up_to_git_marker, project_root_falls_back_to_cwd_when_no_marker
  • Per-IDE presence: claude_present_via_path, claude_present_via_home_marker, claude_absent_when_no_signal, vscode_present_via_config_dir, cursor_present_via_config_dir, windsurf_present_via_config_dir
  • macOS probe: macos_app_probe_checks_both_system_and_user_applications, macos_app_probe_is_noop_when_system_dir_unset
  • path_extensions_unix_returns_bare_name_only
  • Claude already-configured: claude_already_configured_matches_project_root_from_nested_cwd, claude_already_configured_returns_false_when_other_project
  • End-to-end detection: detect_returns_empty_on_empty_env, detect_finds_claude_via_path_only, detect_flags_already_configured_via_project_mcp_json
  • load_config matrix: missing → Default, empty → Default, JSONC → Unsafe, invalid UTF-8 → Unsafe, valid JSON → Parsed

Docs

  • docs/memory/cli/init.md — added Detection signals section + manual-fallback pointer.
  • docs/memory/reference/ide-matrix.md — replaced macOS-centric wording with the four signal classes; added "running from a subdir" note.
  • docs/cli/memory.md — added Detection signals subsection under fluree memory init.
  • docs/memory/cli/mcp-install.md — documented the refuse-to-overwrite policy for non-Zed targets.

bplatz added 3 commits May 21, 2026 06:44
- Factor filesystem/env probing behind a `DetectionEnv` trait so detection
  logic is unit-testable (`RealEnv` + `FakeEnv`). Project-root walk-up is
  derived from the same probes.
- Detect each IDE via four signal classes (any one is enough): binary on
  `PATH`, macOS app bundle (`/Applications` *and* `~/Applications`), home-dir
  marker, and `dirs::config_dir()` marker (Linux `~/.config/<App>`, macOS
  `~/Library/Application Support/<App>`, Windows `%APPDATA%\<App>`). Adds
  `claude` as a first-class PATH probe.
- Fix Claude Code project-root handling: `claude_code_already_configured`
  keys off `project_root` (with canonical + non-canonical + cwd fallbacks),
  `install_claude_code` spawns `claude mcp add` from `project_root`, and
  `CLAUDE.md` is written at `<project_root>/CLAUDE.md` instead of cwd.
- Refuse to clobber unsafe configs in Cursor/VS Code/Windsurf/Zed
  installers. `load_config` now distinguishes missing/empty (default),
  parsed (merge), and unsafe (refuse). Unsafe covers JSONC, invalid UTF-8,
  permission errors, and any non-NotFound IO error.
- Distinguish skipped from installed in `run_init`: new `InstallOutcome`
  enum so the refused-overwrite and missing-`claude`-binary paths no
  longer get counted as successful installs. Prints a separate "Skipped N
  tools" line when applicable.
- Windows `PATHEXT` fallback: if unset/empty, try `.COM/.EXE/.BAT/.CMD`
  plus the bare name.
- macOS app probe early-returns when off macOS so `~/Applications` is
  never probed on Linux/Windows.
- 21 unit tests covering project-root walk-up, per-IDE presence via each
  signal class, Claude already-configured from a nested cwd, the
  `load_config` matrix (including invalid UTF-8 and JSONC), and the
  macOS-only app probe guard.
- Docs updated: `docs/memory/cli/init.md`, `docs/memory/reference/ide-matrix.md`,
  `docs/cli/memory.md`, `docs/memory/cli/mcp-install.md` describe the new
  detection signals, the "running from a subdir" behavior, and the
  refuse-to-overwrite policy.
memory.rs had grown to 1774 lines mixing memory CRUD with IDE detection,
MCP config installs, filesystem/env abstraction, and ~700 lines of
detection tests. Split the IDE concern out into a sibling module and
collapse the per-IDE duplication along the way.

- Move AiTool, DetectionEnv/RealEnv, detection probes, install_*,
  LoadedConfig, InstallOutcome, run_mcp_install, and all detection tests
  into fluree-db-cli/src/commands/memory/ide.rs. memory.rs now declares
  `mod ide;`, delegates Phase 2 of init via `ide::run_mcp_phase(yes)`,
  and routes McpInstall to `ide::run_mcp_install`.
- Make IDE detection data-driven via `IdeDetectionSpec` (binary, macOS
  app, home markers, config markers). One `is_ide_present(env, spec)`
  replaces five near-identical `is_*_present` functions; `path_exists`
  matches both files and dirs, collapsing Claude's `.claude` (dir) vs
  `.claude.json` (file) into one marker list. Adding another IDE is now
  a table edit. Already-configured probes are similarly table-driven via
  `already_configured_probes(tool, env)`.
- Extract `install_json_mcp_config(JsonMcpInstall { config_path, top_key,
  default, entry })` — the load/merge/refuse-on-unsafe/write shape used
  by Cursor / VS Code / Windsurf / Zed. Per-IDE installers shrink to a
  few lines each.
- Extract `install_rules_file(dir)` for the bundled fluree_rules.md.
  Best-effort: prints a warning on failure instead of propagating, so a
  rules-file IO error no longer masks a successful MCP config install in
  the per-tool summary.
- Add `build_synced_store(dirs)` to memory.rs, replacing 7 copy-pasted
  `build_store + ensure_synced` pairs in the CRUD subcommands. run_init
  keeps a plain build_store since it creates an empty store before sync.
- Rename `InstallOutcome::Skipped` → `ManualRequired` and
  `LoadedConfig::Default` → `MissingOrEmpty` for more precise intent.
  The init summary now reports "N tools need manual setup" instead of
  "Skipped N tools".
- Tighten load_config: a parsed JSON value that isn't an object now
  returns Unsafe, since merge_mcp_entry can't merge into arrays /
  scalars / null. Previously a top-level `[]` would have been written
  back as-is with InstallOutcome::Installed reported to the user.

Tests: 28 unit tests in commands::memory::ide::tests (up from 21),
covering project-root walk-up, per-IDE presence via each signal class,
Claude already-configured under nested cwd, the load_config matrix
(missing, empty, JSONC, invalid UTF-8, non-object root, valid),
install_json_mcp_config (happy + refuse + non-object), and the
best-effort install_rules_file failure path. CLI test suite: 73/73.

memory.rs is now 498 lines focused on dispatch + memory CRUD; ide.rs is
1305 lines and self-contained.
The non-object root check in load_config caught files like `[]` or
`"foo"`, but a file shaped like `{"mcpServers": []}` still slipped
through: the root is an object (so load_config returned Parsed), and
merge_mcp_entry's second branch silently replaced the array with our
default object. The user's pre-existing entry under that key would be
lost.

install_json_mcp_config now inspects `config[top_key]` after load and
returns ManualRequired if the value exists and is not an object,
mirroring the don't-clobber-user-config rule we already apply at the
root level. Behavior is unchanged for the common cases — missing key
(create), existing object (merge while preserving siblings).

Tests:
- install_json_mcp_config_refuses_non_object_top_key — file
  `{"mcpServers":[]}` returns ManualRequired and is left untouched.
- install_json_mcp_config_merges_into_existing_object_top_key —
  pre-existing sibling entry under mcpServers is preserved while
  fluree-memory is inserted.

28 → 30 IDE tests; CLI suite still 73/73; workspace clippy clean.
@bplatz bplatz requested review from aaj3f and zonotope May 21, 2026 11:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant