Skip to main content

agent_config/agents/
trae.rs

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