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        let rules = spec
115            .rules
116            .as_ref()
117            .ok_or(AgentConfigError::MissingSpecField {
118                id: "trae",
119                field: "rules",
120            })?;
121        let path = Self::rules_path(scope)?;
122        scope.ensure_contained(&path)?;
123        let mut report = InstallReport::default();
124        file_lock::with_lock(&path, || {
125            let host = fs_atomic::read_to_string_or_empty(&path)?;
126            let new_host = md_block::upsert(&host, &spec.tag, &rules.content);
127            let outcome = safe_fs::write(scope, &path, new_host.as_bytes(), true)?;
128            if outcome.no_change {
129                report.already_installed = true;
130            } else if outcome.existed {
131                report.patched.push(outcome.path.clone());
132            } else {
133                report.created.push(outcome.path.clone());
134            }
135            if let Some(b) = outcome.backup {
136                report.backed_up.push(b);
137            }
138            Ok::<(), AgentConfigError>(())
139        })?;
140        Ok(report)
141    }
142
143    fn uninstall(&self, scope: &Scope, tag: &str) -> Result<UninstallReport, AgentConfigError> {
144        HookSpec::validate_tag(tag)?;
145        let path = Self::rules_path(scope)?;
146        scope.ensure_contained(&path)?;
147        let mut report = UninstallReport::default();
148        file_lock::with_lock(&path, || {
149            let host = fs_atomic::read_to_string_or_empty(&path)?;
150            let (stripped, removed) = md_block::remove(&host, tag);
151            if !removed {
152                report.not_installed = true;
153                return Ok(());
154            }
155            if stripped.trim().is_empty() {
156                if safe_fs::restore_backup_if_matches(scope, &path, stripped.as_bytes())? {
157                    report.restored.push(path.clone());
158                } else {
159                    safe_fs::remove_file(scope, &path)?;
160                    report.removed.push(path.clone());
161                }
162            } else {
163                safe_fs::write(scope, &path, stripped.as_bytes(), false)?;
164                report.patched.push(path.clone());
165            }
166            Ok::<(), AgentConfigError>(())
167        })?;
168        Ok(report)
169    }
170}
171
172impl SkillSurface for TraeAgent {
173    fn id(&self) -> &'static str {
174        "trae"
175    }
176
177    fn supported_skill_scopes(&self) -> &'static [ScopeKind] {
178        &[ScopeKind::Global, ScopeKind::Local]
179    }
180
181    fn skill_status(
182        &self,
183        scope: &Scope,
184        name: &str,
185        expected_owner: &str,
186    ) -> Result<StatusReport, AgentConfigError> {
187        SkillSpec::validate_name(name)?;
188        let root = Self::skills_root(scope)?;
189        let (dir, manifest, ledger) = skills_dir::paths_for_status(&root, name);
190        let recorded = ownership::owner_of(&ledger, name)?;
191        Ok(StatusReport::for_skill(
192            name,
193            dir,
194            manifest,
195            ledger,
196            expected_owner,
197            recorded,
198        ))
199    }
200
201    fn plan_install_skill(
202        &self,
203        scope: &Scope,
204        spec: &SkillSpec,
205    ) -> Result<InstallPlan, AgentConfigError> {
206        agent_planning::skill_install(
207            SkillSurface::id(self),
208            scope,
209            spec,
210            Self::skills_root(scope),
211        )
212    }
213
214    fn plan_uninstall_skill(
215        &self,
216        scope: &Scope,
217        name: &str,
218        owner_tag: &str,
219    ) -> Result<UninstallPlan, AgentConfigError> {
220        agent_planning::skill_uninstall(
221            SkillSurface::id(self),
222            scope,
223            name,
224            owner_tag,
225            Self::skills_root(scope),
226        )
227    }
228
229    fn install_skill(
230        &self,
231        scope: &Scope,
232        spec: &SkillSpec,
233    ) -> Result<InstallReport, AgentConfigError> {
234        let root = Self::skills_root(scope)?;
235        scope.ensure_contained(&root)?;
236        skills_dir::install(&root, spec)
237    }
238
239    fn uninstall_skill(
240        &self,
241        scope: &Scope,
242        name: &str,
243        owner_tag: &str,
244    ) -> Result<UninstallReport, AgentConfigError> {
245        let root = Self::skills_root(scope)?;
246        scope.ensure_contained(&root)?;
247        skills_dir::uninstall(&root, name, owner_tag)
248    }
249}
250
251impl TraeAgent {
252    fn inline_layout(
253        &self,
254        scope: &Scope,
255    ) -> Result<instructions_dir::InlineLayout, AgentConfigError> {
256        Ok(instructions_dir::InlineLayout {
257            config_dir: Self::instruction_config_dir(scope)?,
258            host_file: Self::rules_path(scope)?,
259        })
260    }
261}
262
263impl InstructionSurface for TraeAgent {
264    fn id(&self) -> &'static str {
265        "trae"
266    }
267
268    fn supported_instruction_scopes(&self) -> &'static [ScopeKind] {
269        &[ScopeKind::Local]
270    }
271
272    fn instruction_status(
273        &self,
274        scope: &Scope,
275        name: &str,
276        expected_owner: &str,
277    ) -> Result<StatusReport, AgentConfigError> {
278        instructions_dir::inline_status(self.inline_layout(scope)?, name, expected_owner)
279    }
280
281    fn plan_install_instruction(
282        &self,
283        scope: &Scope,
284        spec: &InstructionSpec,
285    ) -> Result<InstallPlan, AgentConfigError> {
286        instructions_dir::inline_plan_install(
287            InstructionSurface::id(self),
288            scope,
289            self.inline_layout(scope),
290            spec,
291        )
292    }
293
294    fn plan_uninstall_instruction(
295        &self,
296        scope: &Scope,
297        name: &str,
298        owner_tag: &str,
299    ) -> Result<UninstallPlan, AgentConfigError> {
300        instructions_dir::inline_plan_uninstall(
301            InstructionSurface::id(self),
302            scope,
303            self.inline_layout(scope),
304            name,
305            owner_tag,
306        )
307    }
308
309    fn install_instruction(
310        &self,
311        scope: &Scope,
312        spec: &InstructionSpec,
313    ) -> Result<InstallReport, AgentConfigError> {
314        instructions_dir::inline_install(scope, self.inline_layout(scope)?, spec)
315    }
316
317    fn uninstall_instruction(
318        &self,
319        scope: &Scope,
320        name: &str,
321        owner_tag: &str,
322    ) -> Result<UninstallReport, AgentConfigError> {
323        instructions_dir::inline_uninstall(scope, self.inline_layout(scope)?, name, owner_tag)
324    }
325}
326
327#[cfg(test)]
328mod tests {
329    use super::*;
330    use tempfile::tempdir;
331
332    fn rules_spec(tag: &str, body: &str) -> HookSpec {
333        HookSpec::builder(tag)
334            .command_program("noop", [] as [&str; 0])
335            .rules(body)
336            .build()
337    }
338
339    fn skill(name: &str, owner: &str) -> SkillSpec {
340        SkillSpec::builder(name)
341            .owner(owner)
342            .description("Test Trae skill.")
343            .body("## Goal\nDo it.\n")
344            .build()
345    }
346
347    #[test]
348    fn install_writes_project_rules_md() {
349        let dir = tempdir().unwrap();
350        let agent = TraeAgent::new();
351        let scope = Scope::Local(dir.path().to_path_buf());
352        agent
353            .install(&scope, &rules_spec("alpha", "Use Trae."))
354            .unwrap();
355        let body = std::fs::read_to_string(dir.path().join(".trae/project_rules.md")).unwrap();
356        assert!(body.contains("Use Trae."));
357    }
358
359    #[test]
360    fn global_prompt_scope_rejected() {
361        let agent = TraeAgent::new();
362        let err = agent
363            .install(&Scope::Global, &rules_spec("alpha", "x"))
364            .unwrap_err();
365        assert!(matches!(err, AgentConfigError::UnsupportedScope { .. }));
366    }
367
368    #[test]
369    fn install_uninstall_round_trip() {
370        let dir = tempdir().unwrap();
371        let agent = TraeAgent::new();
372        let scope = Scope::Local(dir.path().to_path_buf());
373        agent.install(&scope, &rules_spec("alpha", "x")).unwrap();
374        agent.uninstall(&scope, "alpha").unwrap();
375        assert!(!dir.path().join(".trae/project_rules.md").exists());
376    }
377
378    #[test]
379    fn install_skill_writes_skills_dir() {
380        let dir = tempdir().unwrap();
381        let agent = TraeAgent::new();
382        let scope = Scope::Local(dir.path().to_path_buf());
383        agent
384            .install_skill(&scope, &skill("alpha-skill", "myapp"))
385            .unwrap();
386        assert!(dir
387            .path()
388            .join(".trae/skills/alpha-skill/SKILL.md")
389            .exists());
390    }
391}