use anyhow::{Context, Result};
use std::fs;
use std::path::Path;
use crate::skill::SKILL_CONTENT;
const VERSION: &str = env!("CARGO_PKG_VERSION");
struct PlatformConfig {
skill_dst: &'static str,
register_claude_md: bool,
}
const PLATFORMS: &[(&str, PlatformConfig)] = &[
(
"claude",
PlatformConfig {
skill_dst: ".claude/skills/graphify/SKILL.md",
register_claude_md: true,
},
),
(
"codex",
PlatformConfig {
skill_dst: ".agents/skills/graphify/SKILL.md",
register_claude_md: false,
},
),
(
"opencode",
PlatformConfig {
skill_dst: ".config/opencode/skills/graphify/SKILL.md",
register_claude_md: false,
},
),
(
"claw",
PlatformConfig {
skill_dst: ".claw/skills/graphify/SKILL.md",
register_claude_md: false,
},
),
(
"droid",
PlatformConfig {
skill_dst: ".factory/skills/graphify/SKILL.md",
register_claude_md: false,
},
),
(
"trae",
PlatformConfig {
skill_dst: ".trae/skills/graphify/SKILL.md",
register_claude_md: false,
},
),
(
"trae-cn",
PlatformConfig {
skill_dst: ".trae-cn/skills/graphify/SKILL.md",
register_claude_md: false,
},
),
(
"windows",
PlatformConfig {
skill_dst: ".claude/skills/graphify/SKILL.md",
register_claude_md: true,
},
),
];
const SKILL_REGISTRATION: &str = r#"
# graphify
- **graphify** (`~/.claude/skills/graphify/SKILL.md`) - any input to knowledge graph. Trigger: `/graphify`
When the user types `/graphify`, invoke the Skill tool with `skill: "graphify"` before doing anything else.
"#;
const CLAUDE_MD_SECTION: &str = r#"## graphify
This project has a graphify-rs knowledge graph at graphify-out/.
Rules:
- Before answering architecture or codebase questions, read graphify-out/GRAPH_REPORT.md for god nodes and community structure
- If graphify-out/wiki/index.md exists, navigate it instead of reading raw files
- After modifying code files in this session, run `graphify-rs build --path . --output graphify-out --no-llm --update` to keep the graph current (fast, AST-only, ~2-5s)
"#;
const CLAUDE_MD_MARKER: &str = "## graphify";
const AGENTS_MD_SECTION: &str = r#"## graphify
This project has a graphify-rs knowledge graph at graphify-out/.
Rules:
- Before answering architecture or codebase questions, read graphify-out/GRAPH_REPORT.md for god nodes and community structure
- If graphify-out/wiki/index.md exists, navigate it instead of reading raw files
- After modifying code files in this session, run `graphify-rs build --path . --output graphify-out --no-llm --update` to keep the graph current (fast, AST-only, ~2-5s)
"#;
const AGENTS_MD_MARKER: &str = "## graphify";
pub fn check_skill_versions() {
let home = match home_dir() {
Ok(h) => h,
Err(_) => return,
};
for (_, config) in PLATFORMS {
let version_file = home
.join(config.skill_dst)
.parent()
.map(|p| p.join(".graphify_version"))
.unwrap_or_default();
if version_file.exists() {
if let Ok(installed) = fs::read_to_string(&version_file) {
let installed = installed.trim();
if !installed.is_empty() && installed != VERSION {
eprintln!(
" warning: skill is from graphify-rs {}, package is {}. Run 'graphify-rs install' to update.",
installed, VERSION
);
return; }
}
}
}
}
pub fn install_skill(platform: &str) -> Result<()> {
let config = PLATFORMS
.iter()
.find(|(name, _)| *name == platform)
.map(|(_, cfg)| cfg)
.with_context(|| {
let valid: Vec<&str> = PLATFORMS.iter().map(|(n, _)| *n).collect();
format!(
"Unknown platform '{}'. Valid platforms: {}",
platform,
valid.join(", ")
)
})?;
let home = home_dir()?;
let skill_path = home.join(config.skill_dst);
if let Some(parent) = skill_path.parent() {
fs::create_dir_all(parent)
.with_context(|| format!("Failed to create directory {}", parent.display()))?;
}
fs::write(&skill_path, SKILL_CONTENT)
.with_context(|| format!("Failed to write skill file to {}", skill_path.display()))?;
println!(" Wrote skill file to {}", skill_path.display());
if let Some(parent) = skill_path.parent() {
let version_file = parent.join(".graphify_version");
let _ = fs::write(&version_file, VERSION);
}
if config.register_claude_md {
let claude_md_path = home.join(".claude/CLAUDE.md");
register_in_file(&claude_md_path, SKILL_REGISTRATION, "# graphify")?;
println!(" Registered in {}", claude_md_path.display());
}
println!("\n Installed graphify skill for '{}'.", platform);
println!(" Use `/graphify` in your AI assistant to trigger the skill.");
Ok(())
}
pub fn claude_install(project_root: &Path) -> Result<()> {
let claude_md = project_root.join("CLAUDE.md");
append_section(&claude_md, CLAUDE_MD_SECTION, CLAUDE_MD_MARKER)?;
println!(" Updated {}", claude_md.display());
let settings_path = project_root.join(".claude/settings.json");
write_claude_settings_hook(&settings_path)?;
println!(" Wrote hook to {}", settings_path.display());
println!("\n Claude integration installed.");
Ok(())
}
pub fn claude_uninstall(project_root: &Path) -> Result<()> {
let claude_md = project_root.join("CLAUDE.md");
remove_section(&claude_md, CLAUDE_MD_MARKER)?;
println!(" Cleaned {}", claude_md.display());
let settings_path = project_root.join(".claude/settings.json");
remove_claude_settings_hook(&settings_path)?;
println!(" Cleaned {}", settings_path.display());
println!("\n Claude integration uninstalled.");
Ok(())
}
pub fn codex_install(project_root: &Path) -> Result<()> {
let agents_md = project_root.join("AGENTS.md");
append_section(&agents_md, AGENTS_MD_SECTION, AGENTS_MD_MARKER)?;
println!(" Updated {}", agents_md.display());
let hooks_path = project_root.join(".codex/hooks.json");
write_codex_hooks(&hooks_path)?;
println!(" Wrote hook to {}", hooks_path.display());
println!("\n Codex integration installed.");
Ok(())
}
pub fn codex_uninstall(project_root: &Path) -> Result<()> {
let agents_md = project_root.join("AGENTS.md");
remove_section(&agents_md, AGENTS_MD_MARKER)?;
println!(" Cleaned {}", agents_md.display());
let hooks_path = project_root.join(".codex/hooks.json");
if hooks_path.exists() {
fs::remove_file(&hooks_path)?;
println!(" Removed {}", hooks_path.display());
}
println!("\n Codex integration uninstalled.");
Ok(())
}
pub fn opencode_install(project_root: &Path) -> Result<()> {
let agents_md = project_root.join("AGENTS.md");
append_section(&agents_md, AGENTS_MD_SECTION, AGENTS_MD_MARKER)?;
println!(" Updated {}", agents_md.display());
let plugin_path = project_root.join(".opencode/plugins/graphify.js");
write_opencode_plugin(&plugin_path)?;
println!(" Wrote plugin to {}", plugin_path.display());
let config_path = project_root.join("opencode.json");
register_opencode_config(&config_path)?;
println!(" Updated {}", config_path.display());
println!("\n OpenCode integration installed.");
Ok(())
}
pub fn opencode_uninstall(project_root: &Path) -> Result<()> {
let agents_md = project_root.join("AGENTS.md");
remove_section(&agents_md, AGENTS_MD_MARKER)?;
println!(" Cleaned {}", agents_md.display());
let plugin_path = project_root.join(".opencode/plugins/graphify.js");
if plugin_path.exists() {
fs::remove_file(&plugin_path)?;
println!(" Removed {}", plugin_path.display());
}
let config_path = project_root.join("opencode.json");
unregister_opencode_config(&config_path)?;
println!(" Cleaned {}", config_path.display());
println!("\n OpenCode integration uninstalled.");
Ok(())
}
pub fn generic_platform_install(project_root: &Path, platform: &str) -> Result<()> {
let agents_md = project_root.join("AGENTS.md");
append_section(&agents_md, AGENTS_MD_SECTION, AGENTS_MD_MARKER)?;
println!(" Updated {}", agents_md.display());
println!("\n {} integration installed.", platform);
Ok(())
}
pub fn generic_platform_uninstall(project_root: &Path, platform: &str) -> Result<()> {
let agents_md = project_root.join("AGENTS.md");
remove_section(&agents_md, AGENTS_MD_MARKER)?;
println!(" Cleaned {}", agents_md.display());
println!("\n {} integration uninstalled.", platform);
Ok(())
}
fn home_dir() -> Result<std::path::PathBuf> {
dirs::home_dir().context("Could not determine home directory")
}
fn append_section(path: &Path, section: &str, marker: &str) -> Result<()> {
let existing = if path.exists() {
fs::read_to_string(path)?
} else {
String::new()
};
if existing.contains(marker) {
println!(" Section already present in {}, skipping.", path.display());
return Ok(());
}
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let mut content = existing;
if !content.is_empty() && !content.ends_with('\n') {
content.push('\n');
}
content.push('\n');
content.push_str(section);
fs::write(path, content)?;
Ok(())
}
fn remove_section(path: &Path, marker: &str) -> Result<()> {
if !path.exists() {
return Ok(());
}
let content = fs::read_to_string(path)?;
if !content.contains(marker) {
return Ok(());
}
let mut result = String::new();
let mut skipping = false;
for line in content.lines() {
if line.starts_with(marker) {
skipping = true;
continue;
}
if skipping {
if line.starts_with("## ") {
skipping = false;
result.push_str(line);
result.push('\n');
}
continue;
}
result.push_str(line);
result.push('\n');
}
let trimmed = result.trim_end().to_string() + "\n";
fs::write(path, trimmed)?;
Ok(())
}
fn register_in_file(path: &Path, registration_text: &str, marker: &str) -> Result<()> {
let existing = if path.exists() {
fs::read_to_string(path)?
} else {
String::new()
};
if existing.contains(marker) {
return Ok(());
}
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let mut content = existing;
if !content.is_empty() && !content.ends_with('\n') {
content.push('\n');
}
content.push_str(registration_text);
fs::write(path, content)?;
Ok(())
}
fn write_claude_settings_hook(path: &Path) -> Result<()> {
let mut settings: serde_json::Value = if path.exists() {
let content = fs::read_to_string(path)?;
serde_json::from_str(&content).unwrap_or_else(|_| serde_json::json!({}))
} else {
serde_json::json!({})
};
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let hook_entry = serde_json::json!({
"matcher": "Glob|Grep",
"hooks": [{
"type": "command",
"command": "[ -f graphify-out/graph.json ] && echo '{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"additionalContext\":\"graphify-rs: Knowledge graph exists. Read graphify-out/GRAPH_REPORT.md for god nodes and community structure before searching raw files.\"}}' || true"
}]
});
let hooks = settings
.as_object_mut()
.context("settings is not an object")?
.entry("hooks")
.or_insert_with(|| serde_json::json!({}));
let pre_tool_use = hooks
.as_object_mut()
.context("hooks is not an object")?
.entry("PreToolUse")
.or_insert_with(|| serde_json::json!([]));
let arr = pre_tool_use
.as_array_mut()
.context("PreToolUse is not an array")?;
let already = arr
.iter()
.any(|v| v.get("matcher").and_then(|m| m.as_str()) == Some("Glob|Grep"));
if !already {
arr.push(hook_entry);
}
let output = serde_json::to_string_pretty(&settings)?;
fs::write(path, output)?;
Ok(())
}
fn remove_claude_settings_hook(path: &Path) -> Result<()> {
if !path.exists() {
return Ok(());
}
let content = fs::read_to_string(path)?;
let mut settings: serde_json::Value =
serde_json::from_str(&content).unwrap_or_else(|_| serde_json::json!({}));
if let Some(hooks) = settings.get_mut("hooks") {
if let Some(pre_tool_use) = hooks.get_mut("PreToolUse") {
if let Some(arr) = pre_tool_use.as_array_mut() {
arr.retain(|v| v.get("matcher").and_then(|m| m.as_str()) != Some("Glob|Grep"));
}
}
}
let output = serde_json::to_string_pretty(&settings)?;
fs::write(path, output)?;
Ok(())
}
fn write_codex_hooks(path: &Path) -> Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let hooks = serde_json::json!({
"hooks": {
"PreToolUse": [{
"matcher": "Bash",
"hooks": [{
"type": "command",
"command": "[ -f graphify-out/graph.json ] && echo '{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"allow\",\"systemMessage\":\"graphify-rs: Knowledge graph exists. Read graphify-out/GRAPH_REPORT.md for god nodes and community structure before searching raw files.\"}}' || true"
}]
}]
}
});
let output = serde_json::to_string_pretty(&hooks)?;
fs::write(path, output)?;
Ok(())
}
fn write_opencode_plugin(path: &Path) -> Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let plugin_content = r#"// graphify-rs plugin for OpenCode
module.exports = {
name: "graphify-rs",
description: "Knowledge graph integration",
hooks: {
preToolUse: async (ctx) => {
const fs = require("fs");
if (fs.existsSync("graphify-out/graph.json")) {
return {
prefix: "[graphify-rs] Knowledge graph available. Read graphify-out/GRAPH_REPORT.md for architecture overview."
};
}
}
}
};
"#;
fs::write(path, plugin_content)?;
Ok(())
}
fn register_opencode_config(path: &Path) -> Result<()> {
let mut config: serde_json::Value = if path.exists() {
let content = fs::read_to_string(path)?;
serde_json::from_str(&content).unwrap_or_else(|_| serde_json::json!({}))
} else {
serde_json::json!({})
};
let plugins = config
.as_object_mut()
.context("config is not an object")?
.entry("plugins")
.or_insert_with(|| serde_json::json!([]));
if let Some(arr) = plugins.as_array_mut() {
let already = arr
.iter()
.any(|v| v.as_str() == Some(".opencode/plugins/graphify.js"));
if !already {
arr.push(serde_json::json!(".opencode/plugins/graphify.js"));
}
}
let output = serde_json::to_string_pretty(&config)?;
fs::write(path, output)?;
Ok(())
}
fn unregister_opencode_config(path: &Path) -> Result<()> {
if !path.exists() {
return Ok(());
}
let content = fs::read_to_string(path)?;
let mut config: serde_json::Value =
serde_json::from_str(&content).unwrap_or_else(|_| serde_json::json!({}));
if let Some(plugins) = config.get_mut("plugins") {
if let Some(arr) = plugins.as_array_mut() {
arr.retain(|v| v.as_str() != Some(".opencode/plugins/graphify.js"));
}
}
let output = serde_json::to_string_pretty(&config)?;
fs::write(path, output)?;
Ok(())
}