1use std::path::{Path, PathBuf};
14
15use crate::error::{Result, SqzError};
16
17const KIRO_STEERING_SENTINEL: &str = "<!-- sqz-kiro-steering (auto-installed by sqz init) -->";
18
19pub fn kiro_steering_path(project_dir: &Path) -> PathBuf {
20 project_dir.join(".kiro").join("steering").join("sqz.md")
21}
22
23pub fn kiro_mcp_path(project_dir: &Path) -> PathBuf {
24 project_dir.join(".kiro").join("settings").join("mcp.json")
25}
26
27pub fn kiro_user_root() -> PathBuf {
30 kiro_user_root_from(
31 std::env::var("KIRO_HOME").ok(),
32 dirs_next::home_dir(),
33 )
34}
35
36fn kiro_user_root_from(kiro_home: Option<String>, home: Option<PathBuf>) -> PathBuf {
37 if let Some(custom) = kiro_home {
38 let trimmed = custom.trim();
39 if !trimmed.is_empty() {
40 return PathBuf::from(trimmed);
41 }
42 }
43 home.unwrap_or_else(|| PathBuf::from(".")).join(".kiro")
44}
45
46pub fn kiro_user_steering_path() -> PathBuf {
47 kiro_user_root().join("steering").join("sqz.md")
48}
49
50pub fn kiro_user_mcp_path() -> PathBuf {
51 kiro_user_root().join("settings").join("mcp.json")
52}
53
54pub fn kiro_legacy_hook_path(project_dir: &Path) -> PathBuf {
57 project_dir.join(".kiro").join("hooks").join("sqz-compress.json")
58}
59
60pub fn kiro_steering_content(sqz_path: &str) -> String {
61 format!(
62 "---\ninclusion: always\n---\n{KIRO_STEERING_SENTINEL}\n\n{}",
63 crate::codex_integration::sqz_guidance(sqz_path)
64 )
65}
66
67pub fn kiro_user_steering_content(sqz_path: &str) -> String {
68 kiro_steering_content(sqz_path)
69}
70
71#[derive(Debug, Clone, Copy, PartialEq, Eq)]
73pub enum SteeringState {
74 Missing,
75 UserAuthored,
77 Current,
79 Stale,
82}
83
84pub(crate) fn embedded_sqz_path(content: &str) -> Option<&str> {
87 content.lines().find_map(|line| {
88 line.split_once("git status 2>&1 | ")
89 .and_then(|(_, rest)| rest.strip_suffix(" compress"))
90 })
91}
92
93pub fn kiro_steering_state(path: &Path, global: bool) -> SteeringState {
96 let Ok(content) = std::fs::read_to_string(path) else {
97 return SteeringState::Missing;
98 };
99 if !content.contains(KIRO_STEERING_SENTINEL) {
100 return SteeringState::UserAuthored;
101 }
102 let Some(sqz_path) = embedded_sqz_path(&content) else {
103 return SteeringState::Stale;
104 };
105 let expected = if global {
106 kiro_user_steering_content(sqz_path)
107 } else {
108 kiro_steering_content(sqz_path)
109 };
110 if content.replace("\r\n", "\n") == expected {
111 SteeringState::Current
112 } else {
113 SteeringState::Stale
114 }
115}
116
117pub fn user_steering_installed() -> bool {
121 matches!(
122 kiro_steering_state(&kiro_user_steering_path(), true),
123 SteeringState::Current | SteeringState::Stale
124 )
125}
126
127pub fn refresh_kiro_steering(path: &Path, global: bool, fallback_sqz_path: &str) -> Result<bool> {
132 if kiro_steering_state(path, global) != SteeringState::Stale {
133 return Ok(false);
134 }
135 let existing = std::fs::read_to_string(path)
136 .map_err(|e| SqzError::Other(format!("read {}: {e}", path.display())))?;
137 let sqz_path = embedded_sqz_path(&existing)
138 .filter(|p| Path::new(p).is_file())
139 .unwrap_or(fallback_sqz_path)
140 .to_string();
141 let content = if global {
142 kiro_user_steering_content(&sqz_path)
143 } else {
144 kiro_steering_content(&sqz_path)
145 };
146 install_kiro_steering_at(path, &content)
147}
148
149pub fn install_kiro_steering(project_dir: &Path, sqz_path: &str) -> Result<bool> {
152 install_kiro_steering_at(&kiro_steering_path(project_dir), &kiro_steering_content(sqz_path))
153}
154
155pub fn install_kiro_user_steering(sqz_path: &str) -> Result<bool> {
158 install_kiro_steering_at(&kiro_user_steering_path(), &kiro_user_steering_content(sqz_path))
159}
160
161pub(crate) fn install_kiro_steering_at(path: &Path, content: &str) -> Result<bool> {
162 if path.exists() {
163 let existing = std::fs::read_to_string(&path).map_err(|e| {
164 SqzError::Other(format!("read {}: {e}", path.display()))
165 })?;
166 if !existing.contains(KIRO_STEERING_SENTINEL) {
167 return Ok(false);
168 }
169 if existing == content {
170 return Ok(false);
171 }
172 }
173 if let Some(parent) = path.parent() {
174 std::fs::create_dir_all(parent)
175 .map_err(|e| SqzError::Other(format!("create {}: {e}", parent.display())))?;
176 }
177 std::fs::write(&path, content)
178 .map_err(|e| SqzError::Other(format!("write {}: {e}", path.display())))?;
179 Ok(true)
180}
181
182pub fn remove_kiro_steering(project_dir: &Path) -> Result<bool> {
183 remove_kiro_steering_at(&kiro_steering_path(project_dir))
184}
185
186pub fn remove_kiro_user_steering() -> Result<bool> {
187 remove_kiro_steering_at(&kiro_user_steering_path())
188}
189
190pub(crate) fn remove_kiro_steering_at(path: &Path) -> Result<bool> {
191 if !path.exists() {
192 return Ok(false);
193 }
194 let content = std::fs::read_to_string(path)
195 .map_err(|e| SqzError::Other(format!("read {}: {e}", path.display())))?;
196 if !content.contains(KIRO_STEERING_SENTINEL) {
197 return Ok(false);
198 }
199 std::fs::remove_file(path)
200 .map_err(|e| SqzError::Other(format!("remove {}: {e}", path.display())))?;
201 Ok(true)
202}
203
204pub fn remove_kiro_legacy_hook(project_dir: &Path) -> Result<bool> {
206 let path = kiro_legacy_hook_path(project_dir);
207 if !path.exists() {
208 return Ok(false);
209 }
210 let content = std::fs::read_to_string(&path).unwrap_or_default();
211 if !(content.contains("\"sqz compress\"") && content.contains("hook kiro")) {
212 return Ok(false);
213 }
214 std::fs::remove_file(&path)
215 .map_err(|e| SqzError::Other(format!("remove {}: {e}", path.display())))?;
216 Ok(true)
217}
218
219#[derive(Debug, Clone, Copy, PartialEq, Eq)]
220pub enum KiroMcpInstall {
221 Created,
222 Merged,
223 AlreadyPresent,
224 SkippedUnparseable,
226}
227
228pub fn install_kiro_mcp_config(project_dir: &Path) -> Result<KiroMcpInstall> {
229 install_kiro_mcp_config_at(&kiro_mcp_path(project_dir))
230}
231
232pub fn install_kiro_user_mcp_config() -> Result<KiroMcpInstall> {
235 install_kiro_mcp_config_at(&kiro_user_mcp_path())
236}
237
238pub(crate) fn install_kiro_mcp_config_at(path: &Path) -> Result<KiroMcpInstall> {
239 let sqz_entry = serde_json::json!({
240 "command": "sqz-mcp",
241 "args": ["--transport", "stdio"],
242 "disabled": false
243 });
244
245 if !path.exists() {
246 if let Some(parent) = path.parent() {
247 std::fs::create_dir_all(parent)
248 .map_err(|e| SqzError::Other(format!("create {}: {e}", parent.display())))?;
249 }
250 let root = serde_json::json!({ "mcpServers": { "sqz": sqz_entry } });
251 std::fs::write(path, serde_json::to_string_pretty(&root).unwrap_or_default())
252 .map_err(|e| SqzError::Other(format!("write {}: {e}", path.display())))?;
253 return Ok(KiroMcpInstall::Created);
254 }
255
256 let text = std::fs::read_to_string(path)
257 .map_err(|e| SqzError::Other(format!("read {}: {e}", path.display())))?;
258 let mut root: serde_json::Value = if text.trim().is_empty() {
259 serde_json::json!({})
260 } else {
261 match serde_json::from_str(&text) {
262 Ok(v) => v,
263 Err(_) => return Ok(KiroMcpInstall::SkippedUnparseable),
264 }
265 };
266
267 let Some(root_obj) = root.as_object_mut() else {
268 return Ok(KiroMcpInstall::SkippedUnparseable);
269 };
270 let servers = root_obj
271 .entry("mcpServers".to_string())
272 .or_insert_with(|| serde_json::json!({}));
273 if !servers.is_object() {
274 *servers = serde_json::json!({});
275 }
276 let servers_obj = servers.as_object_mut().expect("just ensured object");
277
278 if servers_obj.contains_key("sqz") {
279 return Ok(KiroMcpInstall::AlreadyPresent);
280 }
281 servers_obj.insert("sqz".to_string(), sqz_entry);
282
283 let mut out = serde_json::to_string_pretty(&root).unwrap_or_default();
284 if text.ends_with('\n') {
285 out.push('\n');
286 }
287 std::fs::write(path, out)
288 .map_err(|e| SqzError::Other(format!("write {}: {e}", path.display())))?;
289 Ok(KiroMcpInstall::Merged)
290}
291
292#[derive(Debug, Clone, Copy, PartialEq, Eq)]
293pub enum KiroMcpRemove {
294 Removed,
295 NotPresent,
296 SkippedUnparseable,
297}
298
299pub fn remove_kiro_mcp_config(project_dir: &Path) -> Result<KiroMcpRemove> {
300 remove_kiro_mcp_config_at(&kiro_mcp_path(project_dir))
301}
302
303pub fn remove_kiro_user_mcp_config() -> Result<KiroMcpRemove> {
304 remove_kiro_mcp_config_at(&kiro_user_mcp_path())
305}
306
307pub(crate) fn remove_kiro_mcp_config_at(path: &Path) -> Result<KiroMcpRemove> {
308 if !path.exists() {
309 return Ok(KiroMcpRemove::NotPresent);
310 }
311 let text = std::fs::read_to_string(path)
312 .map_err(|e| SqzError::Other(format!("read {}: {e}", path.display())))?;
313 let mut root: serde_json::Value = match serde_json::from_str(&text) {
314 Ok(v) => v,
315 Err(_) => {
316 if text.contains("\"sqz\"") {
317 return Ok(KiroMcpRemove::SkippedUnparseable);
318 }
319 return Ok(KiroMcpRemove::NotPresent);
320 }
321 };
322
323 let Some(root_obj) = root.as_object_mut() else {
324 return Ok(KiroMcpRemove::NotPresent);
325 };
326 let Some(servers) = root_obj.get_mut("mcpServers").and_then(|s| s.as_object_mut()) else {
327 return Ok(KiroMcpRemove::NotPresent);
328 };
329 if servers.remove("sqz").is_none() {
330 return Ok(KiroMcpRemove::NotPresent);
331 }
332 if servers.is_empty() {
333 root_obj.remove("mcpServers");
334 }
335 let mut out = serde_json::to_string_pretty(&root).unwrap_or_default();
336 if text.ends_with('\n') {
337 out.push('\n');
338 }
339 std::fs::write(path, out)
340 .map_err(|e| SqzError::Other(format!("write {}: {e}", path.display())))?;
341 Ok(KiroMcpRemove::Removed)
342}
343
344#[cfg(test)]
345mod tests {
346 use super::*;
347
348 #[test]
349 fn steering_install_creates_file() {
350 let dir = tempfile::tempdir().unwrap();
351 assert!(install_kiro_steering(dir.path(), "/usr/bin/sqz").unwrap());
352 let content = std::fs::read_to_string(kiro_steering_path(dir.path())).unwrap();
353 assert!(content.starts_with("---\ninclusion: always\n---"));
354 assert!(content.contains(KIRO_STEERING_SENTINEL));
355 assert!(content.contains("/usr/bin/sqz compress"));
356 }
357
358 #[test]
359 fn steering_install_is_idempotent() {
360 let dir = tempfile::tempdir().unwrap();
361 assert!(install_kiro_steering(dir.path(), "sqz").unwrap());
362 assert!(!install_kiro_steering(dir.path(), "sqz").unwrap());
363 }
364
365 #[test]
366 fn steering_install_refreshes_stale_managed_file() {
367 let dir = tempfile::tempdir().unwrap();
368 install_kiro_steering(dir.path(), "/old/sqz").unwrap();
369 assert!(install_kiro_steering(dir.path(), "/new/sqz").unwrap());
370 let content = std::fs::read_to_string(kiro_steering_path(dir.path())).unwrap();
371 assert!(content.contains("/new/sqz"));
372 assert!(!content.contains("/old/sqz"));
373 }
374
375 #[test]
376 fn steering_install_leaves_user_file_alone() {
377 let dir = tempfile::tempdir().unwrap();
378 let path = kiro_steering_path(dir.path());
379 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
380 std::fs::write(&path, "my own sqz notes\n").unwrap();
381 assert!(!install_kiro_steering(dir.path(), "sqz").unwrap());
382 assert_eq!(std::fs::read_to_string(&path).unwrap(), "my own sqz notes\n");
383 }
384
385 #[test]
386 fn steering_remove_only_deletes_managed_file() {
387 let dir = tempfile::tempdir().unwrap();
388 install_kiro_steering(dir.path(), "sqz").unwrap();
389 assert!(remove_kiro_steering(dir.path()).unwrap());
390 assert!(!kiro_steering_path(dir.path()).exists());
391
392 let path = kiro_steering_path(dir.path());
393 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
394 std::fs::write(&path, "user file\n").unwrap();
395 assert!(!remove_kiro_steering(dir.path()).unwrap());
396 assert!(path.exists());
397 }
398
399 #[test]
400 fn legacy_hook_is_cleaned_up() {
401 let dir = tempfile::tempdir().unwrap();
402 let hook = kiro_legacy_hook_path(dir.path());
403 std::fs::create_dir_all(hook.parent().unwrap()).unwrap();
404 std::fs::write(&hook, r#"{"name":"sqz compress","then":{"command":"sqz hook kiro"}}"#).unwrap();
405 assert!(remove_kiro_legacy_hook(dir.path()).unwrap());
406 assert!(!hook.exists());
407 }
408
409 #[test]
410 fn legacy_cleanup_skips_user_hooks() {
411 let dir = tempfile::tempdir().unwrap();
412 let hook = kiro_legacy_hook_path(dir.path());
413 std::fs::create_dir_all(hook.parent().unwrap()).unwrap();
414 std::fs::write(&hook, r#"{"version":"v1","hooks":[{"name":"mine"}]}"#).unwrap();
415 assert!(!remove_kiro_legacy_hook(dir.path()).unwrap());
416 assert!(hook.exists());
417 }
418
419 #[test]
420 fn mcp_creates_fresh_config() {
421 let dir = tempfile::tempdir().unwrap();
422 assert_eq!(
423 install_kiro_mcp_config(dir.path()).unwrap(),
424 KiroMcpInstall::Created
425 );
426 let parsed: serde_json::Value = serde_json::from_str(
427 &std::fs::read_to_string(kiro_mcp_path(dir.path())).unwrap(),
428 )
429 .unwrap();
430 assert_eq!(parsed["mcpServers"]["sqz"]["command"], "sqz-mcp");
431 assert_eq!(parsed["mcpServers"]["sqz"]["disabled"], false);
432 }
433
434 #[test]
435 fn mcp_merges_preserving_other_servers() {
436 let dir = tempfile::tempdir().unwrap();
437 let path = kiro_mcp_path(dir.path());
438 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
439 std::fs::write(&path, r#"{"mcpServers":{"aws-docs":{"command":"uvx"}}}"#).unwrap();
440 assert_eq!(install_kiro_mcp_config_at(&path).unwrap(), KiroMcpInstall::Merged);
441 let parsed: serde_json::Value =
442 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
443 assert_eq!(parsed["mcpServers"]["aws-docs"]["command"], "uvx");
444 assert_eq!(parsed["mcpServers"]["sqz"]["command"], "sqz-mcp");
445 }
446
447 #[test]
448 fn mcp_leaves_existing_sqz_entry_alone() {
449 let dir = tempfile::tempdir().unwrap();
450 let path = kiro_mcp_path(dir.path());
451 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
452 std::fs::write(&path, r#"{"mcpServers":{"sqz":{"command":"/custom/sqz-mcp"}}}"#).unwrap();
453 assert_eq!(
454 install_kiro_mcp_config_at(&path).unwrap(),
455 KiroMcpInstall::AlreadyPresent
456 );
457 let parsed: serde_json::Value =
458 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
459 assert_eq!(parsed["mcpServers"]["sqz"]["command"], "/custom/sqz-mcp");
460 }
461
462 #[test]
463 fn mcp_skips_unparseable_config() {
464 let dir = tempfile::tempdir().unwrap();
465 let path = kiro_mcp_path(dir.path());
466 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
467 let bad = "{ not json ";
468 std::fs::write(&path, bad).unwrap();
469 assert_eq!(
470 install_kiro_mcp_config_at(&path).unwrap(),
471 KiroMcpInstall::SkippedUnparseable
472 );
473 assert_eq!(std::fs::read_to_string(&path).unwrap(), bad);
474 }
475
476 #[test]
477 fn mcp_remove_is_surgical() {
478 let dir = tempfile::tempdir().unwrap();
479 let path = kiro_mcp_path(dir.path());
480 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
481 std::fs::write(
482 &path,
483 r#"{"mcpServers":{"sqz":{"command":"sqz-mcp"},"aws-docs":{"command":"uvx"}}}"#,
484 )
485 .unwrap();
486 assert_eq!(remove_kiro_mcp_config_at(&path).unwrap(), KiroMcpRemove::Removed);
487 let parsed: serde_json::Value =
488 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
489 assert!(parsed["mcpServers"].get("sqz").is_none());
490 assert_eq!(parsed["mcpServers"]["aws-docs"]["command"], "uvx");
491 }
492
493 #[test]
494 fn user_root_honors_kiro_home() {
495 assert_eq!(
496 kiro_user_root_from(Some("/custom/kiro".into()), Some(PathBuf::from("/home/u"))),
497 PathBuf::from("/custom/kiro")
498 );
499 assert_eq!(
500 kiro_user_root_from(Some(" ".into()), Some(PathBuf::from("/home/u"))),
501 PathBuf::from("/home/u/.kiro")
502 );
503 assert_eq!(
504 kiro_user_root_from(None, Some(PathBuf::from("/home/u"))),
505 PathBuf::from("/home/u/.kiro")
506 );
507 }
508
509 #[test]
510 fn user_and_project_steering_carry_the_shared_guidance() {
511 let content = kiro_user_steering_content("/usr/bin/sqz");
512 assert!(content.contains(KIRO_STEERING_SENTINEL));
513 assert!(content.ends_with(&crate::codex_integration::sqz_guidance("/usr/bin/sqz")));
514 assert_eq!(content, kiro_steering_content("/usr/bin/sqz"));
515 }
516
517 #[test]
518 fn user_level_steering_roundtrip_via_at_helpers() {
519 let dir = tempfile::tempdir().unwrap();
520 let path = dir.path().join("steering").join("sqz.md");
521 let content = kiro_user_steering_content("/usr/bin/sqz");
522 assert!(install_kiro_steering_at(&path, &content).unwrap());
523 assert!(path.exists());
524 assert!(!install_kiro_steering_at(&path, &content).unwrap());
526 assert!(remove_kiro_steering_at(&path).unwrap());
528 assert!(!path.exists());
529 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
530 std::fs::write(&path, "my own steering\n").unwrap();
531 assert!(!remove_kiro_steering_at(&path).unwrap());
532 assert!(path.exists());
533 }
534
535 #[test]
536 fn steering_state_tracks_current_stale_and_user_files() {
537 let dir = tempfile::tempdir().unwrap();
538 let path = dir.path().join("sqz.md");
539 assert_eq!(kiro_steering_state(&path, true), SteeringState::Missing);
540
541 std::fs::write(&path, kiro_user_steering_content("/opt/bin/sqz")).unwrap();
542 assert_eq!(kiro_steering_state(&path, true), SteeringState::Current);
543 assert_eq!(kiro_steering_state(&path, false), SteeringState::Current);
544
545 let old = kiro_user_steering_content("/opt/bin/sqz").replace("sqz_recall", "recall");
547 std::fs::write(&path, old).unwrap();
548 assert_eq!(kiro_steering_state(&path, true), SteeringState::Stale);
549
550 std::fs::write(&path, "my own notes about sqz\n").unwrap();
551 assert_eq!(kiro_steering_state(&path, true), SteeringState::UserAuthored);
552 }
553
554 #[test]
555 fn refresh_keeps_the_existing_binary_path_when_it_exists() {
556 let dir = tempfile::tempdir().unwrap();
557 let bin = dir.path().join("sqz");
558 std::fs::write(&bin, "").unwrap();
559 let bin = bin.to_string_lossy().to_string();
560 let path = dir.path().join("steering.md");
561 let stale = kiro_user_steering_content(&bin).replace("sqz_recall", "recall");
562 std::fs::write(&path, stale).unwrap();
563
564 assert!(refresh_kiro_steering(&path, true, "/fallback/sqz").unwrap());
565 assert_eq!(std::fs::read_to_string(&path).unwrap(), kiro_user_steering_content(&bin));
566 assert!(!refresh_kiro_steering(&path, true, "/fallback/sqz").unwrap(), "now current");
567 }
568
569 #[test]
570 fn refresh_falls_back_when_the_old_binary_is_gone() {
571 let dir = tempfile::tempdir().unwrap();
572 let path = dir.path().join("steering.md");
573 let stale = kiro_steering_content("/deleted/target/debug/sqz").replace("sqz_recall", "recall");
574 std::fs::write(&path, stale).unwrap();
575 assert!(refresh_kiro_steering(&path, false, "/fallback/sqz").unwrap());
576 assert!(std::fs::read_to_string(&path).unwrap().contains("/fallback/sqz compress"));
577 }
578
579 #[test]
580 fn refresh_never_touches_user_files() {
581 let dir = tempfile::tempdir().unwrap();
582 let path = dir.path().join("steering.md");
583 std::fs::write(&path, "mine\n").unwrap();
584 assert!(!refresh_kiro_steering(&path, true, "/fallback/sqz").unwrap());
585 assert_eq!(std::fs::read_to_string(&path).unwrap(), "mine\n");
586 }
587
588 #[test]
589 fn embedded_path_round_trips_through_template() {
590 let content = kiro_steering_content("/Users/me/.cargo/bin/sqz");
591 assert_eq!(embedded_sqz_path(&content), Some("/Users/me/.cargo/bin/sqz"));
592 }
593
594 #[test]
595 fn user_mcp_merge_preserves_existing_servers() {
596 let dir = tempfile::tempdir().unwrap();
597 let path = dir.path().join("settings").join("mcp.json");
598 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
599 std::fs::write(
600 &path,
601 r#"{"mcpServers":{"builder-mcp":{"command":"/x/builder-mcp","args":[]}}}"#,
602 )
603 .unwrap();
604 assert_eq!(
605 install_kiro_mcp_config_at(&path).unwrap(),
606 KiroMcpInstall::Merged
607 );
608 let parsed: serde_json::Value =
609 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
610 assert_eq!(parsed["mcpServers"]["builder-mcp"]["command"], "/x/builder-mcp");
611 assert_eq!(parsed["mcpServers"]["sqz"]["command"], "sqz-mcp");
612 }
613}