@@ -643,6 +643,40 @@ pub fn set_codex_plugin_enabled(
643643 Ok ( ( ) )
644644}
645645
646+ /// Remove a [plugins."plugin_key"] entry from Codex config.toml.
647+ pub fn remove_codex_plugin_entry (
648+ config_path : & Path ,
649+ plugin_key : & str ,
650+ ) -> Result < ( ) , HkError > {
651+ if !config_path. exists ( ) {
652+ return Ok ( ( ) ) ;
653+ }
654+ let file = std:: fs:: OpenOptions :: new ( )
655+ . read ( true )
656+ . write ( true )
657+ . open ( config_path) ?;
658+ file. lock_exclusive ( ) ?;
659+
660+ let mut content = String :: new ( ) ;
661+ ( & file) . read_to_string ( & mut content) ?;
662+ let mut doc: toml:: Table = content
663+ . parse :: < toml:: Table > ( )
664+ . map_err ( |e| HkError :: ConfigCorrupted ( e. to_string ( ) ) ) ?;
665+
666+ if let Some ( plugins) = doc. get_mut ( "plugins" ) . and_then ( |v| v. as_table_mut ( ) ) {
667+ plugins. remove ( plugin_key) ;
668+ }
669+
670+ let output = toml:: to_string_pretty ( & doc) . map_err ( |e| HkError :: Internal ( e. to_string ( ) ) ) ?;
671+ ( & file) . seek ( SeekFrom :: Start ( 0 ) ) ?;
672+ file. set_len ( 0 ) ?;
673+ ( & file) . write_all ( output. as_bytes ( ) ) ?;
674+ ( & file) . flush ( ) ?;
675+
676+ file. unlock ( ) ?;
677+ Ok ( ( ) )
678+ }
679+
646680/// Set VS Code agent plugin enablement in state.vscdb.
647681/// Reads the current `agentPlugins.enablement` array, updates the entry for the
648682/// given plugin URI, and writes it back. Creates the entry if it doesn't exist.
@@ -695,12 +729,97 @@ pub fn set_vscode_plugin_enabled(
695729 Ok ( ( ) )
696730}
697731
732+ /// Remove a plugin entry from VS Code's state.vscdb enablement array.
733+ pub fn remove_vscode_plugin_entry (
734+ vscode_user_dir : & Path ,
735+ plugin_uri : & str ,
736+ ) -> Result < ( ) , HkError > {
737+ let db_path = vscode_user_dir. join ( "globalStorage" ) . join ( "state.vscdb" ) ;
738+ if !db_path. exists ( ) {
739+ return Ok ( ( ) ) ;
740+ }
741+ let conn = rusqlite:: Connection :: open ( & db_path)
742+ . map_err ( |e| HkError :: Internal ( format ! ( "Failed to open VS Code state DB: {}" , e) ) ) ?;
743+
744+ let current: String = conn
745+ . query_row (
746+ "SELECT value FROM ItemTable WHERE key = 'agentPlugins.enablement'" ,
747+ [ ] ,
748+ |row| row. get ( 0 ) ,
749+ )
750+ . unwrap_or_else ( |_| "[]" . to_string ( ) ) ;
751+
752+ let mut entries: Vec < ( String , bool ) > =
753+ serde_json:: from_str ( & current) . unwrap_or_default ( ) ;
754+
755+ entries. retain ( |e| e. 0 != plugin_uri) ;
756+
757+ let new_value = serde_json:: to_string ( & entries)
758+ . map_err ( |e| HkError :: Internal ( e. to_string ( ) ) ) ?;
759+
760+ conn. execute (
761+ "INSERT INTO ItemTable (key, value) VALUES ('agentPlugins.enablement', ?1)
762+ ON CONFLICT(key) DO UPDATE SET value = ?1" ,
763+ rusqlite:: params![ new_value] ,
764+ )
765+ . map_err ( |e| HkError :: Internal ( format ! ( "Failed to update VS Code state DB: {}" , e) ) ) ?;
766+
767+ Ok ( ( ) )
768+ }
769+
698770/// Set Gemini extension enablement in extension-enablement.json.
699771/// Updates only the user-scope rule (`{homedir}/*`) and preserves workspace-scope rules.
700772pub fn set_gemini_extension_enabled (
701773 extensions_dir : & Path ,
702774 extension_name : & str ,
703775 enabled : bool ,
776+ home : & Path ,
777+ ) -> Result < ( ) , HkError > {
778+ let home_str = home. to_string_lossy ( ) ;
779+ let enable_rule = format ! ( "{}/*" , home_str) ;
780+ let disable_rule = format ! ( "!{}/*" , home_str) ;
781+
782+ modify_gemini_enablement ( extensions_dir, |config| {
783+ let entry = config
784+ . entry ( extension_name. to_string ( ) )
785+ . or_insert_with ( || serde_json:: json!( { "overrides" : [ ] } ) ) ;
786+ let overrides = entry
787+ . get_mut ( "overrides" )
788+ . and_then ( |v| v. as_array_mut ( ) )
789+ . ok_or_else ( || HkError :: ConfigCorrupted ( "overrides is not an array" . into ( ) ) ) ?;
790+
791+ // Remove existing user-scope rules (both enable and disable)
792+ overrides. retain ( |v| {
793+ let s = v. as_str ( ) . unwrap_or ( "" ) ;
794+ s != enable_rule && s != disable_rule
795+ } ) ;
796+
797+ // Add the new rule
798+ let rule = if enabled { & enable_rule } else { & disable_rule } ;
799+ overrides. push ( serde_json:: Value :: String ( rule. to_string ( ) ) ) ;
800+ Ok ( ( ) )
801+ } )
802+ }
803+
804+ /// Remove an extension entry from Gemini's extension-enablement.json.
805+ pub fn remove_gemini_extension_entry (
806+ extensions_dir : & Path ,
807+ extension_name : & str ,
808+ ) -> Result < ( ) , HkError > {
809+ let enablement_path = extensions_dir. join ( "extension-enablement.json" ) ;
810+ if !enablement_path. exists ( ) {
811+ return Ok ( ( ) ) ;
812+ }
813+ modify_gemini_enablement ( extensions_dir, |config| {
814+ config. remove ( extension_name) ;
815+ Ok ( ( ) )
816+ } )
817+ }
818+
819+ /// Locked read-modify-write for extension-enablement.json.
820+ fn modify_gemini_enablement (
821+ extensions_dir : & Path ,
822+ modify : impl FnOnce ( & mut serde_json:: Map < String , serde_json:: Value > ) -> Result < ( ) , HkError > ,
704823) -> Result < ( ) , HkError > {
705824 let enablement_path = extensions_dir. join ( "extension-enablement.json" ) ;
706825 if let Some ( parent) = enablement_path. parent ( ) {
@@ -723,29 +842,7 @@ pub fn set_gemini_extension_enabled(
723842 . map_err ( |e| HkError :: ConfigCorrupted ( format ! ( "extension-enablement.json: {}" , e) ) ) ?
724843 } ;
725844
726- let home = dirs:: home_dir ( )
727- . ok_or_else ( || HkError :: Internal ( "Cannot determine home directory" . into ( ) ) ) ?;
728- let home_str = home. to_string_lossy ( ) ;
729- let enable_rule = format ! ( "{}/*" , home_str) ;
730- let disable_rule = format ! ( "!{}/*" , home_str) ;
731-
732- let entry = config
733- . entry ( extension_name)
734- . or_insert_with ( || serde_json:: json!( { "overrides" : [ ] } ) ) ;
735- let overrides = entry
736- . get_mut ( "overrides" )
737- . and_then ( |v| v. as_array_mut ( ) )
738- . ok_or_else ( || HkError :: ConfigCorrupted ( "overrides is not an array" . into ( ) ) ) ?;
739-
740- // Remove existing user-scope rules (both enable and disable)
741- overrides. retain ( |v| {
742- let s = v. as_str ( ) . unwrap_or ( "" ) ;
743- s != enable_rule && s != disable_rule
744- } ) ;
745-
746- // Add the new rule
747- let rule = if enabled { & enable_rule } else { & disable_rule } ;
748- overrides. push ( serde_json:: Value :: String ( rule. to_string ( ) ) ) ;
845+ modify ( & mut config) ?;
749846
750847 let output = serde_json:: to_string_pretty ( & config)
751848 . map_err ( |e| HkError :: Internal ( e. to_string ( ) ) ) ?;
@@ -1642,72 +1739,69 @@ mod tests {
16421739 #[ test]
16431740 fn test_set_gemini_extension_enabled_disable ( ) {
16441741 let dir = TempDir :: new ( ) . unwrap ( ) ;
1645- let ext_dir = dir. path ( ) . join ( "extensions" ) ;
1742+ let home = dir. path ( ) ;
1743+ let ext_dir = home. join ( ".gemini" ) . join ( "extensions" ) ;
16461744 std:: fs:: create_dir_all ( & ext_dir) . unwrap ( ) ;
16471745
1648- set_gemini_extension_enabled ( & ext_dir, "my-ext" , false ) . unwrap ( ) ;
1746+ set_gemini_extension_enabled ( & ext_dir, "my-ext" , false , home ) . unwrap ( ) ;
16491747
16501748 let content: serde_json:: Value = serde_json:: from_str (
16511749 & std:: fs:: read_to_string ( ext_dir. join ( "extension-enablement.json" ) ) . unwrap ( ) ,
16521750 ) . unwrap ( ) ;
16531751 let overrides = content[ "my-ext" ] [ "overrides" ] . as_array ( ) . unwrap ( ) ;
16541752 assert_eq ! ( overrides. len( ) , 1 ) ;
1655- let rule = overrides[ 0 ] . as_str ( ) . unwrap ( ) ;
1656- assert ! ( rule. starts_with( '!' ) , "disable rule should start with !" ) ;
1657- assert ! ( rule. ends_with( "/*" ) ) ;
1753+ let expected = format ! ( "!{}/*" , home. to_string_lossy( ) ) ;
1754+ assert_eq ! ( overrides[ 0 ] . as_str( ) . unwrap( ) , expected) ;
16581755 }
16591756
16601757 #[ test]
16611758 fn test_set_gemini_extension_enabled_enable ( ) {
16621759 let dir = TempDir :: new ( ) . unwrap ( ) ;
1663- let ext_dir = dir. path ( ) . join ( "extensions" ) ;
1760+ let home = dir. path ( ) ;
1761+ let ext_dir = home. join ( ".gemini" ) . join ( "extensions" ) ;
16641762 std:: fs:: create_dir_all ( & ext_dir) . unwrap ( ) ;
16651763
1666- // First disable, then enable
1667- set_gemini_extension_enabled ( & ext_dir, "my-ext" , false ) . unwrap ( ) ;
1668- set_gemini_extension_enabled ( & ext_dir, "my-ext" , true ) . unwrap ( ) ;
1764+ set_gemini_extension_enabled ( & ext_dir, "my-ext" , false , home) . unwrap ( ) ;
1765+ set_gemini_extension_enabled ( & ext_dir, "my-ext" , true , home) . unwrap ( ) ;
16691766
16701767 let content: serde_json:: Value = serde_json:: from_str (
16711768 & std:: fs:: read_to_string ( ext_dir. join ( "extension-enablement.json" ) ) . unwrap ( ) ,
16721769 ) . unwrap ( ) ;
16731770 let overrides = content[ "my-ext" ] [ "overrides" ] . as_array ( ) . unwrap ( ) ;
1674- // Should have exactly one rule (old disable removed, new enable added)
16751771 assert_eq ! ( overrides. len( ) , 1 ) ;
1676- let rule = overrides [ 0 ] . as_str ( ) . unwrap ( ) ;
1677- assert ! ( !rule . starts_with ( '!' ) , "enable rule should not start with !" ) ;
1772+ let expected = format ! ( "{}/*" , home . to_string_lossy ( ) ) ;
1773+ assert_eq ! ( overrides [ 0 ] . as_str ( ) . unwrap ( ) , expected ) ;
16781774 }
16791775
16801776 #[ test]
16811777 fn test_set_gemini_extension_enabled_preserves_other_extensions ( ) {
16821778 let dir = TempDir :: new ( ) . unwrap ( ) ;
1683- let ext_dir = dir. path ( ) . join ( "extensions" ) ;
1779+ let home = dir. path ( ) ;
1780+ let ext_dir = home. join ( ".gemini" ) . join ( "extensions" ) ;
16841781 std:: fs:: create_dir_all ( & ext_dir) . unwrap ( ) ;
16851782
1686- // Pre-populate with another extension's rules
16871783 std:: fs:: write (
16881784 ext_dir. join ( "extension-enablement.json" ) ,
16891785 r#"{"other-ext": {"overrides": ["!/some/workspace/*"]}}"# ,
16901786 ) . unwrap ( ) ;
16911787
1692- set_gemini_extension_enabled ( & ext_dir, "my-ext" , false ) . unwrap ( ) ;
1788+ set_gemini_extension_enabled ( & ext_dir, "my-ext" , false , home ) . unwrap ( ) ;
16931789
16941790 let content: serde_json:: Value = serde_json:: from_str (
16951791 & std:: fs:: read_to_string ( ext_dir. join ( "extension-enablement.json" ) ) . unwrap ( ) ,
16961792 ) . unwrap ( ) ;
1697- // other-ext should be untouched
16981793 assert ! ( content[ "other-ext" ] [ "overrides" ] . as_array( ) . unwrap( ) . len( ) == 1 ) ;
16991794 assert ! ( content[ "my-ext" ] [ "overrides" ] . as_array( ) . unwrap( ) . len( ) == 1 ) ;
17001795 }
17011796
17021797 #[ test]
17031798 fn test_set_gemini_extension_enabled_preserves_workspace_rules ( ) {
17041799 let dir = TempDir :: new ( ) . unwrap ( ) ;
1705- let ext_dir = dir. path ( ) . join ( "extensions" ) ;
1800+ let home = dir. path ( ) ;
1801+ let ext_dir = home. join ( ".gemini" ) . join ( "extensions" ) ;
17061802 std:: fs:: create_dir_all ( & ext_dir) . unwrap ( ) ;
17071803
1708- let home = dirs:: home_dir ( ) . unwrap ( ) ;
17091804 let home_str = home. to_string_lossy ( ) ;
1710- // Pre-populate with a workspace-scope rule
17111805 let initial = serde_json:: json!( {
17121806 "my-ext" : { "overrides" : [
17131807 format!( "!/some/workspace/*" ) ,
@@ -1718,16 +1812,89 @@ mod tests {
17181812 initial. to_string ( ) ,
17191813 ) . unwrap ( ) ;
17201814
1721- // Disable at user scope
1722- set_gemini_extension_enabled ( & ext_dir, "my-ext" , false ) . unwrap ( ) ;
1815+ set_gemini_extension_enabled ( & ext_dir, "my-ext" , false , home) . unwrap ( ) ;
17231816
17241817 let content: serde_json:: Value = serde_json:: from_str (
17251818 & std:: fs:: read_to_string ( ext_dir. join ( "extension-enablement.json" ) ) . unwrap ( ) ,
17261819 ) . unwrap ( ) ;
17271820 let overrides = content[ "my-ext" ] [ "overrides" ] . as_array ( ) . unwrap ( ) ;
1728- // Should have 2 rules: workspace rule preserved + new user disable rule
17291821 assert_eq ! ( overrides. len( ) , 2 ) ;
17301822 assert_eq ! ( overrides[ 0 ] . as_str( ) . unwrap( ) , "!/some/workspace/*" ) ;
17311823 assert_eq ! ( overrides[ 1 ] . as_str( ) . unwrap( ) , format!( "!{}/*" , home_str) ) ;
17321824 }
1825+
1826+ #[ test]
1827+ fn test_remove_gemini_extension_entry ( ) {
1828+ let dir = TempDir :: new ( ) . unwrap ( ) ;
1829+ let home = dir. path ( ) ;
1830+ let ext_dir = home. join ( ".gemini" ) . join ( "extensions" ) ;
1831+ std:: fs:: create_dir_all ( & ext_dir) . unwrap ( ) ;
1832+
1833+ // Create enablement with two extensions
1834+ set_gemini_extension_enabled ( & ext_dir, "ext-a" , false , home) . unwrap ( ) ;
1835+ set_gemini_extension_enabled ( & ext_dir, "ext-b" , false , home) . unwrap ( ) ;
1836+
1837+ // Remove one
1838+ remove_gemini_extension_entry ( & ext_dir, "ext-a" ) . unwrap ( ) ;
1839+
1840+ let content: serde_json:: Value = serde_json:: from_str (
1841+ & std:: fs:: read_to_string ( ext_dir. join ( "extension-enablement.json" ) ) . unwrap ( ) ,
1842+ ) . unwrap ( ) ;
1843+ assert ! ( content. get( "ext-a" ) . is_none( ) , "ext-a should be removed" ) ;
1844+ assert ! ( content. get( "ext-b" ) . is_some( ) , "ext-b should remain" ) ;
1845+ }
1846+
1847+ #[ test]
1848+ fn test_remove_codex_plugin_entry ( ) {
1849+ let dir = TempDir :: new ( ) . unwrap ( ) ;
1850+ let config = dir. path ( ) . join ( "config.toml" ) ;
1851+
1852+ // Set up two plugin entries
1853+ set_codex_plugin_enabled ( & config, "pluginA@marketplace" , false ) . unwrap ( ) ;
1854+ set_codex_plugin_enabled ( & config, "pluginB@marketplace" , true ) . unwrap ( ) ;
1855+
1856+ // Remove one
1857+ remove_codex_plugin_entry ( & config, "pluginA@marketplace" ) . unwrap ( ) ;
1858+
1859+ let content: toml:: Table = std:: fs:: read_to_string ( & config)
1860+ . unwrap ( )
1861+ . parse ( )
1862+ . unwrap ( ) ;
1863+ let plugins = content[ "plugins" ] . as_table ( ) . unwrap ( ) ;
1864+ assert ! ( !plugins. contains_key( "pluginA@marketplace" ) ) ;
1865+ assert ! ( plugins. contains_key( "pluginB@marketplace" ) ) ;
1866+ }
1867+
1868+ #[ test]
1869+ fn test_remove_vscode_plugin_entry ( ) {
1870+ let dir = TempDir :: new ( ) . unwrap ( ) ;
1871+ let gs = dir. path ( ) . join ( "globalStorage" ) ;
1872+ std:: fs:: create_dir_all ( & gs) . unwrap ( ) ;
1873+ let db_path = gs. join ( "state.vscdb" ) ;
1874+
1875+ // Set up state.vscdb
1876+ let conn = rusqlite:: Connection :: open ( & db_path) . unwrap ( ) ;
1877+ conn. execute (
1878+ "CREATE TABLE IF NOT EXISTS ItemTable (key TEXT UNIQUE, value TEXT)" ,
1879+ [ ] ,
1880+ ) . unwrap ( ) ;
1881+
1882+ // Add two entries
1883+ set_vscode_plugin_enabled ( dir. path ( ) , "file:///plugin-a" , false ) . unwrap ( ) ;
1884+ set_vscode_plugin_enabled ( dir. path ( ) , "file:///plugin-b" , true ) . unwrap ( ) ;
1885+
1886+ // Remove one
1887+ remove_vscode_plugin_entry ( dir. path ( ) , "file:///plugin-a" ) . unwrap ( ) ;
1888+
1889+ let result: String = conn
1890+ . query_row (
1891+ "SELECT value FROM ItemTable WHERE key = 'agentPlugins.enablement'" ,
1892+ [ ] ,
1893+ |row| row. get ( 0 ) ,
1894+ )
1895+ . unwrap ( ) ;
1896+ let entries: Vec < ( String , bool ) > = serde_json:: from_str ( & result) . unwrap ( ) ;
1897+ assert_eq ! ( entries. len( ) , 1 ) ;
1898+ assert_eq ! ( entries[ 0 ] . 0 , "file:///plugin-b" ) ;
1899+ }
17331900}
0 commit comments