use std::path::{Path, PathBuf};
use anyhow::{Context, Result, bail};
use serde_json::{Value, json};
pub const SKILL: &str = include_str!("../skill/SKILL.md");
pub const ENFORCE_HOOK: &str = include_str!("../skill/sinter-first.sh");
pub const ENFORCE_HOOK_PS1: &str = include_str!("../skill/sinter-first.ps1");
pub const PLATFORM_HOOK: (&str, &str) = if cfg!(windows) {
("sinter-first.ps1", ENFORCE_HOOK_PS1)
} else {
("sinter-first.sh", ENFORCE_HOOK)
};
pub fn card_body() -> &'static str {
SKILL
.strip_prefix("---")
.and_then(|rest| rest.split_once("---"))
.map(|(_, body)| body.trim_start_matches('\n'))
.unwrap_or(SKILL)
}
const AGENTS_CARD: &str = r#"## sinter
This repo has a code knowledge graph at `.sinter/` (derived state — never
commit or edit it). When `.sinter/graph.redb` exists, query sinter BEFORE
any broad filesystem search for symbol location, callers, dependency
impact, structural paths, or diff impact. Fall back to grep only when
sinter returns no usable evidence; read source directly for
function-body behavior.
| Question | Command |
|---|---|
| Vague/conceptual: "where is X handled" | `sinter ask "<question>"` |
| Orient on a symbol (signature, docs, callers) | `sinter show <symbol>` |
| What depends on X / blast radius | `sinter affected <symbol>` |
| What does X depend on (forward) | `sinter deps <symbol>` |
| How does A reach B | `sinter path <A> <B>` |
| What does this commit/diff/PR affect downstream | `sinter impact <rev-range>` (e.g. `HEAD~1..HEAD`) |
- Every read verb takes `--json` and exits grep-style (0 results,
1 none, 2 error) — branch on the code, not the prose. Results carry
call sites (`file:line`).
- `--relations calls,uses` on affected/deps/path drops file-level
import noise from a blast radius.
- Queries self-sync before answering — no manual refresh needed
(`sinter build` remains for CI/scripts; git hooks refresh on commit).
- "unresolved" and candidate lists are real answers — refine and rerun,
never guess a binding; `sinter unresolved` lists the graph's gaps.
- Spawning subagents? Their prompts must mandate sinter for structure
claims (callers, dependencies, blast radius, "no usages" proofs) and
reserve grep/rg for content-only searches.
- Cross-repo workspace? Add `--workspace <manifest.toml>`; symbols may
be `member:Symbol`.
- Anything else: `sinter --help`; graph problems: `sinter doctor`.
"#;
pub(crate) const AGENTS_BEGIN: &str =
"<!-- BEGIN sinter (managed by `sinter install`; edits inside are overwritten) -->";
pub(crate) const AGENTS_END: &str = "<!-- END sinter -->";
pub fn cursor(repo: &Path) -> Result<PathBuf> {
let dir = repo.join(".cursor").join("rules");
std::fs::create_dir_all(&dir)?;
let path = dir.join("sinter.mdc");
let content = format!(
"---
description: Query the sinter code graph for any codebase-structure question
alwaysApply: false
---
{}",
card_body()
);
std::fs::write(&path, content)?;
Ok(path)
}
pub fn agents(repo: &Path) -> Result<PathBuf> {
let path = repo.join("AGENTS.md");
let existing = std::fs::read_to_string(&path).unwrap_or_default();
let block = format!(
"{AGENTS_BEGIN}
{}
{AGENTS_END}",
AGENTS_CARD.trim_end()
);
let merged = match (existing.find(AGENTS_BEGIN), existing.find(AGENTS_END)) {
(Some(start), Some(end)) if end > start => {
let after = existing[end + AGENTS_END.len()..].to_string();
format!("{}{}{}", &existing[..start], block, after)
}
_ if existing.trim().is_empty() => format!(
"{block}
"
),
_ => format!(
"{}
{block}
",
existing.trim_end()
),
};
std::fs::write(&path, merged)?;
Ok(path)
}
pub fn block_current(content: &str) -> bool {
content.contains(card_body().trim_end()) || content.contains(AGENTS_CARD.trim_end())
}
pub fn default_dir() -> Option<PathBuf> {
std::env::var_os("HOME")
.or_else(|| std::env::var_os("USERPROFILE"))
.map(|home| {
PathBuf::from(home)
.join(".claude")
.join("skills")
.join("sinter")
})
}
pub fn mcp(repo: &Path) -> Result<()> {
let repo = repo.canonicalize()?;
let command = mcp_command()?;
for path in [repo.join(".mcp.json"), repo.join(".cursor/mcp.json")] {
std::fs::create_dir_all(path.parent().unwrap())?;
let mut root: Value = match std::fs::read_to_string(&path) {
Ok(existing) => serde_json::from_str(&existing)
.with_context(|| format!("{} exists but is not valid JSON", path.display()))?,
Err(_) => json!({}),
};
root.as_object_mut()
.ok_or_else(|| anyhow::anyhow!("{} top level is not an object", path.display()))?
.entry("mcpServers")
.or_insert(json!({}));
root["mcpServers"]["sinter"] = json!({
"command": command,
"args": ["serve", "--repo", "."],
});
std::fs::write(
&path,
format!(
"{}
",
serde_json::to_string_pretty(&root)?
),
)?;
println!("registered sinter MCP server in {}", path.display());
}
codex_mcp(&repo, &command)?;
Ok(())
}
fn mcp_command() -> Result<String> {
std::env::current_exe()
.context("locate the current sinter executable")?
.into_os_string()
.into_string()
.map_err(|_| anyhow::anyhow!("the current sinter executable path is not valid UTF-8"))
}
pub(crate) const CODEX_BEGIN: &str =
"# BEGIN sinter (managed by `sinter install`; edits inside are overwritten)";
pub(crate) const CODEX_END: &str = "# END sinter";
fn codex_mcp(repo: &Path, command: &str) -> Result<()> {
let dir = repo.join(".codex");
std::fs::create_dir_all(&dir)?;
let path = dir.join("config.toml");
let existing = std::fs::read_to_string(&path).unwrap_or_default();
#[derive(serde::Serialize)]
struct Server<'a> {
command: &'a str,
args: [&'static str; 3],
required: bool,
}
let server = toml::to_string(&Server {
command,
args: ["serve", "--repo", "."],
required: true,
})
.context("serialize the Codex MCP registration")?;
let block = format!("{CODEX_BEGIN}\n[mcp_servers.sinter]\n{server}{CODEX_END}");
let merged = match (existing.find(CODEX_BEGIN), existing.find(CODEX_END)) {
(Some(start), Some(end)) if end > start => {
let after = existing[end + CODEX_END.len()..].to_string();
format!("{}{}{}", &existing[..start], block, after)
}
_ if existing.trim().is_empty() => format!("{block}\n"),
_ => format!("{}\n\n{block}\n", existing.trim_end()),
};
std::fs::write(&path, merged)?;
println!("registered sinter MCP server in {}", path.display());
Ok(())
}
pub fn stale_artifacts(repo: &Path) -> Vec<String> {
let mut out = Vec::new();
if let Some(dir) = default_dir()
&& let Ok(card) = std::fs::read_to_string(dir.join("SKILL.md"))
&& card != SKILL
{
out.push("skill card is stale — run `sinter install`".to_string());
}
let (hook_file, hook_body) = PLATFORM_HOOK;
for (claude, fix) in [
(Some(repo.join(".claude")), "run `sinter install enforce`"),
(claude_home(), "run `sinter install enforce -g`"),
] {
if let Some(claude) = claude
&& let Ok(script) = std::fs::read_to_string(claude.join("hooks").join(hook_file))
&& script != hook_body
{
out.push(format!(
"enforcement hook {} is stale — {fix}",
claude.join("hooks").join(hook_file).display()
));
}
}
if let Ok(agents) = std::fs::read_to_string(repo.join("AGENTS.md"))
&& agents.contains(AGENTS_BEGIN)
&& !block_current(&agents)
{
out.push("AGENTS.md sinter block is stale — run `sinter install agents`".to_string());
}
if let Ok(rule) = std::fs::read_to_string(repo.join(".cursor/rules/sinter.mdc"))
&& !block_current(&rule)
{
out.push("Cursor rule is stale — run `sinter install cursor`".to_string());
}
out
}
pub(crate) fn claude_home() -> Option<PathBuf> {
std::env::var_os("HOME")
.or_else(|| std::env::var_os("USERPROFILE"))
.map(|home| PathBuf::from(home).join(".claude"))
}
pub fn enforce(repo: Option<&Path>, strict: bool) -> Result<()> {
let claude = match repo {
Some(repo) => repo.canonicalize()?.join(".claude"),
None => claude_home().ok_or_else(|| anyhow::anyhow!("cannot locate home directory"))?,
};
let hooks_dir = claude.join("hooks");
std::fs::create_dir_all(&hooks_dir)?;
let (hook_file, hook_body) = PLATFORM_HOOK;
let script = hooks_dir.join(hook_file);
std::fs::write(&script, hook_body)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755))?;
}
println!("installed {}", script.display());
let settings_path = claude.join("settings.json");
let mut root: Value = match std::fs::read_to_string(&settings_path) {
Ok(existing) => serde_json::from_str(&existing)
.with_context(|| format!("{} exists but is not valid JSON", settings_path.display()))?,
Err(_) => json!({}),
};
let script_str = match repo {
Some(_) => format!(".claude/hooks/{hook_file}"),
None => script.display().to_string(),
};
let entry = |mode: &str| {
if cfg!(windows) {
json!({"type": "command", "shell": "powershell",
"command": format!("& '{script_str}' {mode}")})
} else {
json!({"type": "command", "command": format!("bash {script_str} {mode}")})
}
};
let hooks = root
.as_object_mut()
.ok_or_else(|| anyhow::anyhow!("{} top level is not an object", settings_path.display()))?
.entry("hooks")
.or_insert(json!({}));
let (grep_mode, greptool_mode) = if strict {
("grep-strict", "greptool-strict")
} else {
("grep", "greptool")
};
for (event, matcher, mode) in [
("PreToolUse", Some("Bash"), grep_mode),
("PreToolUse", Some("Grep"), greptool_mode),
("PreToolUse", Some("Task|Agent"), "task"),
("UserPromptSubmit", None, "prompt"),
] {
let groups = hooks
.as_object_mut()
.ok_or_else(|| anyhow::anyhow!("settings `hooks` is not an object"))?
.entry(event)
.or_insert(json!([]));
let groups = groups
.as_array_mut()
.ok_or_else(|| anyhow::anyhow!("settings hooks.{event} is not an array"))?;
let group = groups
.iter_mut()
.find(|g| g.get("matcher").and_then(Value::as_str) == matcher);
let group = match group {
Some(g) => g,
None => {
groups.push(match matcher {
Some(m) => json!({"matcher": m, "hooks": []}),
None => json!({"hooks": []}),
});
groups.last_mut().expect("just pushed")
}
};
let list = group
.as_object_mut()
.and_then(|g| g.get_mut("hooks"))
.and_then(Value::as_array_mut)
.ok_or_else(|| anyhow::anyhow!("settings hooks.{event} group has no hooks array"))?;
let base = mode.strip_suffix("-strict").unwrap_or(mode);
let ours = |c: &str| {
c.contains("sinter-first.")
&& (c.ends_with(&format!(" {base}")) || c.ends_with(&format!(" {base}-strict")))
};
match list
.iter_mut()
.find(|h| h.get("command").and_then(Value::as_str).is_some_and(&ours))
{
Some(existing) => *existing = entry(mode),
None => list.push(entry(mode)),
}
}
std::fs::write(
&settings_path,
format!("{}\n", serde_json::to_string_pretty(&root)?),
)?;
println!(
"registered enforcement hooks in {}",
settings_path.display()
);
Ok(())
}
pub fn run_targets(
targets: &[String],
dir: Option<PathBuf>,
mcp_flag: bool,
repo: &Path,
global: bool,
strict: bool,
) -> Result<()> {
let expanded: Vec<&str> = if targets.iter().any(|t| t == "all") {
vec!["claude", "cursor", "agents", "enforce"]
} else {
targets.iter().map(String::as_str).collect()
};
for target in expanded {
match target {
"claude" => run(dir.clone())?,
"cursor" => {
let path = cursor(&repo.canonicalize()?)?;
println!("installed {}", path.display());
}
"agents" => {
let path = agents(&repo.canonicalize()?)?;
println!("merged managed sinter block into {}", path.display());
}
"enforce" => enforce((!global).then_some(repo), strict)?,
other => {
bail!("unknown install target `{other}` (claude, cursor, agents, enforce, all)")
}
}
}
if mcp_flag {
mcp(repo)?;
}
Ok(())
}
pub fn run(dir: Option<PathBuf>) -> Result<()> {
let target = match dir.or_else(default_dir) {
Some(dir) => dir,
None => bail!("cannot locate home directory; pass --dir"),
};
std::fs::create_dir_all(&target).with_context(|| format!("create {}", target.display()))?;
let path = target.join("SKILL.md");
std::fs::write(&path, SKILL).with_context(|| format!("write {}", path.display()))?;
println!("installed {}", path.display());
println!("rerun `sinter install` after upgrading sinter to refresh the card");
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn agents_block_routes_match_card() {
let card = card_body();
for chunk in AGENTS_CARD.split("`sinter ").skip(1) {
let verb = chunk.split([' ', '`', '\n']).next().unwrap();
if verb.starts_with('-') {
continue; }
assert!(
card.contains(&format!("sinter {verb}")),
"compact block routes `sinter {verb}` but the full card never mentions it"
);
}
for rule in ["never", "unresolved", "sinter build", "--workspace"] {
assert!(
AGENTS_CARD.contains(rule),
"compact block lost rule: {rule}"
);
assert!(card.contains(rule), "full card lost rule: {rule}");
}
}
}