@@ -8,6 +8,7 @@ pub mod ffmpeg;
88pub mod frontend_design;
99pub mod playwright;
1010pub mod ralph_loop;
11+ pub mod rtk;
1112pub mod serena;
1213pub mod sourcegraph;
1314pub mod superpowers;
@@ -33,6 +34,111 @@ pub struct PluginInstallConfig {
3334 pub config_content : String ,
3435}
3536
37+ impl PluginInstallConfig {
38+ /// Resolve `~` in `target_path` to the given home directory.
39+ pub fn resolve_path ( & self , home : & Path ) -> PathBuf {
40+ let s = self . target_path . to_string_lossy ( ) ;
41+ if s. starts_with ( "~/" ) {
42+ home. join ( & s[ 2 ..] )
43+ } else {
44+ self . target_path . clone ( )
45+ }
46+ }
47+
48+ /// Write the plugin config into the target settings file.
49+ ///
50+ /// For JSON files (`.json`): deep-merges `config_content` into the
51+ /// existing file, preserving other keys. Creates the file and parent
52+ /// directories if they don't exist.
53+ ///
54+ /// Returns the resolved path that was written to.
55+ pub fn apply_install ( & self , home : & Path ) -> Result < PathBuf , ApplyInstallError > {
56+ let resolved = self . resolve_path ( home) ;
57+
58+ if let Some ( parent) = resolved. parent ( ) {
59+ std:: fs:: create_dir_all ( parent)
60+ . map_err ( |e| ApplyInstallError :: Io ( parent. to_path_buf ( ) , e) ) ?;
61+ }
62+
63+ let is_json = resolved
64+ . extension ( )
65+ . map ( |ext| ext == "json" )
66+ . unwrap_or ( false ) ;
67+
68+ if is_json {
69+ self . apply_json_merge ( & resolved) ?;
70+ } else {
71+ // For non-JSON files (CLAUDE.md, instructions.md, config.toml),
72+ // append if not already present.
73+ let existing = std:: fs:: read_to_string ( & resolved) . unwrap_or_default ( ) ;
74+ if !existing. contains ( & self . config_content ) {
75+ let mut content = existing;
76+ if !content. is_empty ( ) && !content. ends_with ( '\n' ) {
77+ content. push ( '\n' ) ;
78+ }
79+ content. push_str ( & self . config_content ) ;
80+ std:: fs:: write ( & resolved, content)
81+ . map_err ( |e| ApplyInstallError :: Io ( resolved. clone ( ) , e) ) ?;
82+ }
83+ }
84+
85+ Ok ( resolved)
86+ }
87+
88+ fn apply_json_merge ( & self , path : & Path ) -> Result < ( ) , ApplyInstallError > {
89+ let existing_str = std:: fs:: read_to_string ( path) . unwrap_or_else ( |_| "{}" . to_string ( ) ) ;
90+ let mut existing: serde_json:: Value = serde_json:: from_str ( & existing_str)
91+ . map_err ( |e| ApplyInstallError :: Json ( path. to_path_buf ( ) , e) ) ?;
92+
93+ let incoming: serde_json:: Value = serde_json:: from_str ( & self . config_content )
94+ . map_err ( |e| ApplyInstallError :: Json ( path. to_path_buf ( ) , e) ) ?;
95+
96+ json_deep_merge ( & mut existing, & incoming) ;
97+
98+ let output = serde_json:: to_string_pretty ( & existing)
99+ . map_err ( |e| ApplyInstallError :: Json ( path. to_path_buf ( ) , e) ) ?;
100+ std:: fs:: write ( path, output. as_bytes ( ) )
101+ . map_err ( |e| ApplyInstallError :: Io ( path. to_path_buf ( ) , e) ) ?;
102+
103+ Ok ( ( ) )
104+ }
105+ }
106+
107+ /// Recursively merge `source` into `target`. Objects are merged key-by-key;
108+ /// all other types overwrite.
109+ fn json_deep_merge ( target : & mut serde_json:: Value , source : & serde_json:: Value ) {
110+ match ( target, source) {
111+ ( serde_json:: Value :: Object ( t) , serde_json:: Value :: Object ( s) ) => {
112+ for ( key, value) in s {
113+ json_deep_merge (
114+ t. entry ( key. clone ( ) ) . or_insert ( serde_json:: Value :: Null ) ,
115+ value,
116+ ) ;
117+ }
118+ }
119+ ( target, source) => {
120+ * target = source. clone ( ) ;
121+ }
122+ }
123+ }
124+
125+ #[ derive( Debug ) ]
126+ pub enum ApplyInstallError {
127+ Io ( PathBuf , std:: io:: Error ) ,
128+ Json ( PathBuf , serde_json:: Error ) ,
129+ }
130+
131+ impl std:: fmt:: Display for ApplyInstallError {
132+ fn fmt ( & self , f : & mut std:: fmt:: Formatter < ' _ > ) -> std:: fmt:: Result {
133+ match self {
134+ Self :: Io ( path, e) => write ! ( f, "IO error at {}: {}" , path. display( ) , e) ,
135+ Self :: Json ( path, e) => write ! ( f, "JSON error at {}: {}" , path. display( ) , e) ,
136+ }
137+ }
138+ }
139+
140+ impl std:: error:: Error for ApplyInstallError { }
141+
36142/// A shared, data-driven definition for an ecosystem plugin.
37143///
38144/// Each ecosystem module defines a `plugin()` function that returns one of
@@ -331,4 +437,181 @@ mod tests {
331437 // Should not find project-scope match, falls through to None
332438 assert ! ( !result. installed || result. scope != InstallScope :: Project ) ;
333439 }
440+
441+ // ── resolve_path tests ──────────────────────────────────────────
442+
443+ #[ test]
444+ fn test_resolve_path_tilde ( ) {
445+ let config = PluginInstallConfig {
446+ agent : "claude-code" . into ( ) ,
447+ scope : InstallScope :: User ,
448+ target_path : PathBuf :: from ( "~/.claude/settings.json" ) ,
449+ config_content : "{}" . into ( ) ,
450+ } ;
451+ let resolved = config. resolve_path ( Path :: new ( "/home/user" ) ) ;
452+ assert_eq ! ( resolved, PathBuf :: from( "/home/user/.claude/settings.json" ) ) ;
453+ }
454+
455+ #[ test]
456+ fn test_resolve_path_relative ( ) {
457+ let config = PluginInstallConfig {
458+ agent : "claude-code" . into ( ) ,
459+ scope : InstallScope :: Project ,
460+ target_path : PathBuf :: from ( ".claude/settings.json" ) ,
461+ config_content : "{}" . into ( ) ,
462+ } ;
463+ let resolved = config. resolve_path ( Path :: new ( "/home/user" ) ) ;
464+ assert_eq ! ( resolved, PathBuf :: from( ".claude/settings.json" ) ) ;
465+ }
466+
467+ // ── apply_install JSON merge tests ──────────────────────────────
468+
469+ #[ test]
470+ fn test_apply_install_creates_new_file ( ) {
471+ let tmp = tempfile:: tempdir ( ) . unwrap ( ) ;
472+ let config = PluginInstallConfig {
473+ agent : "claude-code" . into ( ) ,
474+ scope : InstallScope :: User ,
475+ target_path : PathBuf :: from ( "~/.claude/settings.json" ) ,
476+ config_content : r#"{"mcpServers":{"test":{"command":"npx"}}}"# . into ( ) ,
477+ } ;
478+ let path = config. apply_install ( tmp. path ( ) ) . unwrap ( ) ;
479+ let content: serde_json:: Value =
480+ serde_json:: from_str ( & std:: fs:: read_to_string ( & path) . unwrap ( ) ) . unwrap ( ) ;
481+ assert ! ( content[ "mcpServers" ] [ "test" ] [ "command" ] . as_str( ) == Some ( "npx" ) ) ;
482+ }
483+
484+ #[ test]
485+ fn test_apply_install_merges_into_existing ( ) {
486+ let tmp = tempfile:: tempdir ( ) . unwrap ( ) ;
487+ let claude_dir = tmp. path ( ) . join ( ".claude" ) ;
488+ std:: fs:: create_dir_all ( & claude_dir) . unwrap ( ) ;
489+ std:: fs:: write (
490+ claude_dir. join ( "settings.json" ) ,
491+ r#"{"mcpServers":{"existing":{"command":"node"}},"other":"keep"}"# ,
492+ )
493+ . unwrap ( ) ;
494+
495+ let config = PluginInstallConfig {
496+ agent : "claude-code" . into ( ) ,
497+ scope : InstallScope :: User ,
498+ target_path : PathBuf :: from ( "~/.claude/settings.json" ) ,
499+ config_content : r#"{"mcpServers":{"new-plugin":{"command":"npx"}}}"# . into ( ) ,
500+ } ;
501+ let path = config. apply_install ( tmp. path ( ) ) . unwrap ( ) ;
502+ let content: serde_json:: Value =
503+ serde_json:: from_str ( & std:: fs:: read_to_string ( & path) . unwrap ( ) ) . unwrap ( ) ;
504+ // Existing entries preserved
505+ assert_eq ! ( content[ "mcpServers" ] [ "existing" ] [ "command" ] , "node" ) ;
506+ // New entry added
507+ assert_eq ! ( content[ "mcpServers" ] [ "new-plugin" ] [ "command" ] , "npx" ) ;
508+ // Other keys preserved
509+ assert_eq ! ( content[ "other" ] , "keep" ) ;
510+ }
511+
512+ #[ test]
513+ fn test_apply_install_idempotent ( ) {
514+ let tmp = tempfile:: tempdir ( ) . unwrap ( ) ;
515+ let config = PluginInstallConfig {
516+ agent : "claude-code" . into ( ) ,
517+ scope : InstallScope :: User ,
518+ target_path : PathBuf :: from ( "~/.claude/settings.json" ) ,
519+ config_content : r#"{"mcpServers":{"test":{"command":"npx"}}}"# . into ( ) ,
520+ } ;
521+ config. apply_install ( tmp. path ( ) ) . unwrap ( ) ;
522+ config. apply_install ( tmp. path ( ) ) . unwrap ( ) ;
523+ let path = tmp. path ( ) . join ( ".claude/settings.json" ) ;
524+ let content: serde_json:: Value =
525+ serde_json:: from_str ( & std:: fs:: read_to_string ( & path) . unwrap ( ) ) . unwrap ( ) ;
526+ // Only one entry, not duplicated
527+ assert_eq ! ( content[ "mcpServers" ] . as_object( ) . unwrap( ) . len( ) , 1 ) ;
528+ }
529+
530+ #[ test]
531+ fn test_apply_install_invalid_existing_json ( ) {
532+ let tmp = tempfile:: tempdir ( ) . unwrap ( ) ;
533+ let claude_dir = tmp. path ( ) . join ( ".claude" ) ;
534+ std:: fs:: create_dir_all ( & claude_dir) . unwrap ( ) ;
535+ std:: fs:: write ( claude_dir. join ( "settings.json" ) , "not json!!!" ) . unwrap ( ) ;
536+
537+ let config = PluginInstallConfig {
538+ agent : "claude-code" . into ( ) ,
539+ scope : InstallScope :: User ,
540+ target_path : PathBuf :: from ( "~/.claude/settings.json" ) ,
541+ config_content : r#"{"mcpServers":{}}"# . into ( ) ,
542+ } ;
543+ let result = config. apply_install ( tmp. path ( ) ) ;
544+ assert ! ( result. is_err( ) ) ;
545+ assert ! ( result. unwrap_err( ) . to_string( ) . contains( "JSON error" ) ) ;
546+ }
547+
548+ // ── apply_install non-JSON (append) tests ───────────────────────
549+
550+ #[ test]
551+ fn test_apply_install_appends_to_md ( ) {
552+ let tmp = tempfile:: tempdir ( ) . unwrap ( ) ;
553+ let claude_dir = tmp. path ( ) . join ( ".claude" ) ;
554+ std:: fs:: create_dir_all ( & claude_dir) . unwrap ( ) ;
555+ std:: fs:: write ( claude_dir. join ( "CLAUDE.md" ) , "# Existing\n " ) . unwrap ( ) ;
556+
557+ let config = PluginInstallConfig {
558+ agent : "claude-code" . into ( ) ,
559+ scope : InstallScope :: User ,
560+ target_path : PathBuf :: from ( "~/.claude/CLAUDE.md" ) ,
561+ config_content : "# Superpowers\n " . into ( ) ,
562+ } ;
563+ config. apply_install ( tmp. path ( ) ) . unwrap ( ) ;
564+ let content = std:: fs:: read_to_string ( claude_dir. join ( "CLAUDE.md" ) ) . unwrap ( ) ;
565+ assert ! ( content. contains( "# Existing" ) ) ;
566+ assert ! ( content. contains( "# Superpowers" ) ) ;
567+ }
568+
569+ #[ test]
570+ fn test_apply_install_md_idempotent ( ) {
571+ let tmp = tempfile:: tempdir ( ) . unwrap ( ) ;
572+ let config = PluginInstallConfig {
573+ agent : "claude-code" . into ( ) ,
574+ scope : InstallScope :: User ,
575+ target_path : PathBuf :: from ( "~/.claude/CLAUDE.md" ) ,
576+ config_content : "# Superpowers\n " . into ( ) ,
577+ } ;
578+ config. apply_install ( tmp. path ( ) ) . unwrap ( ) ;
579+ config. apply_install ( tmp. path ( ) ) . unwrap ( ) ;
580+ let content = std:: fs:: read_to_string ( tmp. path ( ) . join ( ".claude/CLAUDE.md" ) ) . unwrap ( ) ;
581+ assert_eq ! ( content. matches( "# Superpowers" ) . count( ) , 1 ) ;
582+ }
583+
584+ // ── json_deep_merge tests ───────────────────────────────────────
585+
586+ #[ test]
587+ fn test_json_deep_merge_objects ( ) {
588+ let mut target: serde_json:: Value = serde_json:: json!( { "a" : 1 , "nested" : { "x" : 10 } } ) ;
589+ let source = serde_json:: json!( { "b" : 2 , "nested" : { "y" : 20 } } ) ;
590+ json_deep_merge ( & mut target, & source) ;
591+ assert_eq ! ( target[ "a" ] , 1 ) ;
592+ assert_eq ! ( target[ "b" ] , 2 ) ;
593+ assert_eq ! ( target[ "nested" ] [ "x" ] , 10 ) ;
594+ assert_eq ! ( target[ "nested" ] [ "y" ] , 20 ) ;
595+ }
596+
597+ #[ test]
598+ fn test_json_deep_merge_overwrite_scalar ( ) {
599+ let mut target: serde_json:: Value = serde_json:: json!( { "a" : 1 } ) ;
600+ let source = serde_json:: json!( { "a" : 99 } ) ;
601+ json_deep_merge ( & mut target, & source) ;
602+ assert_eq ! ( target[ "a" ] , 99 ) ;
603+ }
604+
605+ // ── ApplyInstallError display ───────────────────────────────────
606+
607+ #[ test]
608+ fn test_apply_install_error_display ( ) {
609+ let err = ApplyInstallError :: Io (
610+ PathBuf :: from ( "/test" ) ,
611+ std:: io:: Error :: new ( std:: io:: ErrorKind :: PermissionDenied , "denied" ) ,
612+ ) ;
613+ let msg = err. to_string ( ) ;
614+ assert ! ( msg. contains( "IO error" ) ) ;
615+ assert ! ( msg. contains( "/test" ) ) ;
616+ }
334617}
0 commit comments