Skip to main content

agent_config/agents/
junie.rs

1//! JetBrains Junie integration.
2//!
3//! Surfaces:
4//!
5//! 1. **Prompt rules**: project-local fenced block in `<root>/.junie/AGENTS.md`.
6//!    Junie also reads a custom filename via the `JUNIE_GUIDELINES_FILENAME`
7//!    env var; the crate writes the documented default `AGENTS.md`.
8//! 2. **MCP servers**: `mcpServers` JSON map at `~/.junie/mcp/mcp.json`
9//!    (Global) or `<root>/.junie/mcp/mcp.json` (Local).
10//!
11//! Skills are not part of Junie's documented surface. Hook lifecycle support
12//! is tracked upstream (JUNIE-1961) but not yet released.
13
14use std::path::{Path, PathBuf};
15
16use crate::agents::planning as agent_planning;
17use crate::error::AgentConfigError;
18use crate::integration::{
19    InstallReport, InstructionSurface, Integration, McpSurface, UninstallReport,
20};
21use crate::paths;
22use crate::plan::{InstallPlan, UninstallPlan};
23use crate::scope::{Scope, ScopeKind};
24use crate::spec::{HookSpec, InstructionSpec, McpSpec};
25use crate::status::StatusReport;
26use crate::util::{
27    file_lock, fs_atomic, instructions_dir, mcp_json_object, md_block, ownership, safe_fs,
28};
29
30/// JetBrains Junie installer.
31#[derive(Debug, Clone, Copy, Default)]
32pub struct JunieAgent {
33    _private: (),
34}
35
36impl JunieAgent {
37    /// Construct an instance. Stateless.
38    pub const fn new() -> Self {
39        Self { _private: () }
40    }
41
42    fn require_local(scope: &Scope) -> Result<&Path, AgentConfigError> {
43        match scope {
44            Scope::Local(p) => Ok(p),
45            Scope::Global => Err(AgentConfigError::UnsupportedScope {
46                id: "junie",
47                scope: ScopeKind::Global,
48            }),
49        }
50    }
51
52    fn rules_path(scope: &Scope) -> Result<PathBuf, AgentConfigError> {
53        Ok(Self::require_local(scope)?.join(".junie").join("AGENTS.md"))
54    }
55
56    fn mcp_path(scope: &Scope) -> Result<PathBuf, AgentConfigError> {
57        Ok(match scope {
58            Scope::Global => paths::home_dir()?
59                .join(".junie")
60                .join("mcp")
61                .join("mcp.json"),
62            Scope::Local(p) => p.join(".junie").join("mcp").join("mcp.json"),
63        })
64    }
65
66    /// Directory holding the instruction ownership ledger. Local-only;
67    /// lives next to the host file under `<root>/.junie/`.
68    fn instruction_config_dir(scope: &Scope) -> Result<PathBuf, AgentConfigError> {
69        Ok(Self::require_local(scope)?.join(".junie"))
70    }
71}
72
73impl Integration for JunieAgent {
74    fn id(&self) -> &'static str {
75        "junie"
76    }
77
78    fn display_name(&self) -> &'static str {
79        "JetBrains Junie"
80    }
81
82    fn supported_scopes(&self) -> &'static [ScopeKind] {
83        &[ScopeKind::Local]
84    }
85
86    fn status(&self, scope: &Scope, tag: &str) -> Result<StatusReport, AgentConfigError> {
87        HookSpec::validate_tag(tag)?;
88        let path = Self::rules_path(scope)?;
89        StatusReport::for_markdown_block_hook(tag, path)
90    }
91
92    fn plan_install(
93        &self,
94        scope: &Scope,
95        spec: &HookSpec,
96    ) -> Result<InstallPlan, AgentConfigError> {
97        agent_planning::markdown_install(
98            Integration::id(self),
99            scope,
100            spec,
101            Self::rules_path(scope),
102            true,
103        )
104    }
105
106    fn plan_uninstall(&self, scope: &Scope, tag: &str) -> Result<UninstallPlan, AgentConfigError> {
107        agent_planning::markdown_uninstall(
108            Integration::id(self),
109            scope,
110            tag,
111            Self::rules_path(scope),
112        )
113    }
114
115    fn install(&self, scope: &Scope, spec: &HookSpec) -> Result<InstallReport, AgentConfigError> {
116        HookSpec::validate_tag(&spec.tag)?;
117        agent_planning::validate_prompt_only_event(Integration::id(self), &spec.event)?;
118        let rules = spec
119            .rules
120            .as_ref()
121            .ok_or(AgentConfigError::MissingSpecField {
122                id: "junie",
123                field: "rules",
124            })?;
125        let path = Self::rules_path(scope)?;
126        scope.ensure_contained(&path)?;
127        let mut report = InstallReport::default();
128        file_lock::with_lock(&path, || {
129            let host = fs_atomic::read_to_string_or_empty(&path)?;
130            let new_host = md_block::upsert(&host, &spec.tag, &rules.content);
131            let outcome = safe_fs::write(scope, &path, new_host.as_bytes(), true)?;
132            if outcome.no_change {
133                report.already_installed = true;
134            } else if outcome.existed {
135                report.patched.push(outcome.path.clone());
136            } else {
137                report.created.push(outcome.path.clone());
138            }
139            if let Some(b) = outcome.backup {
140                report.backed_up.push(b);
141            }
142            Ok::<(), AgentConfigError>(())
143        })?;
144        Ok(report)
145    }
146
147    fn uninstall(&self, scope: &Scope, tag: &str) -> Result<UninstallReport, AgentConfigError> {
148        HookSpec::validate_tag(tag)?;
149        let path = Self::rules_path(scope)?;
150        scope.ensure_contained(&path)?;
151        let mut report = UninstallReport::default();
152        file_lock::with_lock(&path, || {
153            let host = fs_atomic::read_to_string_or_empty(&path)?;
154            let (stripped, removed) = md_block::remove(&host, tag);
155
156            if !removed {
157                report.not_installed = true;
158                return Ok(());
159            }
160
161            if stripped.trim().is_empty() {
162                if safe_fs::restore_backup_if_matches(scope, &path, stripped.as_bytes())? {
163                    report.restored.push(path.clone());
164                } else {
165                    safe_fs::remove_file(scope, &path)?;
166                    report.removed.push(path.clone());
167                }
168            } else {
169                safe_fs::write(scope, &path, stripped.as_bytes(), false)?;
170                report.patched.push(path.clone());
171            }
172            Ok::<(), AgentConfigError>(())
173        })?;
174        Ok(report)
175    }
176}
177
178impl McpSurface for JunieAgent {
179    fn id(&self) -> &'static str {
180        "junie"
181    }
182
183    fn supported_mcp_scopes(&self) -> &'static [ScopeKind] {
184        &[ScopeKind::Global, ScopeKind::Local]
185    }
186
187    fn mcp_status(
188        &self,
189        scope: &Scope,
190        name: &str,
191        expected_owner: &str,
192    ) -> Result<StatusReport, AgentConfigError> {
193        McpSpec::validate_name(name)?;
194        let cfg = Self::mcp_path(scope)?;
195        let ledger = ownership::mcp_ledger_for(&cfg);
196        let presence = mcp_json_object::config_presence(&cfg, name)?;
197        let recorded = ownership::owner_of(&ledger, name)?;
198        Ok(StatusReport::for_mcp(
199            name,
200            cfg,
201            ledger,
202            presence,
203            expected_owner,
204            recorded,
205        ))
206    }
207
208    fn plan_install_mcp(
209        &self,
210        scope: &Scope,
211        spec: &McpSpec,
212    ) -> Result<InstallPlan, AgentConfigError> {
213        agent_planning::mcp_json_object_install(
214            McpSurface::id(self),
215            scope,
216            spec,
217            Self::mcp_path(scope),
218        )
219    }
220
221    fn plan_uninstall_mcp(
222        &self,
223        scope: &Scope,
224        name: &str,
225        owner_tag: &str,
226    ) -> Result<UninstallPlan, AgentConfigError> {
227        agent_planning::mcp_json_object_uninstall(
228            McpSurface::id(self),
229            scope,
230            name,
231            owner_tag,
232            Self::mcp_path(scope),
233        )
234    }
235
236    fn install_mcp(
237        &self,
238        scope: &Scope,
239        spec: &McpSpec,
240    ) -> Result<InstallReport, AgentConfigError> {
241        spec.validate()?;
242        let cfg = Self::mcp_path(scope)?;
243        spec.validate_local_secret_policy(scope)?;
244        scope.ensure_contained(&cfg)?;
245        let ledger = ownership::mcp_ledger_for(&cfg);
246        mcp_json_object::install(&cfg, &ledger, spec)
247    }
248
249    fn uninstall_mcp(
250        &self,
251        scope: &Scope,
252        name: &str,
253        owner_tag: &str,
254    ) -> Result<UninstallReport, AgentConfigError> {
255        McpSpec::validate_name(name)?;
256        HookSpec::validate_tag(owner_tag)?;
257        let cfg = Self::mcp_path(scope)?;
258        scope.ensure_contained(&cfg)?;
259        let ledger = ownership::mcp_ledger_for(&cfg);
260        mcp_json_object::uninstall(&cfg, &ledger, name, owner_tag, "mcp server")
261    }
262}
263
264impl JunieAgent {
265    fn inline_layout(
266        &self,
267        scope: &Scope,
268    ) -> Result<instructions_dir::InlineLayout, AgentConfigError> {
269        Ok(instructions_dir::InlineLayout {
270            config_dir: Self::instruction_config_dir(scope)?,
271            host_file: Self::rules_path(scope)?,
272        })
273    }
274}
275
276impl InstructionSurface for JunieAgent {
277    fn id(&self) -> &'static str {
278        "junie"
279    }
280
281    fn supported_instruction_scopes(&self) -> &'static [ScopeKind] {
282        &[ScopeKind::Local]
283    }
284
285    fn instruction_status(
286        &self,
287        scope: &Scope,
288        name: &str,
289        expected_owner: &str,
290    ) -> Result<StatusReport, AgentConfigError> {
291        instructions_dir::inline_status(self.inline_layout(scope)?, name, expected_owner)
292    }
293
294    fn plan_install_instruction(
295        &self,
296        scope: &Scope,
297        spec: &InstructionSpec,
298    ) -> Result<InstallPlan, AgentConfigError> {
299        instructions_dir::inline_plan_install(
300            InstructionSurface::id(self),
301            scope,
302            self.inline_layout(scope),
303            spec,
304        )
305    }
306
307    fn plan_uninstall_instruction(
308        &self,
309        scope: &Scope,
310        name: &str,
311        owner_tag: &str,
312    ) -> Result<UninstallPlan, AgentConfigError> {
313        instructions_dir::inline_plan_uninstall(
314            InstructionSurface::id(self),
315            scope,
316            self.inline_layout(scope),
317            name,
318            owner_tag,
319        )
320    }
321
322    fn install_instruction(
323        &self,
324        scope: &Scope,
325        spec: &InstructionSpec,
326    ) -> Result<InstallReport, AgentConfigError> {
327        instructions_dir::inline_install(scope, self.inline_layout(scope)?, spec)
328    }
329
330    fn uninstall_instruction(
331        &self,
332        scope: &Scope,
333        name: &str,
334        owner_tag: &str,
335    ) -> Result<UninstallReport, AgentConfigError> {
336        instructions_dir::inline_uninstall(scope, self.inline_layout(scope)?, name, owner_tag)
337    }
338}
339
340#[cfg(test)]
341mod tests {
342    use super::*;
343    use serde_json::{json, Value};
344    use tempfile::tempdir;
345
346    fn rules_spec(tag: &str, body: &str) -> HookSpec {
347        HookSpec::builder(tag)
348            .command_program("noop", [] as [&str; 0])
349            .rules(body)
350            .build()
351    }
352
353    fn mcp_spec(name: &str, owner: &str) -> McpSpec {
354        McpSpec::builder(name)
355            .owner(owner)
356            .stdio("npx", ["-y", "@example/server"])
357            .build()
358    }
359
360    fn read_json(p: &Path) -> Value {
361        serde_json::from_slice(&std::fs::read(p).unwrap()).unwrap()
362    }
363
364    #[test]
365    fn install_writes_dot_junie_agents_md() {
366        let dir = tempdir().unwrap();
367        let agent = JunieAgent::new();
368        let scope = Scope::Local(dir.path().to_path_buf());
369        agent
370            .install(&scope, &rules_spec("alpha", "Use Junie."))
371            .unwrap();
372        let body = std::fs::read_to_string(dir.path().join(".junie/AGENTS.md")).unwrap();
373        assert!(body.contains("Use Junie."));
374    }
375
376    #[test]
377    fn global_prompt_scope_rejected() {
378        let agent = JunieAgent::new();
379        let err = agent
380            .install(&Scope::Global, &rules_spec("alpha", "x"))
381            .unwrap_err();
382        assert!(matches!(err, AgentConfigError::UnsupportedScope { .. }));
383    }
384
385    #[test]
386    fn install_uninstall_round_trip() {
387        let dir = tempdir().unwrap();
388        let agent = JunieAgent::new();
389        let scope = Scope::Local(dir.path().to_path_buf());
390        agent.install(&scope, &rules_spec("alpha", "x")).unwrap();
391        agent.uninstall(&scope, "alpha").unwrap();
392        assert!(!dir.path().join(".junie/AGENTS.md").exists());
393    }
394
395    #[test]
396    fn install_mcp_writes_nested_mcp_json() {
397        let dir = tempdir().unwrap();
398        let agent = JunieAgent::new();
399        let scope = Scope::Local(dir.path().to_path_buf());
400        agent
401            .install_mcp(&scope, &mcp_spec("github", "myapp"))
402            .unwrap();
403        let v = read_json(&dir.path().join(".junie/mcp/mcp.json"));
404        assert_eq!(v["mcpServers"]["github"]["command"], json!("npx"));
405    }
406
407    #[test]
408    fn install_mcp_idempotent() {
409        let dir = tempdir().unwrap();
410        let agent = JunieAgent::new();
411        let scope = Scope::Local(dir.path().to_path_buf());
412        let s = mcp_spec("github", "myapp");
413        agent.install_mcp(&scope, &s).unwrap();
414        let r = agent.install_mcp(&scope, &s).unwrap();
415        assert!(r.already_installed);
416    }
417
418    #[test]
419    fn uninstall_mcp_other_owner_refused() {
420        let dir = tempdir().unwrap();
421        let agent = JunieAgent::new();
422        let scope = Scope::Local(dir.path().to_path_buf());
423        agent
424            .install_mcp(&scope, &mcp_spec("github", "appA"))
425            .unwrap();
426        let err = agent.uninstall_mcp(&scope, "github", "appB").unwrap_err();
427        assert!(matches!(err, AgentConfigError::NotOwnedByCaller { .. }));
428    }
429}