1use 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#[derive(Debug, Clone, Copy, Default)]
36pub struct TabnineAgent {
37 _private: (),
38}
39
40impl TabnineAgent {
41 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
310fn 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}