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    }
318}
319
320#[cfg(test)]
321mod tests {
322    use super::*;
323    use serde_json::Value;
324    use tempfile::tempdir;
325
326    fn local_spec(tag: &str) -> HookSpec {
327        HookSpec::builder(tag)
328            .command_program("myapp", ["hook"])
329            .matcher(Matcher::Bash)
330            .event(Event::PreToolUse)
331            .build()
332    }
333
334    fn mcp_spec(name: &str, owner: &str) -> McpSpec {
335        McpSpec::builder(name)
336            .owner(owner)
337            .stdio("npx", ["-y", "@example/server"])
338            .build()
339    }
340
341    fn read_json(p: &std::path::Path) -> Value {
342        serde_json::from_slice(&std::fs::read(p).unwrap()).unwrap()
343    }
344
345    #[test]
346    fn install_writes_before_tool_event() {
347        let dir = tempdir().unwrap();
348        let agent = TabnineAgent::new();
349        let scope = Scope::Local(dir.path().to_path_buf());
350        agent.install(&scope, &local_spec("alpha")).unwrap();
351
352        let v = read_json(&dir.path().join(".tabnine/agent/settings.json"));
353        assert_eq!(v["hooks"]["BeforeTool"][0]["matcher"], json!("Bash"));
354        assert_eq!(
355            v["hooks"]["BeforeTool"][0]["_agent_config_tag"],
356            json!("alpha")
357        );
358    }
359
360    #[test]
361    fn post_tool_use_maps_to_after_tool() {
362        let dir = tempdir().unwrap();
363        let agent = TabnineAgent::new();
364        let scope = Scope::Local(dir.path().to_path_buf());
365        let spec = HookSpec::builder("alpha")
366            .command_program("noop", [] as [&str; 0])
367            .event(Event::PostToolUse)
368            .build();
369        agent.install(&scope, &spec).unwrap();
370        let v = read_json(&dir.path().join(".tabnine/agent/settings.json"));
371        assert!(v["hooks"]["AfterTool"].is_array());
372    }
373
374    #[test]
375    fn install_idempotent() {
376        let dir = tempdir().unwrap();
377        let agent = TabnineAgent::new();
378        let scope = Scope::Local(dir.path().to_path_buf());
379        let spec = local_spec("alpha");
380        agent.install(&scope, &spec).unwrap();
381        let r2 = agent.install(&scope, &spec).unwrap();
382        assert!(r2.already_installed);
383    }
384
385    #[test]
386    fn install_uninstall_round_trip() {
387        let dir = tempdir().unwrap();
388        let agent = TabnineAgent::new();
389        let scope = Scope::Local(dir.path().to_path_buf());
390        agent.install(&scope, &local_spec("alpha")).unwrap();
391        agent.uninstall(&scope, "alpha").unwrap();
392        assert!(!dir.path().join(".tabnine/agent/settings.json").exists());
393    }
394
395    #[test]
396    fn hook_and_mcp_share_settings_json() {
397        let dir = tempdir().unwrap();
398        let agent = TabnineAgent::new();
399        let scope = Scope::Local(dir.path().to_path_buf());
400        agent.install(&scope, &local_spec("alpha")).unwrap();
401        agent
402            .install_mcp(&scope, &mcp_spec("github", "myapp"))
403            .unwrap();
404        let v = read_json(&dir.path().join(".tabnine/agent/settings.json"));
405        assert!(v["hooks"]["BeforeTool"].is_array());
406        assert_eq!(v["mcpServers"]["github"]["command"], json!("npx"));
407    }
408}