Skip to content

Commit fde074c

Browse files
h4x0rclaude
andcommitted
feat(ecosystem): add RTK plugin, apply_install, and opt-out telemetry
Add RTK ecosystem plugin for hooks-based CLI proxy detection/install. Add apply_install() on PluginInstallConfig with JSON deep merge for writing MCP/hooks config into settings.json. Add opt-out telemetry flag (disabled by default) that injects cost-saving env vars into spawned agents when enabled. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 42ba6af commit fde074c

5 files changed

Lines changed: 529 additions & 1 deletion

File tree

crates/shepherd-core/src/config/types.rs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,14 @@ pub struct EcosystemConfig {
9393
#[serde(default = "default_true")]
9494
pub auto_detect_context_hub: bool,
9595
#[serde(default = "default_true")]
96+
pub auto_detect_rtk: bool,
97+
#[serde(default = "default_true")]
9698
pub offer_install_on_new_task: bool,
99+
/// Disable non-essential telemetry (Statsig, Sentry, RTK analytics)
100+
/// in spawned agents. Defaults to `false` — telemetry stays enabled
101+
/// unless the user explicitly opts out.
102+
#[serde(default)]
103+
pub disable_agent_telemetry: bool,
97104
}
98105

99106
fn default_true() -> bool {
@@ -135,7 +142,9 @@ impl Default for EcosystemConfig {
135142
auto_detect_ralph_loop: true,
136143
auto_detect_frontend_design: true,
137144
auto_detect_context_hub: true,
145+
auto_detect_rtk: true,
138146
offer_install_on_new_task: true,
147+
disable_agent_telemetry: false,
139148
}
140149
}
141150
}

crates/shepherd-core/src/ecosystem/mod.rs

Lines changed: 283 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ pub mod ffmpeg;
88
pub mod frontend_design;
99
pub mod playwright;
1010
pub mod ralph_loop;
11+
pub mod rtk;
1112
pub mod serena;
1213
pub mod sourcegraph;
1314
pub mod superpowers;
@@ -33,6 +34,111 @@ pub struct PluginInstallConfig {
3334
pub config_content: String,
3435
}
3536

37+
impl PluginInstallConfig {
38+
/// Resolve `~` in `target_path` to the given home directory.
39+
pub fn resolve_path(&self, home: &Path) -> PathBuf {
40+
let s = self.target_path.to_string_lossy();
41+
if s.starts_with("~/") {
42+
home.join(&s[2..])
43+
} else {
44+
self.target_path.clone()
45+
}
46+
}
47+
48+
/// Write the plugin config into the target settings file.
49+
///
50+
/// For JSON files (`.json`): deep-merges `config_content` into the
51+
/// existing file, preserving other keys. Creates the file and parent
52+
/// directories if they don't exist.
53+
///
54+
/// Returns the resolved path that was written to.
55+
pub fn apply_install(&self, home: &Path) -> Result<PathBuf, ApplyInstallError> {
56+
let resolved = self.resolve_path(home);
57+
58+
if let Some(parent) = resolved.parent() {
59+
std::fs::create_dir_all(parent)
60+
.map_err(|e| ApplyInstallError::Io(parent.to_path_buf(), e))?;
61+
}
62+
63+
let is_json = resolved
64+
.extension()
65+
.map(|ext| ext == "json")
66+
.unwrap_or(false);
67+
68+
if is_json {
69+
self.apply_json_merge(&resolved)?;
70+
} else {
71+
// For non-JSON files (CLAUDE.md, instructions.md, config.toml),
72+
// append if not already present.
73+
let existing = std::fs::read_to_string(&resolved).unwrap_or_default();
74+
if !existing.contains(&self.config_content) {
75+
let mut content = existing;
76+
if !content.is_empty() && !content.ends_with('\n') {
77+
content.push('\n');
78+
}
79+
content.push_str(&self.config_content);
80+
std::fs::write(&resolved, content)
81+
.map_err(|e| ApplyInstallError::Io(resolved.clone(), e))?;
82+
}
83+
}
84+
85+
Ok(resolved)
86+
}
87+
88+
fn apply_json_merge(&self, path: &Path) -> Result<(), ApplyInstallError> {
89+
let existing_str = std::fs::read_to_string(path).unwrap_or_else(|_| "{}".to_string());
90+
let mut existing: serde_json::Value = serde_json::from_str(&existing_str)
91+
.map_err(|e| ApplyInstallError::Json(path.to_path_buf(), e))?;
92+
93+
let incoming: serde_json::Value = serde_json::from_str(&self.config_content)
94+
.map_err(|e| ApplyInstallError::Json(path.to_path_buf(), e))?;
95+
96+
json_deep_merge(&mut existing, &incoming);
97+
98+
let output = serde_json::to_string_pretty(&existing)
99+
.map_err(|e| ApplyInstallError::Json(path.to_path_buf(), e))?;
100+
std::fs::write(path, output.as_bytes())
101+
.map_err(|e| ApplyInstallError::Io(path.to_path_buf(), e))?;
102+
103+
Ok(())
104+
}
105+
}
106+
107+
/// Recursively merge `source` into `target`. Objects are merged key-by-key;
108+
/// all other types overwrite.
109+
fn json_deep_merge(target: &mut serde_json::Value, source: &serde_json::Value) {
110+
match (target, source) {
111+
(serde_json::Value::Object(t), serde_json::Value::Object(s)) => {
112+
for (key, value) in s {
113+
json_deep_merge(
114+
t.entry(key.clone()).or_insert(serde_json::Value::Null),
115+
value,
116+
);
117+
}
118+
}
119+
(target, source) => {
120+
*target = source.clone();
121+
}
122+
}
123+
}
124+
125+
#[derive(Debug)]
126+
pub enum ApplyInstallError {
127+
Io(PathBuf, std::io::Error),
128+
Json(PathBuf, serde_json::Error),
129+
}
130+
131+
impl std::fmt::Display for ApplyInstallError {
132+
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
133+
match self {
134+
Self::Io(path, e) => write!(f, "IO error at {}: {}", path.display(), e),
135+
Self::Json(path, e) => write!(f, "JSON error at {}: {}", path.display(), e),
136+
}
137+
}
138+
}
139+
140+
impl std::error::Error for ApplyInstallError {}
141+
36142
/// A shared, data-driven definition for an ecosystem plugin.
37143
///
38144
/// Each ecosystem module defines a `plugin()` function that returns one of
@@ -331,4 +437,181 @@ mod tests {
331437
// Should not find project-scope match, falls through to None
332438
assert!(!result.installed || result.scope != InstallScope::Project);
333439
}
440+
441+
// ── resolve_path tests ──────────────────────────────────────────
442+
443+
#[test]
444+
fn test_resolve_path_tilde() {
445+
let config = PluginInstallConfig {
446+
agent: "claude-code".into(),
447+
scope: InstallScope::User,
448+
target_path: PathBuf::from("~/.claude/settings.json"),
449+
config_content: "{}".into(),
450+
};
451+
let resolved = config.resolve_path(Path::new("/home/user"));
452+
assert_eq!(resolved, PathBuf::from("/home/user/.claude/settings.json"));
453+
}
454+
455+
#[test]
456+
fn test_resolve_path_relative() {
457+
let config = PluginInstallConfig {
458+
agent: "claude-code".into(),
459+
scope: InstallScope::Project,
460+
target_path: PathBuf::from(".claude/settings.json"),
461+
config_content: "{}".into(),
462+
};
463+
let resolved = config.resolve_path(Path::new("/home/user"));
464+
assert_eq!(resolved, PathBuf::from(".claude/settings.json"));
465+
}
466+
467+
// ── apply_install JSON merge tests ──────────────────────────────
468+
469+
#[test]
470+
fn test_apply_install_creates_new_file() {
471+
let tmp = tempfile::tempdir().unwrap();
472+
let config = PluginInstallConfig {
473+
agent: "claude-code".into(),
474+
scope: InstallScope::User,
475+
target_path: PathBuf::from("~/.claude/settings.json"),
476+
config_content: r#"{"mcpServers":{"test":{"command":"npx"}}}"#.into(),
477+
};
478+
let path = config.apply_install(tmp.path()).unwrap();
479+
let content: serde_json::Value =
480+
serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
481+
assert!(content["mcpServers"]["test"]["command"].as_str() == Some("npx"));
482+
}
483+
484+
#[test]
485+
fn test_apply_install_merges_into_existing() {
486+
let tmp = tempfile::tempdir().unwrap();
487+
let claude_dir = tmp.path().join(".claude");
488+
std::fs::create_dir_all(&claude_dir).unwrap();
489+
std::fs::write(
490+
claude_dir.join("settings.json"),
491+
r#"{"mcpServers":{"existing":{"command":"node"}},"other":"keep"}"#,
492+
)
493+
.unwrap();
494+
495+
let config = PluginInstallConfig {
496+
agent: "claude-code".into(),
497+
scope: InstallScope::User,
498+
target_path: PathBuf::from("~/.claude/settings.json"),
499+
config_content: r#"{"mcpServers":{"new-plugin":{"command":"npx"}}}"#.into(),
500+
};
501+
let path = config.apply_install(tmp.path()).unwrap();
502+
let content: serde_json::Value =
503+
serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
504+
// Existing entries preserved
505+
assert_eq!(content["mcpServers"]["existing"]["command"], "node");
506+
// New entry added
507+
assert_eq!(content["mcpServers"]["new-plugin"]["command"], "npx");
508+
// Other keys preserved
509+
assert_eq!(content["other"], "keep");
510+
}
511+
512+
#[test]
513+
fn test_apply_install_idempotent() {
514+
let tmp = tempfile::tempdir().unwrap();
515+
let config = PluginInstallConfig {
516+
agent: "claude-code".into(),
517+
scope: InstallScope::User,
518+
target_path: PathBuf::from("~/.claude/settings.json"),
519+
config_content: r#"{"mcpServers":{"test":{"command":"npx"}}}"#.into(),
520+
};
521+
config.apply_install(tmp.path()).unwrap();
522+
config.apply_install(tmp.path()).unwrap();
523+
let path = tmp.path().join(".claude/settings.json");
524+
let content: serde_json::Value =
525+
serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
526+
// Only one entry, not duplicated
527+
assert_eq!(content["mcpServers"].as_object().unwrap().len(), 1);
528+
}
529+
530+
#[test]
531+
fn test_apply_install_invalid_existing_json() {
532+
let tmp = tempfile::tempdir().unwrap();
533+
let claude_dir = tmp.path().join(".claude");
534+
std::fs::create_dir_all(&claude_dir).unwrap();
535+
std::fs::write(claude_dir.join("settings.json"), "not json!!!").unwrap();
536+
537+
let config = PluginInstallConfig {
538+
agent: "claude-code".into(),
539+
scope: InstallScope::User,
540+
target_path: PathBuf::from("~/.claude/settings.json"),
541+
config_content: r#"{"mcpServers":{}}"#.into(),
542+
};
543+
let result = config.apply_install(tmp.path());
544+
assert!(result.is_err());
545+
assert!(result.unwrap_err().to_string().contains("JSON error"));
546+
}
547+
548+
// ── apply_install non-JSON (append) tests ───────────────────────
549+
550+
#[test]
551+
fn test_apply_install_appends_to_md() {
552+
let tmp = tempfile::tempdir().unwrap();
553+
let claude_dir = tmp.path().join(".claude");
554+
std::fs::create_dir_all(&claude_dir).unwrap();
555+
std::fs::write(claude_dir.join("CLAUDE.md"), "# Existing\n").unwrap();
556+
557+
let config = PluginInstallConfig {
558+
agent: "claude-code".into(),
559+
scope: InstallScope::User,
560+
target_path: PathBuf::from("~/.claude/CLAUDE.md"),
561+
config_content: "# Superpowers\n".into(),
562+
};
563+
config.apply_install(tmp.path()).unwrap();
564+
let content = std::fs::read_to_string(claude_dir.join("CLAUDE.md")).unwrap();
565+
assert!(content.contains("# Existing"));
566+
assert!(content.contains("# Superpowers"));
567+
}
568+
569+
#[test]
570+
fn test_apply_install_md_idempotent() {
571+
let tmp = tempfile::tempdir().unwrap();
572+
let config = PluginInstallConfig {
573+
agent: "claude-code".into(),
574+
scope: InstallScope::User,
575+
target_path: PathBuf::from("~/.claude/CLAUDE.md"),
576+
config_content: "# Superpowers\n".into(),
577+
};
578+
config.apply_install(tmp.path()).unwrap();
579+
config.apply_install(tmp.path()).unwrap();
580+
let content = std::fs::read_to_string(tmp.path().join(".claude/CLAUDE.md")).unwrap();
581+
assert_eq!(content.matches("# Superpowers").count(), 1);
582+
}
583+
584+
// ── json_deep_merge tests ───────────────────────────────────────
585+
586+
#[test]
587+
fn test_json_deep_merge_objects() {
588+
let mut target: serde_json::Value = serde_json::json!({"a": 1, "nested": {"x": 10}});
589+
let source = serde_json::json!({"b": 2, "nested": {"y": 20}});
590+
json_deep_merge(&mut target, &source);
591+
assert_eq!(target["a"], 1);
592+
assert_eq!(target["b"], 2);
593+
assert_eq!(target["nested"]["x"], 10);
594+
assert_eq!(target["nested"]["y"], 20);
595+
}
596+
597+
#[test]
598+
fn test_json_deep_merge_overwrite_scalar() {
599+
let mut target: serde_json::Value = serde_json::json!({"a": 1});
600+
let source = serde_json::json!({"a": 99});
601+
json_deep_merge(&mut target, &source);
602+
assert_eq!(target["a"], 99);
603+
}
604+
605+
// ── ApplyInstallError display ───────────────────────────────────
606+
607+
#[test]
608+
fn test_apply_install_error_display() {
609+
let err = ApplyInstallError::Io(
610+
PathBuf::from("/test"),
611+
std::io::Error::new(std::io::ErrorKind::PermissionDenied, "denied"),
612+
);
613+
let msg = err.to_string();
614+
assert!(msg.contains("IO error"));
615+
assert!(msg.contains("/test"));
616+
}
334617
}

0 commit comments

Comments
 (0)