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        let rules = spec
118            .rules
119            .as_ref()
120            .ok_or(AgentConfigError::MissingSpecField {
121                id: "junie",
122                field: "rules",
123            })?;
124        let path = Self::rules_path(scope)?;
125        scope.ensure_contained(&path)?;
126        let mut report = InstallReport::default();
127        file_lock::with_lock(&path, || {
128            let host = fs_atomic::read_to_string_or_empty(&path)?;
129            let new_host = md_block::upsert(&host, &spec.tag, &rules.content);
130            let outcome = safe_fs::write(scope, &path, new_host.as_bytes(), true)?;
131            if outcome.no_change {
132                report.already_installed = true;
133            } else if outcome.existed {
134                report.patched.push(outcome.path.clone());
135            } else {
136                report.created.push(outcome.path.clone());
137            }
138            if let Some(b) = outcome.backup {
139                report.backed_up.push(b);
140            }
141            Ok::<(), AgentConfigError>(())
142        })?;
143        Ok(report)
144    }
145
146    fn uninstall(&self, scope: &Scope, tag: &str) -> Result<UninstallReport, AgentConfigError> {
147        HookSpec::validate_tag(tag)?;
148        let path = Self::rules_path(scope)?;
149        scope.ensure_contained(&path)?;
150        let mut report = UninstallReport::default();
151        file_lock::with_lock(&path, || {
152            let host = fs_atomic::read_to_string_or_empty(&path)?;
153            let (stripped, removed) = md_block::remove(&host, tag);
154
155            if !removed {
156                report.not_installed = true;
157                return Ok(());
158            }
159
160            if stripped.trim().is_empty() {
161                if safe_fs::restore_backup_if_matches(scope, &path, stripped.as_bytes())? {
162                    report.restored.push(path.clone());
163                } else {
164                    safe_fs::remove_file(scope, &path)?;
165                    report.removed.push(path.clone());
166                }
167            } else {
168                safe_fs::write(scope, &path, stripped.as_bytes(), false)?;
169                report.patched.push(path.clone());
170            }
171            Ok::<(), AgentConfigError>(())
172        })?;
173        Ok(report)
174    }
175}
176
177impl McpSurface for JunieAgent {
178    fn id(&self) -> &'static str {
179        "junie"
180    }
181
182    fn supported_mcp_scopes(&self) -> &'static [ScopeKind] {
183        &[ScopeKind::Global, ScopeKind::Local]
184    }
185
186    fn mcp_status(
187        &self,
188        scope: &Scope,
189        name: &str,
190        expected_owner: &str,
191    ) -> Result<StatusReport, AgentConfigError> {
192        McpSpec::validate_name(name)?;
193        let cfg = Self::mcp_path(scope)?;
194        let ledger = ownership::mcp_ledger_for(&cfg);
195        let presence = mcp_json_object::config_presence(&cfg, name)?;
196        let recorded = ownership::owner_of(&ledger, name)?;
197        Ok(StatusReport::for_mcp(
198            name,
199            cfg,
200            ledger,
201            presence,
202            expected_owner,
203            recorded,
204        ))
205    }
206
207    fn plan_install_mcp(
208        &self,
209        scope: &Scope,
210        spec: &McpSpec,
211    ) -> Result<InstallPlan, AgentConfigError> {
212        agent_planning::mcp_json_object_install(
213            McpSurface::id(self),
214            scope,
215            spec,
216            Self::mcp_path(scope),
217        )
218    }
219
220    fn plan_uninstall_mcp(
221        &self,
222        scope: &Scope,
223        name: &str,
224        owner_tag: &str,
225    ) -> Result<UninstallPlan, AgentConfigError> {
226        agent_planning::mcp_json_object_uninstall(
227            McpSurface::id(self),
228            scope,
229            name,
230            owner_tag,
231            Self::mcp_path(scope),
232        )
233    }
234
235    fn install_mcp(
236        &self,
237        scope: &Scope,
238        spec: &McpSpec,
239    ) -> Result<InstallReport, AgentConfigError> {
240        spec.validate()?;
241        let cfg = Self::mcp_path(scope)?;
242        spec.validate_local_secret_policy(scope)?;
243        scope.ensure_contained(&cfg)?;
244        let ledger = ownership::mcp_ledger_for(&cfg);
245        mcp_json_object::install(&cfg, &ledger, spec)
246    }
247
248    fn uninstall_mcp(
249        &self,
250        scope: &Scope,
251        name: &str,
252        owner_tag: &str,
253    ) -> Result<UninstallReport, AgentConfigError> {
254        McpSpec::validate_name(name)?;
255        HookSpec::validate_tag(owner_tag)?;
256        let cfg = Self::mcp_path(scope)?;
257        scope.ensure_contained(&cfg)?;
258        let ledger = ownership::mcp_ledger_for(&cfg);
259        mcp_json_object::uninstall(&cfg, &ledger, name, owner_tag, "mcp server")
260    }
261}
262
263impl JunieAgent {
264    fn inline_layout(
265        &self,
266        scope: &Scope,
267    ) -> Result<instructions_dir::InlineLayout, AgentConfigError> {
268        Ok(instructions_dir::InlineLayout {
269            config_dir: Self::instruction_config_dir(scope)?,
270            host_file: Self::rules_path(scope)?,
271        })
272    }
273}
274
275impl InstructionSurface for JunieAgent {
276    fn id(&self) -> &'static str {
277        "junie"
278    }
279
280    fn supported_instruction_scopes(&self) -> &'static [ScopeKind] {
281        &[ScopeKind::Local]
282    }
283
284    fn instruction_status(
285        &self,
286        scope: &Scope,
287        name: &str,
288        expected_owner: &str,
289    ) -> Result<StatusReport, AgentConfigError> {
290        instructions_dir::inline_status(self.inline_layout(scope)?, name, expected_owner)
291    }
292
293    fn plan_install_instruction(
294        &self,
295        scope: &Scope,
296        spec: &InstructionSpec,
297    ) -> Result<InstallPlan, AgentConfigError> {
298        instructions_dir::inline_plan_install(
299            InstructionSurface::id(self),
300            scope,
301            self.inline_layout(scope),
302            spec,
303        )
304    }
305
306    fn plan_uninstall_instruction(
307        &self,
308        scope: &Scope,
309        name: &str,
310        owner_tag: &str,
311    ) -> Result<UninstallPlan, AgentConfigError> {
312        instructions_dir::inline_plan_uninstall(
313            InstructionSurface::id(self),
314            scope,
315            self.inline_layout(scope),
316            name,
317            owner_tag,
318        )
319    }
320
321    fn install_instruction(
322        &self,
323        scope: &Scope,
324        spec: &InstructionSpec,
325    ) -> Result<InstallReport, AgentConfigError> {
326        instructions_dir::inline_install(scope, self.inline_layout(scope)?, spec)
327    }
328
329    fn uninstall_instruction(
330        &self,
331        scope: &Scope,
332        name: &str,
333        owner_tag: &str,
334    ) -> Result<UninstallReport, AgentConfigError> {
335        instructions_dir::inline_uninstall(scope, self.inline_layout(scope)?, name, owner_tag)
336    }
337}
338
339#[cfg(test)]
340mod tests {
341    use super::*;
342    use serde_json::{json, Value};
343    use tempfile::tempdir;
344
345    fn rules_spec(tag: &str, body: &str) -> HookSpec {
346        HookSpec::builder(tag)
347            .command_program("noop", [] as [&str; 0])
348            .rules(body)
349            .build()
350    }
351
352    fn mcp_spec(name: &str, owner: &str) -> McpSpec {
353        McpSpec::builder(name)
354            .owner(owner)
355            .stdio("npx", ["-y", "@example/server"])
356            .build()
357    }
358
359    fn read_json(p: &Path) -> Value {
360        serde_json::from_slice(&std::fs::read(p).unwrap()).unwrap()
361    }
362
363    #[test]
364    fn install_writes_dot_junie_agents_md() {
365        let dir = tempdir().unwrap();
366        let agent = JunieAgent::new();
367        let scope = Scope::Local(dir.path().to_path_buf());
368        agent
369            .install(&scope, &rules_spec("alpha", "Use Junie."))
370            .unwrap();
371        let body = std::fs::read_to_string(dir.path().join(".junie/AGENTS.md")).unwrap();
372        assert!(body.contains("Use Junie."));
373    }
374
375    #[test]
376    fn global_prompt_scope_rejected() {
377        let agent = JunieAgent::new();
378        let err = agent
379            .install(&Scope::Global, &rules_spec("alpha", "x"))
380            .unwrap_err();
381        assert!(matches!(err, AgentConfigError::UnsupportedScope { .. }));
382    }
383
384    #[test]
385    fn install_uninstall_round_trip() {
386        let dir = tempdir().unwrap();
387        let agent = JunieAgent::new();
388        let scope = Scope::Local(dir.path().to_path_buf());
389        agent.install(&scope, &rules_spec("alpha", "x")).unwrap();
390        agent.uninstall(&scope, "alpha").unwrap();
391        assert!(!dir.path().join(".junie/AGENTS.md").exists());
392    }
393
394    #[test]
395    fn install_mcp_writes_nested_mcp_json() {
396        let dir = tempdir().unwrap();
397        let agent = JunieAgent::new();
398        let scope = Scope::Local(dir.path().to_path_buf());
399        agent
400            .install_mcp(&scope, &mcp_spec("github", "myapp"))
401            .unwrap();
402        let v = read_json(&dir.path().join(".junie/mcp/mcp.json"));
403        assert_eq!(v["mcpServers"]["github"]["command"], json!("npx"));
404    }
405
406    #[test]
407    fn install_mcp_idempotent() {
408        let dir = tempdir().unwrap();
409        let agent = JunieAgent::new();
410        let scope = Scope::Local(dir.path().to_path_buf());
411        let s = mcp_spec("github", "myapp");
412        agent.install_mcp(&scope, &s).unwrap();
413        let r = agent.install_mcp(&scope, &s).unwrap();
414        assert!(r.already_installed);
415    }
416
417    #[test]
418    fn uninstall_mcp_other_owner_refused() {
419        let dir = tempdir().unwrap();
420        let agent = JunieAgent::new();
421        let scope = Scope::Local(dir.path().to_path_buf());
422        agent
423            .install_mcp(&scope, &mcp_spec("github", "appA"))
424            .unwrap();
425        let err = agent.uninstall_mcp(&scope, "github", "appB").unwrap_err();
426        assert!(matches!(err, AgentConfigError::NotOwnedByCaller { .. }));
427    }
428}