Skip to content

Commit 38ff097

Browse files
RealZSTclaude
andcommitted
fix: clean up agent config files when deleting plugins
Delete now removes config entries for all agents that have native plugin state: Claude (settings.json), Codex (config.toml), Gemini (extension-enablement.json), Copilot (state.vscdb). Fixes stale disabled state surviving reinstall. Also fixes Claude delete using wrong config path (mcp_config_path → plugin_config_path), injects home path into Gemini deployer for testability, and shows config cleanup paths in the delete confirmation dialog. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 82c846b commit 38ff097

4 files changed

Lines changed: 299 additions & 67 deletions

File tree

crates/hk-core/src/deployer.rs

Lines changed: 212 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -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.
700772
pub 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
}

crates/hk-core/src/manager.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -267,7 +267,9 @@ fn toggle_plugin(ext: &Extension, enabled: bool, store: &Store, adapters: &[Box<
267267
store.set_disabled_config(&ext.id, None)?;
268268
} else if a.name() == "gemini" {
269269
let extensions_dir = a.base_dir().join("extensions");
270-
deployer::set_gemini_extension_enabled(&extensions_dir, &ext.name, enabled)?;
270+
let home = dirs::home_dir()
271+
.ok_or_else(|| HkError::Internal("Cannot determine home directory".into()))?;
272+
deployer::set_gemini_extension_enabled(&extensions_dir, &ext.name, enabled, &home)?;
271273
store.set_disabled_config(&ext.id, None)?;
272274
} else if a.name() == "copilot" {
273275
// Check if this is a VS Code agent plugin (has uri from read_plugins).

0 commit comments

Comments
 (0)