1use 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#[derive(Debug, Clone, Copy, Default)]
30pub struct TraeAgent {
31 _private: (),
32}
33
34impl TraeAgent {
35 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 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}