22//!
33//! Preferred path: shell out to `claude mcp add -s user --transport stdio
44//! paneflow -- <bridge>` when the `claude` CLI is on PATH - it owns the
5- //! schema and writes user-scope servers to `~/.claude.json`. Fallback when
6- //! `claude` is absent (or the add fails): merge the entry directly into
7- //! `~/.claude.json` under `mcpServers.paneflow`.
5+ //! schema and writes user-scope servers to `~/.claude.json`. Do **not**
6+ //! `mcp remove` first: a crash between remove and add used to drop the
7+ //! paneflow entry while the rest of the file stayed intact, and holding
8+ //! [`crate::io::ConfigLock`] across the CLI does not serialize `claude`.
9+ //! After a successful add, the on-disk file is verified; a mismatch or a
10+ //! failed add falls back to a locked merge into `~/.claude.json` under
11+ //! `mcpServers.paneflow`. Fallback also runs when `claude` is absent.
812//!
913//! The entry carries **no `env` block** (PRD D5): the bridge inherits
1014//! `PANEFLOW_SOCKET_PATH` from the pane it runs in. Per 2026 verification
@@ -22,12 +26,19 @@ use crate::{io, merge};
2226const CLI : & str = "claude" ;
2327const CONTAINER : & str = "mcpServers" ;
2428
29+ #[ cfg( test) ]
30+ type CliHook = Box < dyn Fn ( & [ & str ] ) -> Result < ( ) > > ;
31+
2532pub struct ClaudeCode {
2633 config_path : Option < PathBuf > ,
2734 /// Whether shell-out to the `claude` CLI is permitted. Always true in
2835 /// production; forced false in unit tests so they never mutate the
2936 /// developer's real `~/.claude.json` via a real `claude` on PATH.
3037 allow_cli : bool ,
38+ /// Test-only stand-in for `claude`. When set, install/uninstall never
39+ /// consult PATH or spawn a real process.
40+ #[ cfg( test) ]
41+ cli : Option < CliHook > ,
3142}
3243
3344impl ClaudeCode {
@@ -36,6 +47,8 @@ impl ClaudeCode {
3647 Self {
3748 config_path : support:: claude_config ( ) ,
3849 allow_cli : true ,
50+ #[ cfg( test) ]
51+ cli : None ,
3952 }
4053 }
4154
@@ -62,6 +75,22 @@ impl ClaudeCode {
6275 "Claude Code MCP entry must be stdio, have empty args, and no env block" ,
6376 )
6477 }
78+
79+ fn cli_available ( & self ) -> bool {
80+ #[ cfg( test) ]
81+ if self . cli . is_some ( ) {
82+ return true ;
83+ }
84+ self . allow_cli && support:: cli_on_path ( CLI )
85+ }
86+
87+ fn invoke_cli ( & self , args : & [ & str ] ) -> Result < ( ) > {
88+ #[ cfg( test) ]
89+ if let Some ( cli) = & self . cli {
90+ return cli ( args) ;
91+ }
92+ support:: shell_out ( CLI , args)
93+ }
6594}
6695
6796impl Default for ClaudeCode {
@@ -96,34 +125,37 @@ impl AgentConfigWriter for ClaudeCode {
96125 }
97126 let had_prior = support:: json_entry_present ( path, CONTAINER ) ?;
98127
99- if self . allow_cli && support :: cli_on_path ( CLI ) {
128+ if self . cli_available ( ) {
100129 io:: backup ( path) ?;
101- // A stale entry would make `add` conflict; remove it first
102- // (best-effort - a missing entry just no-ops).
103- if had_prior {
104- let _ = support:: shell_out ( CLI , & [ "mcp" , "remove" , "paneflow" ] ) ;
105- }
106- match support:: shell_out (
107- CLI ,
108- & [
109- "mcp" ,
110- "add" ,
111- "-s" ,
112- "user" ,
113- "--transport" ,
114- "stdio" ,
115- "paneflow" ,
116- "--" ,
117- & bridge_s,
118- ] ,
119- ) {
120- Ok ( ( ) ) => {
121- return Ok ( if had_prior {
122- InstallOutcome :: Updated
123- } else {
124- InstallOutcome :: Installed
125- } ) ;
126- }
130+ // Skip `mcp remove`. A crash between remove and add used to
131+ // drop `mcpServers.paneflow` while the rest of the file stayed
132+ // intact. `add` is attempted as-is; a non-idempotent CLI (entry
133+ // already present) fails here and the locked merge repairs it.
134+ match self . invoke_cli ( & [
135+ "mcp" ,
136+ "add" ,
137+ "-s" ,
138+ "user" ,
139+ "--transport" ,
140+ "stdio" ,
141+ "paneflow" ,
142+ "--" ,
143+ & bridge_s,
144+ ] ) {
145+ Ok ( ( ) ) => match self . status ( Some ( bridge) ) ? {
146+ StatusOutcome :: Installed { .. } => {
147+ return Ok ( if had_prior {
148+ InstallOutcome :: Updated
149+ } else {
150+ InstallOutcome :: Installed
151+ } ) ;
152+ }
153+ _ => {
154+ log:: warn!(
155+ "paneflow mcp: `claude mcp add` exited 0 but ~/.claude.json does not match the managed entry; falling back to direct merge"
156+ ) ;
157+ }
158+ } ,
127159 Err ( e) => {
128160 log:: warn!(
129161 "paneflow mcp: `claude mcp add` failed ({e:#}); falling back to direct ~/.claude.json merge"
@@ -148,9 +180,9 @@ impl AgentConfigWriter for ClaudeCode {
148180 if !support:: json_entry_present ( path, CONTAINER ) ? {
149181 return Ok ( UninstallOutcome :: NothingToRemove ) ;
150182 }
151- if self . allow_cli && support :: cli_on_path ( CLI ) {
183+ if self . cli_available ( ) {
152184 io:: backup ( path) ?;
153- if let Ok ( ( ) ) = support :: shell_out ( CLI , & [ "mcp" , "remove" , "paneflow" ] ) {
185+ if let Ok ( ( ) ) = self . invoke_cli ( & [ "mcp" , "remove" , "paneflow" ] ) {
154186 return Ok ( UninstallOutcome :: Removed ) ;
155187 }
156188 }
@@ -165,14 +197,36 @@ impl AgentConfigWriter for ClaudeCode {
165197#[ cfg( test) ]
166198mod tests {
167199 use super :: * ;
200+ use std:: cell:: RefCell ;
201+ use std:: rc:: Rc ;
168202
169203 fn test_writer ( path : PathBuf ) -> ClaudeCode {
170204 ClaudeCode {
171205 config_path : Some ( path) ,
172206 allow_cli : false , // never shell out to a real `claude` in tests
207+ cli : None ,
173208 }
174209 }
175210
211+ fn record_args ( calls : & Rc < RefCell < Vec < Vec < String > > > > , args : & [ & str ] ) {
212+ calls
213+ . borrow_mut ( )
214+ . push ( args. iter ( ) . map ( |s| ( * s) . to_string ( ) ) . collect ( ) ) ;
215+ }
216+
217+ fn assert_add_without_remove ( calls : & [ Vec < String > ] ) {
218+ assert ! (
219+ calls. iter( ) . all( |args| !args. iter( ) . any( |a| a == "remove" ) ) ,
220+ "install must not call mcp remove: {calls:?}"
221+ ) ;
222+ assert ! (
223+ calls
224+ . iter( )
225+ . any( |args| args. windows( 2 ) . any( |w| w == [ "mcp" , "add" ] ) ) ,
226+ "expected mcp add: {calls:?}"
227+ ) ;
228+ }
229+
176230 #[ test]
177231 fn install_writes_stdio_entry_without_env ( ) {
178232 let dir = tempfile:: TempDir :: new ( ) . unwrap ( ) ;
@@ -283,6 +337,121 @@ mod tests {
283337 assert_eq ! ( w. uninstall( ) . unwrap( ) , UninstallOutcome :: NothingToRemove ) ;
284338 }
285339
340+ #[ test]
341+ fn install_cli_add_skips_remove_and_verifies_file ( ) {
342+ let dir = tempfile:: TempDir :: new ( ) . unwrap ( ) ;
343+ let p = dir. path ( ) . join ( ".claude.json" ) ;
344+ let dest = p. clone ( ) ;
345+ let calls = Rc :: new ( RefCell :: new ( Vec :: new ( ) ) ) ;
346+ let calls_h = Rc :: clone ( & calls) ;
347+ let w = ClaudeCode {
348+ config_path : Some ( p. clone ( ) ) ,
349+ allow_cli : true ,
350+ cli : Some ( Box :: new ( move |args| {
351+ record_args ( & calls_h, args) ;
352+ let v = json ! ( {
353+ "mcpServers" : {
354+ "paneflow" : {
355+ "type" : "stdio" ,
356+ "command" : "/data/paneflow-mcp" ,
357+ "args" : [ ]
358+ }
359+ }
360+ } ) ;
361+ std:: fs:: write ( & dest, serde_json:: to_vec ( & v) . unwrap ( ) ) . unwrap ( ) ;
362+ Ok ( ( ) )
363+ } ) ) ,
364+ } ;
365+
366+ assert_eq ! (
367+ w. install( Path :: new( "/data/paneflow-mcp" ) ) . unwrap( ) ,
368+ InstallOutcome :: Installed
369+ ) ;
370+ assert_add_without_remove ( & calls. borrow ( ) ) ;
371+ let v: serde_json:: Value = serde_json:: from_slice ( & std:: fs:: read ( & p) . unwrap ( ) ) . unwrap ( ) ;
372+ assert_eq ! (
373+ v[ "mcpServers" ] [ "paneflow" ] [ "command" ] ,
374+ json!( "/data/paneflow-mcp" )
375+ ) ;
376+ }
377+
378+ #[ test]
379+ fn install_cli_success_falls_back_when_file_does_not_match ( ) {
380+ // CLI exited 0 but did not write our watched file (e.g. it edited
381+ // `$CLAUDE_CONFIG_DIR/.claude.json` while we track `$HOME`).
382+ let dir = tempfile:: TempDir :: new ( ) . unwrap ( ) ;
383+ let p = dir. path ( ) . join ( ".claude.json" ) ;
384+ let calls = Rc :: new ( RefCell :: new ( Vec :: new ( ) ) ) ;
385+ let calls_h = Rc :: clone ( & calls) ;
386+ let w = ClaudeCode {
387+ config_path : Some ( p. clone ( ) ) ,
388+ allow_cli : true ,
389+ cli : Some ( Box :: new ( move |args| {
390+ record_args ( & calls_h, args) ;
391+ Ok ( ( ) )
392+ } ) ) ,
393+ } ;
394+
395+ assert_eq ! (
396+ w. install( Path :: new( "/data/paneflow-mcp" ) ) . unwrap( ) ,
397+ InstallOutcome :: Installed
398+ ) ;
399+ assert_add_without_remove ( & calls. borrow ( ) ) ;
400+ let v: serde_json:: Value = serde_json:: from_slice ( & std:: fs:: read ( & p) . unwrap ( ) ) . unwrap ( ) ;
401+ assert_eq ! (
402+ v[ "mcpServers" ] [ "paneflow" ] [ "command" ] ,
403+ json!( "/data/paneflow-mcp" )
404+ ) ;
405+ assert_eq ! ( v[ "mcpServers" ] [ "paneflow" ] [ "type" ] , json!( "stdio" ) ) ;
406+ }
407+
408+ #[ test]
409+ fn install_cli_failure_falls_back_without_remove ( ) {
410+ // Non-idempotent `mcp add` (stale entry already present) must not
411+ // be preceded by `mcp remove`; the locked merge updates in place.
412+ let dir = tempfile:: TempDir :: new ( ) . unwrap ( ) ;
413+ let p = dir. path ( ) . join ( ".claude.json" ) ;
414+ std:: fs:: write (
415+ & p,
416+ serde_json:: to_vec ( & json ! ( {
417+ "numStartups" : 42 ,
418+ "mcpServers" : {
419+ "github" : { "command" : "gh-mcp" } ,
420+ "paneflow" : {
421+ "type" : "stdio" ,
422+ "command" : "/old/paneflow-mcp" ,
423+ "args" : [ ]
424+ }
425+ }
426+ } ) )
427+ . unwrap ( ) ,
428+ )
429+ . unwrap ( ) ;
430+ let calls = Rc :: new ( RefCell :: new ( Vec :: new ( ) ) ) ;
431+ let calls_h = Rc :: clone ( & calls) ;
432+ let w = ClaudeCode {
433+ config_path : Some ( p. clone ( ) ) ,
434+ allow_cli : true ,
435+ cli : Some ( Box :: new ( move |args| {
436+ record_args ( & calls_h, args) ;
437+ Err ( anyhow ! ( "mcp add: already exists" ) )
438+ } ) ) ,
439+ } ;
440+
441+ assert_eq ! (
442+ w. install( Path :: new( "/data/paneflow-mcp" ) ) . unwrap( ) ,
443+ InstallOutcome :: Updated
444+ ) ;
445+ assert_add_without_remove ( & calls. borrow ( ) ) ;
446+ let v: serde_json:: Value = serde_json:: from_slice ( & std:: fs:: read ( & p) . unwrap ( ) ) . unwrap ( ) ;
447+ assert_eq ! ( v[ "numStartups" ] , json!( 42 ) ) ;
448+ assert_eq ! ( v[ "mcpServers" ] [ "github" ] [ "command" ] , json!( "gh-mcp" ) ) ;
449+ assert_eq ! (
450+ v[ "mcpServers" ] [ "paneflow" ] [ "command" ] ,
451+ json!( "/data/paneflow-mcp" )
452+ ) ;
453+ }
454+
286455 #[ test]
287456 fn uninstall_then_status_roundtrip ( ) {
288457 let dir = tempfile:: TempDir :: new ( ) . unwrap ( ) ;
0 commit comments