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 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}