Skip to main content

agent_config/agents/
tabnine.rs

1//! Tabnine CLI integration.
2//!
3//! Tabnine packs hooks and MCP servers into a single `settings.json`. The
4//! per-event hook entry shape mirrors Claude's, but Tabnine uses its own
5//! event names (`BeforeTool`, `AfterTool`, `BeforeAgent`, `AfterAgent`,
6//! `SessionStart`, `SessionEnd`, `PreCompress`, `BeforeModel`, `AfterModel`,
7//! `BeforeToolSelection`).
8//!
9//! Surfaces:
10//!
11//! 1. **Hooks**: JSON envelope at `~/.tabnine/agent/settings.json` (Global)
12//!    or `<root>/.tabnine/agent/settings.json` (Local).
13//! 2. **MCP servers**: `mcpServers` JSON map in the same `settings.json`.
14//!
15//! A dedicated prompt-rules markdown file and a directory-scoped skill
16//! contract are not part of Tabnine's documented file-config surface; Tabnine
17//! uses `skills.enabled` / `skills.disabled` arrays inside `settings.json`,
18//! which this crate does not manage.
19
20use std::path::PathBuf;
21
22use serde_json::json;
23
24use crate::agents::planning as agent_planning;
25use crate::error::AgentConfigError;
26use crate::integration::{InstallReport, Integration, McpSurface, UninstallReport};
27use crate::paths;
28use crate::plan::{has_refusal, InstallPlan, PlanTarget, UninstallPlan};
29use crate::scope::{Scope, ScopeKind};
30use crate::spec::{Event, HookSpec, Matcher, McpSpec};
31use crate::status::StatusReport;
32use crate::util::{file_lock, json_patch, mcp_json_object, ownership, planning, safe_fs};
33
34/// Tabnine CLI installer.
35#[derive(Debug, Clone, Copy, Default)]
36pub struct TabnineAgent {
37    _private: (),
38}
39
40impl TabnineAgent {
41    /// Construct an instance. Stateless.
42    pub const fn new() -> Self {
43        Self { _private: () }
44    }
45
46    fn settings_path(scope: &Scope) -> Result<PathBuf, AgentConfigError> {
47        Ok(match scope {
48            Scope::Global => paths::home_dir()?
49                .join(".tabnine")
50                .join("agent")
51                .join("settings.json"),
52            Scope::Local(p) => p.join(".tabnine").join("agent").join("settings.json"),
53        })
54    }
55
56    fn mcp_path(scope: &Scope) -> Result<PathBuf, AgentConfigError> {
57        Self::settings_path(scope)
58    }
59}
60
61impl Integration for TabnineAgent {
62    fn id(&self) -> &'static str {
63        "tabnine"
64    }
65
66    fn display_name(&self) -> &'static str {
67        "Tabnine CLI"
68    }
69
70    fn supported_scopes(&self) -> &'static [ScopeKind] {
71        &[ScopeKind::Global, ScopeKind::Local]
72    }
73
74    fn status(&self, scope: &Scope, tag: &str) -> Result<StatusReport, AgentConfigError> {
75        HookSpec::validate_tag(tag)?;
76        let p = Self::settings_path(scope)?;
77        let presence = json_patch::tagged_hook_presence(&p, &["hooks"], tag)?;
78        Ok(StatusReport::for_tagged_hook(tag, p, presence))
79    }
80
81    fn plan_install(
82        &self,
83        scope: &Scope,
84        spec: &HookSpec,
85    ) -> Result<InstallPlan, AgentConfigError> {
86        HookSpec::validate_tag(&spec.tag)?;
87        let target = PlanTarget::Hook {
88            integration_id: Integration::id(self),
89            scope: scope.clone(),
90            tag: spec.tag.clone(),
91        };
92        let p = Self::settings_path(scope)?;
93        let mut changes = Vec::new();
94
95        let event_key = event_to_string(&spec.event);
96        let matcher_str = matcher_to_tabnine(&spec.matcher);
97        let entry = json!({
98            "matcher": matcher_str,
99            "hooks": [{ "type": "command", "command": spec.command.render_shell() }],
100        });
101        planning::plan_tagged_json_upsert(
102            &mut changes,
103            &p,
104            &["hooks", event_key.as_str()],
105            &spec.tag,
106            entry,
107            |_| {},
108        )?;
109        if has_refusal(&changes) {
110            return Ok(InstallPlan::from_changes(target, changes));
111        }
112        Ok(InstallPlan::from_changes(target, changes))
113    }
114
115    fn plan_uninstall(&self, scope: &Scope, tag: &str) -> Result<UninstallPlan, AgentConfigError> {
116        HookSpec::validate_tag(tag)?;
117        let target = PlanTarget::Hook {
118            integration_id: Integration::id(self),
119            scope: scope.clone(),
120            tag: tag.to_string(),
121        };
122        let mut changes = Vec::new();
123        let p = Self::settings_path(scope)?;
124        planning::plan_tagged_json_remove_under(
125            &mut changes,
126            &p,
127            &["hooks"],
128            tag,
129            planning::json_object_empty,
130            true,
131        )?;
132        Ok(UninstallPlan::from_changes(target, changes))
133    }
134
135    fn install(&self, scope: &Scope, spec: &HookSpec) -> Result<InstallReport, AgentConfigError> {
136        HookSpec::validate_tag(&spec.tag)?;
137        let mut report = InstallReport::default();
138
139        let p = Self::settings_path(scope)?;
140        scope.ensure_contained(&p)?;
141        file_lock::with_lock(&p, || {
142            let mut root = json_patch::read_or_empty(&p)?;
143
144            let event_key = event_to_string(&spec.event);
145            let matcher_str = matcher_to_tabnine(&spec.matcher);
146
147            let entry = json!({
148                "matcher": matcher_str,
149                "hooks": [{ "type": "command", "command": spec.command.render_shell() }],
150            });
151
152            let changed = json_patch::upsert_tagged_array_entry(
153                &mut root,
154                &["hooks", &event_key],
155                &spec.tag,
156                entry,
157            )?;
158
159            if changed {
160                let bytes = json_patch::to_pretty(&root);
161                let outcome = safe_fs::write(scope, &p, &bytes, true)?;
162                if outcome.existed {
163                    report.patched.push(outcome.path.clone());
164                } else {
165                    report.created.push(outcome.path.clone());
166                }
167                if let Some(b) = outcome.backup {
168                    report.backed_up.push(b);
169                }
170            } else {
171                report.already_installed = true;
172            }
173            Ok::<(), AgentConfigError>(())
174        })?;
175
176        Ok(report)
177    }
178
179    fn uninstall(&self, scope: &Scope, tag: &str) -> Result<UninstallReport, AgentConfigError> {
180        HookSpec::validate_tag(tag)?;
181        let mut report = UninstallReport::default();
182
183        let p = Self::settings_path(scope)?;
184        scope.ensure_contained(&p)?;
185        if p.exists() {
186            file_lock::with_lock(&p, || {
187                let mut root = json_patch::read_or_empty(&p)?;
188                let changed =
189                    json_patch::remove_tagged_array_entries_under(&mut root, &["hooks"], tag)?;
190                if changed {
191                    let is_now_empty = root.as_object().map(|o| o.is_empty()).unwrap_or(true);
192                    let bytes = json_patch::to_pretty(&root);
193                    if is_now_empty && safe_fs::restore_backup_if_matches(scope, &p, &bytes)? {
194                        report.restored.push(p.clone());
195                    } else if is_now_empty {
196                        safe_fs::remove_file(scope, &p)?;
197                        report.removed.push(p.clone());
198                    } else {
199                        safe_fs::write(scope, &p, &bytes, false)?;
200                        report.patched.push(p.clone());
201                    }
202                }
203                Ok::<(), AgentConfigError>(())
204            })?;
205        }
206
207        if report.removed.is_empty() && report.patched.is_empty() && report.restored.is_empty() {
208            report.not_installed = true;
209        }
210        Ok(report)
211    }
212}
213
214impl McpSurface for TabnineAgent {
215    fn id(&self) -> &'static str {
216        "tabnine"
217    }
218
219    fn supported_mcp_scopes(&self) -> &'static [ScopeKind] {
220        &[ScopeKind::Global, ScopeKind::Local]
221    }
222
223    fn mcp_status(
224        &self,
225        scope: &Scope,
226        name: &str,
227        expected_owner: &str,
228    ) -> Result<StatusReport, AgentConfigError> {
229        McpSpec::validate_name(name)?;
230        let cfg = Self::mcp_path(scope)?;
231        let ledger = ownership::mcp_ledger_for(&cfg);
232        let presence = mcp_json_object::config_presence(&cfg, name)?;
233        let recorded = ownership::owner_of(&ledger, name)?;
234        Ok(StatusReport::for_mcp(
235            name,
236            cfg,
237            ledger,
238            presence,
239            expected_owner,
240            recorded,
241        ))
242    }
243
244    fn plan_install_mcp(
245        &self,
246        scope: &Scope,
247        spec: &McpSpec,
248    ) -> Result<InstallPlan, AgentConfigError> {
249        agent_planning::mcp_json_object_install(
250            McpSurface::id(self),
251            scope,
252            spec,
253            Self::mcp_path(scope),
254        )
255    }
256
257    fn plan_uninstall_mcp(
258        &self,
259        scope: &Scope,
260        name: &str,
261        owner_tag: &str,
262    ) -> Result<UninstallPlan, AgentConfigError> {
263        agent_planning::mcp_json_object_uninstall(
264            McpSurface::id(self),
265            scope,
266            name,
267            owner_tag,
268            Self::mcp_path(scope),
269        )
270    }
271
272    fn install_mcp(
273        &self,
274        scope: &Scope,
275        spec: &McpSpec,
276    ) -> Result<InstallReport, AgentConfigError> {
277        spec.validate()?;
278        let cfg = Self::mcp_path(scope)?;
279        spec.validate_local_secret_policy(scope)?;
280        scope.ensure_contained(&cfg)?;
281        let ledger = ownership::mcp_ledger_for(&cfg);
282        mcp_json_object::install(&cfg, &ledger, spec)
283    }
284
285    fn uninstall_mcp(
286        &self,
287        scope: &Scope,
288        name: &str,
289        owner_tag: &str,
290    ) -> Result<UninstallReport, AgentConfigError> {
291        McpSpec::validate_name(name)?;
292        HookSpec::validate_tag(owner_tag)?;
293        let cfg = Self::mcp_path(scope)?;
294        scope.ensure_contained(&cfg)?;
295        let ledger = ownership::mcp_ledger_for(&cfg);
296        mcp_json_object::uninstall(&cfg, &ledger, name, owner_tag, "mcp server")
297    }
298}
299
300fn matcher_to_tabnine(m: &Matcher) -> String {
301    match m {
302        Matcher::All => "*".to_string(),
303        Matcher::Bash => "Bash".to_string(),
304        Matcher::Exact(s) => s.clone(),
305        Matcher::AnyOf(names) => names.join("|"),
306        Matcher::Regex(s) => s.clone(),
307    }
308}
309
310/// Tabnine event names: `BeforeTool` / `AfterTool` rather than Claude's
311/// `PreToolUse` / `PostToolUse`.
312fn event_to_string(e: &Event) -> String {
313    match e {
314        Event::PreToolUse => "BeforeTool".into(),
315        Event::PostToolUse => "AfterTool".into(),
316        Event::Custom(s) => s.clone(),
317        other => other.as_str().into(),
318    }
319}
320
321#[cfg(test)]
322mod tests {
323    use super::*;
324    use serde_json::Value;
325    use tempfile::tempdir;
326
327    fn local_spec(tag: &str) -> HookSpec {
328        HookSpec::builder(tag)
329            .command_program("myapp", ["hook"])
330            .matcher(Matcher::Bash)
331            .event(Event::PreToolUse)
332            .build()
333    }
334
335    fn mcp_spec(name: &str, owner: &str) -> McpSpec {
336        McpSpec::builder(name)
337            .owner(owner)
338            .stdio("npx", ["-y", "@example/server"])
339            .build()
340    }
341
342    fn read_json(p: &std::path::Path) -> Value {
343        serde_json::from_slice(&std::fs::read(p).unwrap()).unwrap()
344    }
345
346    #[test]
347    fn install_writes_before_tool_event() {
348        let dir = tempdir().unwrap();
349        let agent = TabnineAgent::new();
350        let scope = Scope::Local(dir.path().to_path_buf());
351        agent.install(&scope, &local_spec("alpha")).unwrap();
352
353        let v = read_json(&dir.path().join(".tabnine/agent/settings.json"));
354        assert_eq!(v["hooks"]["BeforeTool"][0]["matcher"], json!("Bash"));
355        assert_eq!(
356            v["hooks"]["BeforeTool"][0]["_agent_config_tag"],
357            json!("alpha")
358        );
359    }
360
361    #[test]
362    fn post_tool_use_maps_to_after_tool() {
363        let dir = tempdir().unwrap();
364        let agent = TabnineAgent::new();
365        let scope = Scope::Local(dir.path().to_path_buf());
366        let spec = HookSpec::builder("alpha")
367            .command_program("noop", [] as [&str; 0])
368            .event(Event::PostToolUse)
369            .build();
370        agent.install(&scope, &spec).unwrap();
371        let v = read_json(&dir.path().join(".tabnine/agent/settings.json"));
372        assert!(v["hooks"]["AfterTool"].is_array());
373    }
374
375    #[test]
376    fn install_idempotent() {
377        let dir = tempdir().unwrap();
378        let agent = TabnineAgent::new();
379        let scope = Scope::Local(dir.path().to_path_buf());
380        let spec = local_spec("alpha");
381        agent.install(&scope, &spec).unwrap();
382        let r2 = agent.install(&scope, &spec).unwrap();
383        assert!(r2.already_installed);
384    }
385
386    #[test]
387    fn install_uninstall_round_trip() {
388        let dir = tempdir().unwrap();
389        let agent = TabnineAgent::new();
390        let scope = Scope::Local(dir.path().to_path_buf());
391        agent.install(&scope, &local_spec("alpha")).unwrap();
392        agent.uninstall(&scope, "alpha").unwrap();
393        assert!(!dir.path().join(".tabnine/agent/settings.json").exists());
394    }
395
396    #[test]
397    fn hook_and_mcp_share_settings_json() {
398        let dir = tempdir().unwrap();
399        let agent = TabnineAgent::new();
400        let scope = Scope::Local(dir.path().to_path_buf());
401        agent.install(&scope, &local_spec("alpha")).unwrap();
402        agent
403            .install_mcp(&scope, &mcp_spec("github", "myapp"))
404            .unwrap();
405        let v = read_json(&dir.path().join(".tabnine/agent/settings.json"));
406        assert!(v["hooks"]["BeforeTool"].is_array());
407        assert_eq!(v["mcpServers"]["github"]["command"], json!("npx"));
408    }
409}