use std::fs;
use std::path::{Path, PathBuf};
pub const SCRYER_START_SENTINEL: &str = "<!-- scryer:start -->";
pub const SCRYER_END_SENTINEL: &str = "<!-- scryer:end -->";
/// Path of the generated skill, relative to the project root.
pub const SKILL_RELATIVE_PATH: &str = ".agents/skills/scryer/SKILL.md";
/// Generate the short Scryer block for AGENTS.md, wrapped in sentinels.
///
/// AGENTS.md is read in every session, so this holds only what must be true even when the
/// skill is never loaded: use Scryer instead of grep, the one-line tool choice, when grep is
/// still right, and how to read an empty result. Everything else lives in the skill
/// ([`generate_agent_skill`]) and in the MCP server's `instructions`.
pub fn generate_agent_instructions() -> String {
format!(
r#"{SCRYER_START_SENTINEL}
# Code Intelligence (Scryer)
This repository is indexed by **Scryer** (Rust, Python, TypeScript). When exploring or about to edit code, use it instead of grep and whole-file reads, and **load the `scryer` skill (`{SKILL_RELATIVE_PATH}`) for the full playbook**.
- Don't know the name → `search_symbols`. Know it, or a crate path like `tokio::sync::Mutex` → `inspect_symbol`. Know a file and line → `get_enclosing_scope` / `resolve_definition`. Want a file's shape → `get_file_outline`. About to change something → `calculate_blast_radius` + `query_adrs`.
- Grep is still right for string literals, log and error messages, comments, environment variables, non-code files (Markdown, JSON, YAML, TOML, Dockerfiles), or when the index doesn't resolve a symbol.
- A list marked `complete` is exhaustive over the indexed files: don't re-grep it unless a `CAVEAT` applies. A cut list says `complete: false` and the next call. An empty result with a `notes` entry means the name or path didn't match, not that nothing uses it; `INDEXING` or `STALE` means partial, so retry shortly. The index follows your edits. Lines are 1-based, columns 0-based.
- Not seen: code behind `cfg` is indexed whether or not it compiles; trait-object and closure calls resolve by name; build-script output; languages other than Rust, Python and TypeScript. "Who calls X?" is `find_references(X)` (default `view: callers`: each caller once, with its line), not grep plus a lookup per hit. Batch where you can: `get_enclosing_scope(at: ["src/a.rs:12", ..])` takes grep hits across files (or `lines: [..]` in one file), `inspect_symbol(symbols: [..])`.
- Antigravity / Gemini CLI: call tools through the gateway, `call_mcp_tool(ServerName: "Scryer", ToolName: "inspect_symbol", Arguments: {{ "symbol": "MySymbol" }})`. No MCP tools at all? Use the CLI: `scryer --help`, `scryer inspect-symbol MySymbol`, `scryer get-file-outline src/main.rs`.
{SCRYER_END_SENTINEL}"#
)
}
/// A Codex `config.toml` entry that exposes only the core tools, generated from
/// [`scryer_mcp::tools::CORE_TOOLS`] so it cannot drift from the server.
pub fn codex_config_snippet() -> String {
let tools = scryer_mcp::tools::CORE_TOOLS
.iter()
.map(|t| format!("\"{t}\""))
.collect::<Vec<_>>()
.join(", ");
format!(
"[mcp_servers.scryer]\ncommand = \"scryer\"\nargs = [\"serve\"]\nenabled_tools = [{tools}]\ntool_timeout_sec = 60\n"
)
}
/// Resolve target path to an AGENTS.md file.
pub fn resolve_agents_file(target: &Path) -> PathBuf {
if target.is_dir() {
target.join("AGENTS.md")
} else {
target.to_path_buf()
}
}
/// Result of an installation or update operation.
#[derive(Debug, PartialEq, Eq)]
pub enum InstallOutcome {
Created,
Appended,
Updated,
AlreadyUpToDate,
/// The file already has its own Scryer configuration, which was left as the user wrote it.
UserEdited,
}
/// Apply Scryer instructions to content. Returns (new_content, outcome).
pub fn apply_instructions_to_content(content: &str) -> anyhow::Result<(String, InstallOutcome)> {
let instructions = generate_agent_instructions();
let has_start = content.contains(SCRYER_START_SENTINEL);
let has_end = content.contains(SCRYER_END_SENTINEL);
match (has_start, has_end) {
(true, true) => {
let start_idx = content
.find(SCRYER_START_SENTINEL)
.expect("start sentinel checked");
let end_idx = content
.find(SCRYER_END_SENTINEL)
.expect("end sentinel checked")
+ SCRYER_END_SENTINEL.len();
if start_idx >= end_idx {
anyhow::bail!(
"Malformed sentinels: `{}` appears after `{}`",
SCRYER_START_SENTINEL,
SCRYER_END_SENTINEL
);
}
let existing_block = &content[start_idx..end_idx];
if existing_block.trim() == instructions.trim() {
Ok((content.to_string(), InstallOutcome::AlreadyUpToDate))
} else {
let mut new_content = String::new();
new_content.push_str(&content[..start_idx]);
new_content.push_str(&instructions);
new_content.push_str(&content[end_idx..]);
Ok((new_content, InstallOutcome::Updated))
}
}
(true, false) => {
anyhow::bail!(
"Malformed AGENTS.md: found `{}` but missing closing `{}`",
SCRYER_START_SENTINEL,
SCRYER_END_SENTINEL
);
}
(false, true) => {
anyhow::bail!(
"Malformed AGENTS.md: found closing `{}` but missing `{}`",
SCRYER_END_SENTINEL,
SCRYER_START_SENTINEL
);
}
(false, false) => {
if content.trim().is_empty() {
Ok((instructions, InstallOutcome::Created))
} else {
let trimmed = content.trim_end();
let new_content = format!("{trimmed}\n\n{instructions}\n");
Ok((new_content, InstallOutcome::Appended))
}
}
}
}
/// Write `contents` to `path` without ever leaving it half-written.
///
/// Writes a sibling temp file and renames it over `path`, keeping the original's permissions.
/// A crash or full disk then leaves the old file intact instead of a truncated one.
pub fn write_atomic(path: &Path, contents: &str) -> anyhow::Result<()> {
let file_name = path
.file_name()
.ok_or_else(|| anyhow::anyhow!("`{}` has no file name", path.display()))?
.to_string_lossy();
let tmp = path.with_file_name(format!(".{file_name}.scryer-tmp"));
fs::write(&tmp, contents)?;
if let Ok(meta) = fs::metadata(path) {
let _ = fs::set_permissions(&tmp, meta.permissions());
}
if let Err(e) = fs::rename(&tmp, path) {
let _ = fs::remove_file(&tmp);
return Err(e.into());
}
Ok(())
}
/// Install or update instructions in the target file.
pub fn install_instructions(
target_path: &Path,
check_only: bool,
) -> anyhow::Result<InstallOutcome> {
let file_path = resolve_agents_file(target_path);
if !file_path.exists() {
if check_only {
anyhow::bail!("Target file `{}` does not exist", file_path.display());
}
if let Some(parent) = file_path.parent() {
fs::create_dir_all(parent)?;
}
let instructions = format!("{}\n", generate_agent_instructions());
write_atomic(&file_path, &instructions)?;
return Ok(InstallOutcome::Created);
}
let existing_content = fs::read_to_string(&file_path)?;
let (new_content, outcome) = apply_instructions_to_content(&existing_content)?;
if check_only {
if outcome == InstallOutcome::AlreadyUpToDate {
return Ok(InstallOutcome::AlreadyUpToDate);
} else {
anyhow::bail!(
"Scryer instructions in `{}` are missing or outdated",
file_path.display()
);
}
}
if outcome != InstallOutcome::AlreadyUpToDate {
write_atomic(&file_path, &new_content)?;
}
Ok(outcome)
}
// ---------------------------------------------------------------------------------------------
// Per-agent setup: `scryer init --agent claude|codex|cursor`
//
// Every installer here follows the rules of the AGENTS.md block: Scryer's text goes between
// sentinels (or, in JSON, under its own key), what the user wrote outside them is never
// touched, running twice changes nothing, and `check_only` reports without writing.
// ---------------------------------------------------------------------------------------------
/// An agent Scryer can configure.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum Agent {
Claude,
Codex,
Cursor,
}
/// The agents a project already shows signs of using, so a bare `scryer init` configures what
/// is there and nothing else.
pub fn detect_agents(project_root: &Path) -> Vec<Agent> {
let mut agents = Vec::new();
if ["CLAUDE.md", ".claude", ".mcp.json"]
.iter()
.any(|p| project_root.join(p).exists())
{
agents.push(Agent::Claude);
}
if project_root.join(".codex").exists() {
agents.push(Agent::Codex);
}
if project_root.join(".cursor").exists() {
agents.push(Agent::Cursor);
}
agents
}
/// Sentinels for TOML files, which have no HTML comments.
pub const TOML_START_SENTINEL: &str = "# scryer:start";
pub const TOML_END_SENTINEL: &str = "# scryer:end";
/// Put `block` (which starts with `start` and ends with `end`) into `content`: replace the
/// existing block, or append one. `label` names the file in errors.
fn apply_block(
content: &str,
start: &str,
end: &str,
block: &str,
label: &str,
) -> anyhow::Result<(String, InstallOutcome)> {
match (content.find(start), content.find(end)) {
(Some(start_idx), Some(end_idx)) => {
let end_idx = end_idx + end.len();
if start_idx >= end_idx {
anyhow::bail!("Malformed sentinels in {label}: `{start}` appears after `{end}`");
}
if content[start_idx..end_idx].trim() == block.trim() {
return Ok((content.to_string(), InstallOutcome::AlreadyUpToDate));
}
Ok((
format!("{}{block}{}", &content[..start_idx], &content[end_idx..]),
InstallOutcome::Updated,
))
}
(Some(_), None) => anyhow::bail!("Malformed {label}: found `{start}` but missing `{end}`"),
(None, Some(_)) => anyhow::bail!("Malformed {label}: found `{end}` but missing `{start}`"),
(None, None) if content.trim().is_empty() => {
Ok((format!("{block}\n"), InstallOutcome::Created))
}
(None, None) => Ok((
format!("{}\n\n{block}\n", content.trim_end()),
InstallOutcome::Appended,
)),
}
}
/// Read `path` (empty if missing), apply `apply`, and write the result unless `check_only`.
/// With `check_only`, anything but an up-to-date or user-edited file is an error.
fn install_with(
path: &Path,
check_only: bool,
apply: impl FnOnce(&str) -> anyhow::Result<(String, InstallOutcome)>,
) -> anyhow::Result<InstallOutcome> {
let existing = match fs::read_to_string(path) {
Ok(text) => text,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
Err(e) => return Err(e.into()),
};
let (updated, outcome) = apply(&existing)?;
match outcome {
InstallOutcome::AlreadyUpToDate | InstallOutcome::UserEdited => Ok(outcome),
_ if check_only => anyhow::bail!("Scryer's setup is missing or outdated"),
_ => {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
write_atomic(path, &updated)?;
Ok(outcome)
}
}
}
/// The `scryer` entry of an MCP client's `mcpServers` table.
///
/// The core profile (six tools) keeps definitions small. For Claude Code the server is also
/// marked `alwaysLoad` so older versions, which ignore the per-tool marker, still load the
/// tools up front: deferred tools were used in 6 of 18 large-repository runs, loaded ones in 18.
pub fn mcp_server_entry(agent: Agent) -> serde_json::Value {
let mut entry = serde_json::json!({
"command": "scryer",
"args": ["serve", "--tools", "core"],
});
if agent == Agent::Claude {
entry["alwaysLoad"] = serde_json::Value::Bool(true);
}
entry
}
/// Add the `scryer` entry to the `mcpServers` of an MCP config file (`.mcp.json`,
/// `.cursor/mcp.json`). Other servers and keys are kept. An existing `scryer` entry that
/// differs is the user's and is left alone (`UserEdited`).
pub fn apply_mcp_json(
content: &str,
entry: &serde_json::Value,
) -> anyhow::Result<(String, InstallOutcome)> {
let existed = !content.trim().is_empty();
let mut root: serde_json::Value = if existed {
serde_json::from_str(content)
.map_err(|e| anyhow::anyhow!("not valid JSON, so left untouched: {e}"))?
} else {
serde_json::json!({})
};
let Some(object) = root.as_object_mut() else {
anyhow::bail!("expected a JSON object at the top level, so left untouched");
};
let servers = object
.entry("mcpServers")
.or_insert_with(|| serde_json::json!({}));
let Some(servers) = servers.as_object_mut() else {
anyhow::bail!("`mcpServers` is not an object, so left untouched");
};
match servers.get("scryer") {
Some(current) if current == entry => {
Ok((content.to_string(), InstallOutcome::AlreadyUpToDate))
}
Some(_) => Ok((content.to_string(), InstallOutcome::UserEdited)),
None => {
servers.insert("scryer".to_string(), entry.clone());
let outcome = if existed {
InstallOutcome::Appended
} else {
InstallOutcome::Created
};
Ok((
format!("{}\n", serde_json::to_string_pretty(&root)?),
outcome,
))
}
}
}
/// Write the `scryer` MCP entry for `agent` into `path`.
pub fn install_mcp_json(
path: &Path,
agent: Agent,
check_only: bool,
) -> anyhow::Result<InstallOutcome> {
let entry = mcp_server_entry(agent);
install_with(path, check_only, |c| apply_mcp_json(c, &entry))
.map_err(|e| anyhow::anyhow!("`{}`: {e}", path.display()))
}
/// The permission rules that let Claude Code use Scryer without prompting: the shell CLI and
/// the core tools.
pub fn claude_permission_rules() -> Vec<String> {
let mut rules = vec!["Bash(scryer:*)".to_string()];
rules.extend(
scryer_mcp::tools::CORE_TOOLS
.iter()
.map(|t| format!("mcp__scryer__{t}")),
);
rules
}
/// Add Scryer's rules to `permissions.allow` of a Claude Code settings file. Rules and keys
/// the user has are kept, and none is ever removed.
pub fn apply_claude_settings(content: &str) -> anyhow::Result<(String, InstallOutcome)> {
let existed = !content.trim().is_empty();
let mut root: serde_json::Value = if existed {
serde_json::from_str(content)
.map_err(|e| anyhow::anyhow!("not valid JSON, so left untouched: {e}"))?
} else {
serde_json::json!({})
};
let Some(object) = root.as_object_mut() else {
anyhow::bail!("expected a JSON object at the top level, so left untouched");
};
let permissions = object
.entry("permissions")
.or_insert_with(|| serde_json::json!({}));
let Some(permissions) = permissions.as_object_mut() else {
anyhow::bail!("`permissions` is not an object, so left untouched");
};
let allow = permissions
.entry("allow")
.or_insert_with(|| serde_json::json!([]));
let Some(allow) = allow.as_array_mut() else {
anyhow::bail!("`permissions.allow` is not an array, so left untouched");
};
let mut added = false;
for rule in claude_permission_rules() {
if !allow.iter().any(|r| r.as_str() == Some(rule.as_str())) {
allow.push(serde_json::Value::String(rule));
added = true;
}
}
if !added {
return Ok((content.to_string(), InstallOutcome::AlreadyUpToDate));
}
let outcome = if existed {
InstallOutcome::Updated
} else {
InstallOutcome::Created
};
Ok((
format!("{}\n", serde_json::to_string_pretty(&root)?),
outcome,
))
}
/// Allow Scryer in the project's `.claude/settings.json`.
pub fn install_claude_settings(
project_root: &Path,
check_only: bool,
) -> anyhow::Result<InstallOutcome> {
let path = project_root.join(".claude/settings.json");
install_with(&path, check_only, apply_claude_settings)
.map_err(|e| anyhow::anyhow!("`{}`: {e}", path.display()))
}
/// The block that makes Claude Code read AGENTS.md: it reads `CLAUDE.md` and, when one exists,
/// ignores `AGENTS.md`.
pub fn claude_md_block() -> String {
format!("{SCRYER_START_SENTINEL}\n@AGENTS.md\n{SCRYER_END_SENTINEL}")
}
/// Make an existing `CLAUDE.md` import `AGENTS.md`. `None` when there is nothing to do:
/// it already imports it, or Claude Code has no `CLAUDE.md` and so reads `AGENTS.md` itself.
pub fn apply_claude_md(content: &str) -> anyhow::Result<(String, InstallOutcome)> {
let imports_agents = content
.lines()
.any(|l| l.trim() == "@AGENTS.md" || l.trim() == "@./AGENTS.md");
let has_block = content.contains(SCRYER_START_SENTINEL);
if imports_agents && !has_block {
return Ok((content.to_string(), InstallOutcome::UserEdited));
}
apply_block(
content,
SCRYER_START_SENTINEL,
SCRYER_END_SENTINEL,
&claude_md_block(),
"CLAUDE.md",
)
}
/// Make `CLAUDE.md` import `AGENTS.md`, if the project has a `CLAUDE.md`. Returns `None` when
/// it has none: Claude Code then reads `AGENTS.md` directly.
pub fn install_claude_md(
project_root: &Path,
check_only: bool,
) -> anyhow::Result<Option<InstallOutcome>> {
let path = project_root.join("CLAUDE.md");
if !path.exists() {
return Ok(None);
}
install_with(&path, check_only, apply_claude_md)
.map(Some)
.map_err(|e| anyhow::anyhow!("`{}`: {e}", path.display()))
}
/// The Codex project config for Scryer, between TOML sentinels.
pub fn codex_config_block() -> String {
format!(
"{TOML_START_SENTINEL}\n{}{TOML_END_SENTINEL}",
codex_config_snippet()
)
}
/// Add Scryer's block to a Codex `config.toml`. A `[mcp_servers.scryer]` table the user wrote
/// outside the sentinels is theirs and is left alone.
pub fn apply_codex_config(content: &str) -> anyhow::Result<(String, InstallOutcome)> {
if !content.contains(TOML_START_SENTINEL) && content.contains("[mcp_servers.scryer]") {
return Ok((content.to_string(), InstallOutcome::UserEdited));
}
apply_block(
content,
TOML_START_SENTINEL,
TOML_END_SENTINEL,
&codex_config_block(),
"config.toml",
)
}
/// Write Scryer's entry into the project's `.codex/config.toml`.
pub fn install_codex_config(
project_root: &Path,
check_only: bool,
) -> anyhow::Result<InstallOutcome> {
let path = project_root.join(".codex/config.toml");
install_with(&path, check_only, apply_codex_config)
.map_err(|e| anyhow::anyhow!("`{}`: {e}", path.display()))
}
/// One line of an `init` report: what was done to which file.
#[derive(Debug)]
pub struct AgentStep {
pub path: PathBuf,
pub what: &'static str,
pub outcome: InstallOutcome,
}
/// Configure `agent` in `project_root`: its MCP entry, permissions and guidance import.
pub fn install_agent(
project_root: &Path,
agent: Agent,
check_only: bool,
) -> anyhow::Result<Vec<AgentStep>> {
let mut steps = Vec::new();
match agent {
Agent::Claude => {
let path = project_root.join(".mcp.json");
let outcome = install_mcp_json(&path, agent, check_only)?;
steps.push(AgentStep {
path,
what: "Claude Code MCP server",
outcome,
});
let path = project_root.join(".claude/settings.json");
let outcome = install_claude_settings(project_root, check_only)?;
steps.push(AgentStep {
path,
what: "Claude Code permissions",
outcome,
});
if let Some(outcome) = install_claude_md(project_root, check_only)? {
steps.push(AgentStep {
path: project_root.join("CLAUDE.md"),
what: "import of AGENTS.md",
outcome,
});
}
}
Agent::Codex => {
let path = project_root.join(".codex/config.toml");
let outcome = install_codex_config(project_root, check_only)?;
steps.push(AgentStep {
path,
what: "Codex MCP server",
outcome,
});
}
Agent::Cursor => {
let path = project_root.join(".cursor/mcp.json");
let outcome = install_mcp_json(&path, agent, check_only)?;
steps.push(AgentStep {
path,
what: "Cursor MCP server",
outcome,
});
}
}
Ok(steps)
}
/// Generate the Scryer agent skill (`.agents/skills/scryer/SKILL.md`).
///
/// The short playbook that AGENTS.md points at: the decision tree, the working rules and the
/// budgets in brief. It is read in full by every agent that loads the skill, so it stays small;
/// the rest is in [`generate_skill_references`]. Keep the decision tree in step with
/// `SERVER_INSTRUCTIONS` in `scryer-mcp`.
pub fn generate_agent_skill() -> String {
r#"---
name: scryer
description: >-
Code intelligence for Rust, Python and TypeScript through the Scryer MCP server or CLI:
search symbols by concept, inspect a named symbol (definition, callers, references, ADRs),
locate definitions and usages, outline files and dependency crates, trace call chains and
assess the blast radius of a change. Load this whenever you explore, navigate or search
code, read a stack trace or diff, look up a type or dependency, or prepare to edit code,
before grepping or reading whole files.
---
# Scryer Code Intelligence Playbook
Scryer indexes this repository with Tree-sitter. Answers come from parsed code with exact line spans, so prefer it to grep and to reading whole files. This is the short form; `references/` holds the rest, to read only when you need it.
## Which Tool? (Decision Tree)
- **Don't know the name** ("where is the retry/backoff logic?") → `search_symbols` (`like_symbol` finds code similar to a known symbol).
- **Know the name**, workspace or dependency (`ingest_batch`, `tokio::sync::Mutex`, an enum variant `AgentEvent::HistoryReset`, a field `persisted_len`) → `inspect_symbol`: definition, signature, docstring, source snippet, 1-hop callers/callees (or a type's public API), reference count and related ADRs in one call. Pass `symbols: [..]` (up to 5) for one section each. Ambiguous names return candidates; pass `file_path` or a qualified name.
- **Know a location** (file + line, e.g. from a stack trace or diff) → `resolve_definition` to jump to what a reference points at, `get_enclosing_scope` for the function or type containing a line (pass `lines: [..]` or `at: ["path:line", ..]`, up to 25 across files, so a list of grep hits is one call).
- **Need file or crate structure** → `get_file_outline` (declarations with line spans, no bodies; fields and variants only with `kinds: ["member"]`) / `get_crate_outline` (a dependency's modules and core types).
- **Before changing something** → `calculate_blast_radius` (what depends on it) + `query_adrs` (decisions and invariants that govern it).
Go deeper only when a summary isn't enough: `find_references` (every usage; "who calls X?" is its default `callers` view), `trace_call_hierarchy` (multi-hop callers/callees), `get_type_contract` (a type's full fields, variants and methods). `inspect_symbol`'s `see_also` names the right one, and gives the exact line range of any body it didn't show.
## Working Rules
- **No preflight.** Don't call `list_projects`, `get_indexing_status` or `index_workspace` before your first query. Tools act on the session's project, every answer says `index: fresh` (or `refreshed N files`), and the first call in a new checkout waits while the project is indexed. `INDEXING` or `STALE` means the answer is partial: retry in a few seconds.
- **Read only what you need**: after `inspect_symbol` or `get_file_outline`, read the specific line range, not the whole file.
- **Don't grep for symbols**: definitions, callers and references come from parsed code and don't match comments or string literals the way grep does. Don't guess struct fields, enum variants or signatures; check them with `inspect_symbol` or `get_type_contract`.
- **Grep is still right for** string literals, log and error messages, comments, environment variables, non-code files (Markdown, JSON, YAML, TOML, Dockerfiles), or when the index doesn't resolve a symbol.
- **Read `notes` and `see_also`**: an empty result with a `notes` entry means the name or path didn't match (try `search_symbols`), not that nothing uses it. `see_also` names the next call to make.
- **Trust `complete`**: a list marked `complete` (a `find_references` answer for Rust code) is exhaustive over the indexed files, so don't re-grep it unless a `CAVEAT` line applies. A cut list says `has_more` and `next_offset`: call again with `offset: next_offset` for the rest.
- **Known blind spots**: code behind `cfg` is indexed whether or not it compiles; calls through trait objects and closures resolve by name (`via: name`); a `x.field` access on a name several types share is not linked; build-script output is not indexed; other languages are not indexed.
- **Conventions**: lines are 1-based, columns 0-based. `file_path` is project-relative or absolute and must lie inside a registered project.
## Budgets
Answers are sized to the tool: about 1,000 tokens, 1,500 for `find_references` and `inspect_symbol`, 2,500 for `search_symbols`, 8,000 for `get_file_outline`. Lists give up whole items, never part of one. `no_truncate: true` or `max_tokens: N` lifts the cap on any tool; `format: "text"` is about half the size of JSON; `compact: true` trims JSON further.
## More
- `references/invocation.md`: calling the tools from Codex, Cursor, Claude Code, Antigravity / Gemini CLI, or the terminal, and a list of every tool.
- `references/options.md`: session flags (`--tools`, `--format`, `--no-truncate`, `--structured`), budgets and every per-tool parameter.
- `references/workflows.md`: step-by-step navigation and editing workflows.
"#
.to_string()
}
/// The files the skill points at, as `(path relative to the skill directory, content)`: how to
/// invoke the tools from each harness, every option, and the editing workflows. Loaded only when
/// needed, so they can be as long as they have to be.
pub fn generate_skill_references() -> Vec<(&'static str, String)> {
vec![
(
"references/invocation.md",
r#"# Invoking the Scryer Tools
### 1. Direct MCP tools (Codex, Cursor, Claude Code, Windsurf)
Call them by name:
- `search_symbols(query: "retry backoff")`
- `inspect_symbol(symbol: "MySymbol")`
- `get_file_outline(file_path: "src/main.rs")`
- `resolve_definition(file_path: "src/main.rs", line: 42, col: 10)`
- `calculate_blast_radius(symbol: "MySymbol")`
### 2. MCP gateway (Google Antigravity / Gemini CLI)
Tools are lazy-loaded; dispatch every call through `call_mcp_tool`:
```json
call_mcp_tool(
ServerName: "Scryer",
ToolName: "inspect_symbol",
Arguments: { "symbol": "MySymbol" }
)
```
### 3. CLI (terminal, or no MCP available)
```bash
scryer --help # all tools and flags
scryer inspect-symbol --help
# Short verbs; output is compact text (a header line, then one line per item). `--json` for JSON.
scryer callers X # who calls X, each caller once, with its line
scryer refs X # every usage site of X
scryer files X # the files that use X
scryer def X # definition, code, callers, ADRs
scryer outline FILE # declarations of a file
scryer at FILE:LINE # the function or type containing that line
scryer find retry backoff # symbols by concept words
scryer search-symbols "retry backoff" --kinds fn
scryer inspect-symbol MySymbol
scryer inspect-symbol new --file-path src/config.rs
scryer get-file-outline src/main.rs
scryer get-file-outline --file-path src/main.rs --compact
scryer resolve-definition src/main.rs 42 10
scryer get-type-contract MyType
scryer find-references MySymbol
scryer trace-call-hierarchy my_func --direction inbound
scryer calculate-blast-radius MySymbol
scryer query-adrs "storage tenancy"
scryer list-projects
```
Subcommands take kebab-case or snake_case names; put boolean flags such as `--compact` last, or write `--compact=true`.
## Tools at a Glance
- **Discover**: `search_symbols`, `get_file_outline`, `get_crate_outline`
- **Understand**: `inspect_symbol`, `get_type_contract`
- **Navigate by location**: `resolve_definition`, `get_enclosing_scope`
- **Deep dive / impact**: `find_references` (`scope_level: "dependencies"` for usages of external crate symbols; `Type::method` or `Class.method` picks one definition of a shared name), `trace_call_hierarchy` (up to depth 5), `calculate_blast_radius`
- **Decisions**: `query_adrs` (ranked by text match and `affected_paths`, explained in `match_reasons`), `record_adr`, `delete_adr`
- **Projects and index**: `list_projects`, `switch_active_project`, `index_workspace` (`scryer index`), `get_indexing_status` (`scryer status`; `project: "0"` or `"dependencies"` for the shared Cargo dependency cache; reports counts, languages and `problems`: files that failed to parse or have syntax errors, files over the size cap, and dependencies that could not be indexed. `scryer doctor` prints the same report), `delete_project` (`scryer delete`; removes index data only), `get_token_savings_metrics`
"#
.to_string(),
),
(
"references/options.md",
r#"# Scryer Options
## Session Options & Tool Profiles
- `scryer serve --project <slug|id|path>`: the project active at session start (`SCRYER_PROJECT`). `--max-tokens N`: default response budget (`SCRYER_MAX_TOKENS`).
- `scryer serve --tools core`: list only `search_symbols`, `inspect_symbol`, `find_references`, `get_file_outline`, `get_enclosing_scope` and `calculate_blast_radius` (`SCRYER_TOOLS`; default `full`). Every tool stays callable by name, and the rest are `scryer <command>`. Use it for clients that don't defer tool definitions.
- Claude Code defers MCP tools behind `ToolSearch`, and deferred tools are mostly never used. The core six carry `anthropic/alwaysLoad`, so they load up front while the others stay deferred (`--always-load none` turns that off). Older Claude Code versions ignore it: set `"alwaysLoad": true` for the server in `.mcp.json`, which loads every tool.
- `scryer serve --no-truncate` (`SCRYER_NO_TRUNCATE`) is for a program that calls Scryer and filters the answers itself: no token-budget fitting or detail shedding, and list tools return `--page-size` items per call (default 200, `SCRYER_PAGE_SIZE`; `search_symbols` caps a page at 50). Follow `next_offset` until `has_more` is false. A call's own `limit` wins.
- `scryer serve --format text` (`SCRYER_FORMAT`) makes the compact text form the default for list tools.
- `scryer serve --structured` (`SCRYER_STRUCTURED`) publishes an `outputSchema` per read tool and returns each answer's JSON as `structuredContent`, beside the text, so a program gets known keys instead of a string to parse.
## Payload Budgets & Selective Querying
Responses are sized to the tool: about 1,000 tokens, 1,500 for `find_references` and `inspect_symbol`, 2,500 for `search_symbols`, 8,000 for `get_file_outline` (a batched `get_enclosing_scope` or `inspect_symbol` scales with its size). Lists give up whole items, never part of one: a cut list says `complete: false`, `next_offset` and the exact next call. Prefer narrower queries; `no_truncate: true` or `max_tokens: N` lifts the cap on any tool (most don't list them in their schema), and `scryer serve --max-tokens N` changes the default for a session. List tools also take `format: "text"` (a header line with counts, `complete` or `TRUNCATED`, and index state, then one line per item); `scryer serve --format text` makes it the session default.
- `inspect_symbol`: `include` (e.g. `["snippet"]`), `snippet_lines`, `limit` (callers/callees shown), `file_path`, `symbols` (up to 5 names, one section each). An oversized snippet is shortened to fit, with `snippet_remaining` naming the rest.
- `get_file_outline`: `query`, `kinds` (e.g. `["fn"]`, `["struct"]`; `function`/`method`/`class` aliases work; `use` items are hidden unless `["reexport"]` is listed), `start_line`/`end_line`, `compact: true`, `offset`/`limit`. Each declaration has a one-sentence doc summary (the whole comment with `max_tokens`). Over budget it sheds doc summaries, then parameter lists, then repeated fields (listed in `shed`) before paging.
- `get_enclosing_scope`: `line`, `lines` (up to 25 in one file) or `at` (up to 25 `path:line` entries across files, as `grep -n` prints them), grouped by containing symbol, each line with its source text; a single line also returns about 40 lines of the function, its doc summary and the innermost block or closure named by its parent.
- `find_references`: `view` (`callers` distinct enclosing functions with a count, the default for a function; `files` distinct files with counts, the default for `role: import`; `sites` every usage with its source line), `file_filter`, `role` (`call`, `type_annotation`, `import`, `reexport`, `read`, `write`, `value`: a function passed or stored without being called), `via` (how the reference was tied to its target: `exact` through a path, import, `self` or the file's own scopes to the only match; `name` by name alone, e.g. a method on a receiver whose type is unknown, so it may be another symbol of the same name; `macro` found inside a macro invocation), `scope_level` (`project`, `workspace_all`, `file`, `dependencies`), `offset`/`limit` (default 40). When several used definitions share the name, the answer lists the definitions with usage counts: pass `file_path` or a qualified name (`Type::method`) to pick one. `calculate_blast_radius` and `trace_call_hierarchy` do the same.
- `search_symbols`: `kinds`, `file_filter`, `scope_level` (`project`, `dependencies`), `crate_name`, `compact: true`, `offset`/`limit` (default 15). Each hit has its signature and a doc summary; a `query` also returns the best hit's first lines, and `confident: true` means it is far ahead of the rest.
- `get_crate_outline`: `module_path`, `version`. `trace_call_hierarchy`: `direction` (`outbound` callees, `inbound` callers), `file_path` (picks one of several same-named definitions), `file_filter`, `limit`.
- `calculate_blast_radius`: `include_symbols: false` (affected files only), `limit`. `query_adrs`: `status`, `compact: true`, `offset`/`limit`.
"#
.to_string(),
),
(
"references/workflows.md",
r#"# Navigation & Editing Workflows
### 0. Understand a Named Symbol in One Call
Instead of grepping for a definition and reading the whole file:
1. Run `inspect_symbol`:
```json
call_mcp_tool(
ServerName: "Scryer",
ToolName: "inspect_symbol",
Arguments: {
"symbol": "MySymbol"
}
)
```
2. You get the definition (kind, file, line span, signature, docstring), a source snippet, 1-hop callers/callees, a reference count and related ADRs.
3. If `snippet_truncated` is true, read exactly `snippet_remaining` (`file_path`, `start_line`-`end_line`) for the rest of the body.
4. If the result is `ambiguous`, repeat with `file_path` or a qualified name from `candidates`.
### 1. Work Outside-In: Locate Functions, Types, or Methods to Edit
Avoid loading entire files to locate code.
1. Run `get_file_outline`:
```json
call_mcp_tool(
ServerName: "Scryer",
ToolName: "get_file_outline",
Arguments: {
"file_path": "path/to/file.rs",
"query": "target_symbol_or_function_name"
}
)
```
2. Note the exact `start_line` and `end_line` returned in the `OutlineSymbol`.
3. Call `view_file` targeting **only** that exact slice `[start_line, end_line]` to obtain the verbatim code required for `replace_file_content`.
If you don't know the name of the code you need, find candidates first with `search_symbols`:
```json
call_mcp_tool(
ServerName: "Scryer",
ToolName: "search_symbols",
Arguments: {
"query": "retry backoff",
"kinds": ["fn"]
}
)
```
### 2. Inspecting Data Structures & Interfaces
Before reading an entire file to understand a struct, enum, or trait:
1. Run `get_type_contract`:
```json
call_mcp_tool(
ServerName: "Scryer",
ToolName: "get_type_contract",
Arguments: {
"type_name": "MyStruct"
}
)
```
2. Inspect fields, variants, and trait methods without loading implementation bodies into context.
### 3. Finding Usages & Call Sites (Blast Radius)
Before renaming, modifying signatures, or deleting symbols:
1. Run `find_references`:
```json
call_mcp_tool(
ServerName: "Scryer",
ToolName: "find_references",
Arguments: {
"symbol": "MySymbol",
"scope_level": "project"
}
)
```
2. To see caller chains up to 5 levels deep:
Run `trace_call_hierarchy` with `direction: "inbound"`.
3. To see every indexed dependent (symbols and files), run `calculate_blast_radius`, and check governing decisions with `query_adrs`.
"#
.to_string(),
),
]
}
/// Every file of the skill: `SKILL.md`, then the references.
pub fn generate_skill_files() -> Vec<(&'static str, String)> {
let mut files = vec![("SKILL.md", generate_agent_skill())];
files.extend(generate_skill_references());
files
}
/// Start of every generated skill file; its presence marks the file as Scryer's to update.
const SKILL_FRONTMATTER_PREFIX: &str = "---\nname: scryer\n";
/// Install or update the Scryer skill in `.agents/skills/scryer/`.
pub fn install_skill(project_root: &Path, check_only: bool) -> anyhow::Result<InstallOutcome> {
let dir = project_root.join(SKILL_RELATIVE_PATH);
let dir = dir.parent().unwrap_or(project_root);
install_skill_in(dir, check_only)
}
/// Install or update the skill (`SKILL.md` and `references/`) in `dir`.
pub fn install_skill_in(dir: &Path, check_only: bool) -> anyhow::Result<InstallOutcome> {
let skill_path = dir.join("SKILL.md");
let files = generate_skill_files();
// Only replace a skill Scryer generated; never clobber one the user wrote at this path.
if skill_path.exists()
&& !fs::read_to_string(&skill_path)?.starts_with(SKILL_FRONTMATTER_PREFIX)
{
anyhow::bail!(
"`{}` exists but is not a Scryer-generated skill; leaving it untouched. Move or delete it to install the Scryer skill.",
skill_path.display()
);
}
let mut outcome = InstallOutcome::AlreadyUpToDate;
let mut stale = Vec::new();
for (relative, content) in &files {
let path = dir.join(relative);
match fs::read_to_string(&path) {
Ok(existing) if existing.trim() == content.trim() => continue,
Ok(_) if outcome == InstallOutcome::AlreadyUpToDate => {
outcome = InstallOutcome::Updated
}
Err(_) if *relative == "SKILL.md" => outcome = InstallOutcome::Created,
Err(_) if outcome == InstallOutcome::AlreadyUpToDate => {
outcome = InstallOutcome::Updated
}
_ => {}
}
stale.push((path, content));
}
if stale.is_empty() {
return Ok(outcome);
}
if check_only {
if outcome == InstallOutcome::Created {
anyhow::bail!("Skill file `{}` does not exist", skill_path.display());
}
anyhow::bail!("Skill in `{}` is outdated", dir.display());
}
for (path, content) in stale {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
write_atomic(&path, content)?;
}
Ok(outcome)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_agents_block_is_short_and_points_at_the_skill() {
let text = generate_agent_instructions();
assert!(text.starts_with(SCRYER_START_SENTINEL));
assert!(text.ends_with(SCRYER_END_SENTINEL));
// Always loaded, so it stays small; the detail belongs in the skill.
assert!(text.lines().count() <= 16, "{} lines", text.lines().count());
assert!(text.len() < 2_800, "{} bytes", text.len());
assert!(text.contains(SKILL_RELATIVE_PATH));
assert!(text.contains("load the `scryer` skill"));
// What must hold even if the skill is never loaded.
for needle in [
"search_symbols",
"inspect_symbol",
"get_enclosing_scope",
"get_file_outline",
"calculate_blast_radius",
"`tokio::sync::Mutex`",
"Grep is still right",
"`notes`",
"1-based",
"call_mcp_tool",
"scryer --help",
"scryer inspect-symbol",
] {
assert!(text.contains(needle), "AGENTS block lacks {needle}");
}
}
#[test]
fn test_codex_snippet_lists_exactly_the_core_tools() {
let snippet = codex_config_snippet();
for tool in scryer_mcp::tools::CORE_TOOLS {
assert!(snippet.contains(&format!("\"{tool}\"")), "missing {tool}");
}
assert!(!snippet.contains("trace_call_hierarchy"));
assert!(snippet.contains("tool_timeout_sec"));
}
#[test]
fn test_skill_describes_session_options_not_a_per_call_project() {
let text = all_skill_text();
for needle in [
"--project",
"--max-tokens",
"--tools core",
"alwaysLoad",
"--no-truncate",
"--page-size",
"--structured",
] {
assert!(text.contains(needle), "skill lacks {needle}");
}
assert!(!text.contains("`project` is optional"));
}
#[test]
fn test_instructions_omit_removed_tools() {
for text in [generate_agent_instructions(), all_skill_text()] {
assert!(!text.contains("get_symbol_docs"));
assert!(!text.contains("get_symbol_invariants"));
assert!(!text.contains("--direction callers"));
}
}
/// `SKILL.md` and every reference file, as one text.
fn all_skill_text() -> String {
generate_skill_files()
.into_iter()
.map(|(_, content)| content)
.collect::<Vec<_>>()
.join("\n")
}
#[test]
fn test_skill_core_is_short_and_says_what_to_do_first() {
let text = generate_agent_skill();
assert!(text.starts_with(SKILL_FRONTMATTER_PREFIX));
// Every agent that loads the skill reads all of this, so it stays short (Plan 24).
assert!(text.len() < 6_000, "SKILL.md is {} bytes", text.len());
for needle in [
"Decision Tree",
"`tokio::sync::Mutex`",
"Working Rules",
"Grep is still right",
"`see_also`",
"`next_offset`",
"No preflight",
"`list_projects`",
"references/invocation.md",
"references/options.md",
"references/workflows.md",
// Variants and fields are looked up by name.
"AgentEvent::HistoryReset",
] {
assert!(text.contains(needle), "SKILL.md lacks {needle}");
}
}
#[test]
fn test_skill_references_hold_the_rest_of_the_playbook() {
let references: Vec<_> = generate_skill_references();
let names: Vec<&str> = references.iter().map(|(name, _)| *name).collect();
assert_eq!(
names,
[
"references/invocation.md",
"references/options.md",
"references/workflows.md"
]
);
let text = all_skill_text();
for needle in [
"call_mcp_tool",
"ServerName: \"Scryer\"",
"Codex",
"scryer get-file-outline",
"scryer --help",
"scryer inspect-symbol",
"Payload Budgets",
"Navigation & Editing Workflows",
] {
assert!(text.contains(needle), "skill lacks {needle}");
}
// The detail went to the references, so the whole outweighs the always-loaded block
// and each reference outweighs the core it hangs off.
assert!(text.len() > 2 * generate_agent_instructions().len());
for (name, content) in references {
assert!(!content.trim().is_empty(), "{name} is empty");
}
}
#[test]
fn test_skill_description_names_the_situations_that_should_load_it() {
let text = generate_agent_skill();
let frontmatter = text.split("---").nth(1).unwrap();
for trigger in ["explore", "stack trace", "dependency", "prepare to edit"] {
assert!(
frontmatter.contains(trigger),
"description lacks '{trigger}'"
);
}
}
/// Every tool name in the generated text must be a real tool.
#[test]
fn test_generated_text_names_only_real_tools() {
let tools: Vec<String> = scryer_mcp::all_tool_definitions()
.iter()
.map(|t| t.name.to_string())
.collect();
for text in [generate_agent_instructions(), all_skill_text()] {
for word in text.split(|c: char| !(c.is_ascii_alphanumeric() || c == '_')) {
let looks_like_tool = word.contains('_')
&& word.chars().all(|c| c.is_ascii_lowercase() || c == '_')
&& [
"search_",
"inspect_",
"get_",
"resolve_",
"calculate_",
"trace_",
"find_",
"query_",
"list_",
"index_",
"switch_",
"delete_",
"record_",
]
.iter()
.any(|p| word.starts_with(p));
if looks_like_tool {
assert!(tools.iter().any(|t| t == word), "unknown tool '{word}'");
}
}
}
}
#[test]
fn test_apply_to_empty() {
let (content, outcome) = apply_instructions_to_content("").unwrap();
assert_eq!(outcome, InstallOutcome::Created);
assert!(content.contains(SCRYER_START_SENTINEL));
}
#[test]
fn test_apply_append_existing() {
let initial = "# My Project\n\nExisting guidelines.\n";
let (content, outcome) = apply_instructions_to_content(initial).unwrap();
assert_eq!(outcome, InstallOutcome::Appended);
assert!(content.starts_with("# My Project\n\nExisting guidelines."));
assert!(content.contains(SCRYER_START_SENTINEL));
assert!(content.contains(SCRYER_END_SENTINEL));
}
#[test]
fn test_apply_update_existing() {
let initial = format!(
"# Header\n\n{}\nOld instructions here\n{}\n\n# Footer\n",
SCRYER_START_SENTINEL, SCRYER_END_SENTINEL
);
let (content, outcome) = apply_instructions_to_content(&initial).unwrap();
assert_eq!(outcome, InstallOutcome::Updated);
assert!(content.starts_with("# Header\n\n"));
assert!(content.ends_with("\n\n# Footer\n"));
assert!(content.contains("get_file_outline"));
assert!(!content.contains("Old instructions here"));
}
#[test]
fn test_apply_idempotent() {
let initial = "# Header\n\n";
let (first, _) = apply_instructions_to_content(initial).unwrap();
let (second, outcome) = apply_instructions_to_content(&first).unwrap();
assert_eq!(outcome, InstallOutcome::AlreadyUpToDate);
assert_eq!(first, second);
}
#[test]
fn write_atomic_replaces_content_and_leaves_no_temp_file() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("AGENTS.md");
fs::write(&path, "old").unwrap();
write_atomic(&path, "new").unwrap();
assert_eq!(fs::read_to_string(&path).unwrap(), "new");
let names: Vec<_> = fs::read_dir(dir.path())
.unwrap()
.map(|e| e.unwrap().file_name())
.collect();
assert_eq!(names, vec![std::ffi::OsString::from("AGENTS.md")]);
}
#[test]
fn install_instructions_creates_then_is_idempotent() {
let dir = tempfile::tempdir().unwrap();
let target = dir.path().join("AGENTS.md");
assert_eq!(
install_instructions(&target, false).unwrap(),
InstallOutcome::Created
);
assert_eq!(
install_instructions(&target, false).unwrap(),
InstallOutcome::AlreadyUpToDate
);
// `--check` agrees, and fails when the file is stale or missing.
assert!(install_instructions(&target, true).is_ok());
fs::write(&target, "# unrelated\n").unwrap();
assert!(install_instructions(&target, true).is_err());
assert!(install_instructions(&dir.path().join("MISSING.md"), true).is_err());
}
#[test]
fn skill_check_fails_when_missing_or_outdated() {
let dir = tempfile::tempdir().unwrap();
assert!(install_skill(dir.path(), true).is_err(), "missing skill");
assert_eq!(
install_skill(dir.path(), false).unwrap(),
InstallOutcome::Created
);
assert_eq!(
install_skill(dir.path(), true).unwrap(),
InstallOutcome::AlreadyUpToDate
);
// An older generated skill is updated, but `--check` reports it as outdated.
let skill = dir.path().join(".agents/skills/scryer/SKILL.md");
fs::write(&skill, "---\nname: scryer\n---\nold body\n").unwrap();
assert!(install_skill(dir.path(), true).is_err());
assert_eq!(
install_skill(dir.path(), false).unwrap(),
InstallOutcome::Updated
);
assert!(!fs::read_to_string(&skill).unwrap().contains("old body"));
// The references are part of the skill: a missing one is outdated, and install restores it.
let reference = dir
.path()
.join(".agents/skills/scryer/references/options.md");
assert!(reference.exists());
fs::remove_file(&reference).unwrap();
assert!(install_skill(dir.path(), true).is_err());
assert_eq!(
install_skill(dir.path(), false).unwrap(),
InstallOutcome::Updated
);
assert!(reference.exists());
assert_eq!(
install_skill(dir.path(), true).unwrap(),
InstallOutcome::AlreadyUpToDate
);
}
#[test]
fn skill_install_never_overwrites_a_users_own_skill() {
let dir = tempfile::tempdir().unwrap();
let skill = dir.path().join(".agents/skills/scryer/SKILL.md");
fs::create_dir_all(skill.parent().unwrap()).unwrap();
fs::write(&skill, "# my own notes about scryer\n").unwrap();
let err = install_skill(dir.path(), false).unwrap_err();
assert!(
err.to_string().contains("not a Scryer-generated skill"),
"{err}"
);
assert_eq!(
fs::read_to_string(&skill).unwrap(),
"# my own notes about scryer\n"
);
}
// ---- per-agent setup -------------------------------------------------------------------
use serde_json::json;
fn read(path: &Path) -> String {
fs::read_to_string(path).unwrap()
}
#[test]
fn mcp_json_keeps_other_servers_and_is_idempotent() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join(".mcp.json");
fs::write(
&path,
r#"{"mcpServers": {"other": {"command": "x"}}, "extra": 1}"#,
)
.unwrap();
assert_eq!(
install_mcp_json(&path, Agent::Claude, false).unwrap(),
InstallOutcome::Appended
);
let value: serde_json::Value = serde_json::from_str(&read(&path)).unwrap();
assert_eq!(value["mcpServers"]["other"], json!({"command": "x"}));
assert_eq!(value["extra"], 1);
assert_eq!(
value["mcpServers"]["scryer"],
mcp_server_entry(Agent::Claude)
);
assert_eq!(value["mcpServers"]["scryer"]["alwaysLoad"], true);
let before = read(&path);
assert_eq!(
install_mcp_json(&path, Agent::Claude, false).unwrap(),
InstallOutcome::AlreadyUpToDate
);
assert_eq!(read(&path), before);
assert_eq!(
install_mcp_json(&path, Agent::Claude, true).unwrap(),
InstallOutcome::AlreadyUpToDate
);
}
#[test]
fn mcp_json_never_overwrites_the_users_scryer_entry() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join(".mcp.json");
let mine = r#"{"mcpServers": {"scryer": {"command": "/opt/scryer", "args": ["serve", "--tools", "full"]}}}"#;
fs::write(&path, mine).unwrap();
assert_eq!(
install_mcp_json(&path, Agent::Claude, false).unwrap(),
InstallOutcome::UserEdited
);
assert_eq!(read(&path), mine);
// A check passes: the user chose this.
assert_eq!(
install_mcp_json(&path, Agent::Claude, true).unwrap(),
InstallOutcome::UserEdited
);
}
#[test]
fn mcp_json_that_is_not_json_is_left_alone() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join(".mcp.json");
fs::write(&path, "{ not json").unwrap();
let err = install_mcp_json(&path, Agent::Claude, false).unwrap_err();
assert!(err.to_string().contains("left untouched"), "{err}");
assert_eq!(read(&path), "{ not json");
}
#[test]
fn check_fails_when_the_mcp_entry_is_missing_and_writes_nothing() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join(".mcp.json");
assert!(install_mcp_json(&path, Agent::Claude, true).is_err());
assert!(!path.exists());
}
#[test]
fn cursor_gets_the_core_profile_without_always_load() {
let entry = mcp_server_entry(Agent::Cursor);
assert_eq!(entry["args"], json!(["serve", "--tools", "core"]));
assert!(entry.get("alwaysLoad").is_none());
}
#[test]
fn claude_settings_union_the_rules_and_keep_the_users() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join(".claude/settings.json");
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(
&path,
r#"{"model": "x", "permissions": {"allow": ["Bash(git:*)"], "deny": ["Bash(rm:*)"]}}"#,
)
.unwrap();
assert_eq!(
install_claude_settings(dir.path(), false).unwrap(),
InstallOutcome::Updated
);
let value: serde_json::Value = serde_json::from_str(&read(&path)).unwrap();
assert_eq!(value["model"], "x");
assert_eq!(value["permissions"]["deny"], json!(["Bash(rm:*)"]));
let allow: Vec<&str> = value["permissions"]["allow"]
.as_array()
.unwrap()
.iter()
.filter_map(|v| v.as_str())
.collect();
assert_eq!(allow[0], "Bash(git:*)");
assert!(allow.contains(&"Bash(scryer:*)"));
for tool in scryer_mcp::tools::CORE_TOOLS {
assert!(allow.contains(&format!("mcp__scryer__{tool}").as_str()));
}
let before = read(&path);
assert_eq!(
install_claude_settings(dir.path(), false).unwrap(),
InstallOutcome::AlreadyUpToDate
);
assert_eq!(read(&path), before);
}
#[test]
fn claude_md_gets_an_import_only_when_it_exists() {
let dir = tempfile::tempdir().unwrap();
// No CLAUDE.md: Claude Code reads AGENTS.md itself, so nothing is created.
assert_eq!(install_claude_md(dir.path(), false).unwrap(), None);
assert!(!dir.path().join("CLAUDE.md").exists());
let path = dir.path().join("CLAUDE.md");
fs::write(&path, "# My project\n\nBe brief.\n").unwrap();
assert_eq!(
install_claude_md(dir.path(), false).unwrap(),
Some(InstallOutcome::Appended)
);
let text = read(&path);
assert!(text.starts_with("# My project\n\nBe brief.\n"), "{text}");
assert!(text.contains("@AGENTS.md"));
let before = read(&path);
assert_eq!(
install_claude_md(dir.path(), false).unwrap(),
Some(InstallOutcome::AlreadyUpToDate)
);
assert_eq!(read(&path), before);
}
#[test]
fn a_claude_md_that_already_imports_agents_md_is_not_touched() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("CLAUDE.md");
fs::write(&path, "See below.\n\n@AGENTS.md\n").unwrap();
assert_eq!(
install_claude_md(dir.path(), false).unwrap(),
Some(InstallOutcome::UserEdited)
);
assert_eq!(read(&path), "See below.\n\n@AGENTS.md\n");
}
#[test]
fn codex_config_is_a_sentinel_block_that_keeps_the_users_settings() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join(".codex/config.toml");
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(&path, "model = \"o\"\n").unwrap();
assert_eq!(
install_codex_config(dir.path(), false).unwrap(),
InstallOutcome::Appended
);
let text = read(&path);
assert!(text.starts_with("model = \"o\"\n"));
assert!(text.contains(TOML_START_SENTINEL) && text.contains(TOML_END_SENTINEL));
assert!(text.contains("[mcp_servers.scryer]"));
assert!(!text.contains("--watch"));
assert!(text.contains("enabled_tools"));
let before = text;
assert_eq!(
install_codex_config(dir.path(), false).unwrap(),
InstallOutcome::AlreadyUpToDate
);
assert_eq!(read(&path), before);
// Outdated block between the sentinels is replaced; the rest is kept.
let stale = before.replace("tool_timeout_sec = 60", "tool_timeout_sec = 5");
fs::write(&path, &stale).unwrap();
assert!(install_codex_config(dir.path(), true).is_err());
assert_eq!(
install_codex_config(dir.path(), false).unwrap(),
InstallOutcome::Updated
);
assert_eq!(read(&path), before);
}
#[test]
fn a_codex_server_the_user_wrote_is_theirs() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join(".codex/config.toml");
fs::create_dir_all(path.parent().unwrap()).unwrap();
let mine = "[mcp_servers.scryer]\ncommand = \"/opt/scryer\"\n";
fs::write(&path, mine).unwrap();
assert_eq!(
install_codex_config(dir.path(), false).unwrap(),
InstallOutcome::UserEdited
);
assert_eq!(read(&path), mine);
}
/// The whole setup, twice, in a repository with and without a `CLAUDE.md`.
#[test]
fn init_for_claude_is_idempotent_with_and_without_claude_md() {
for with_claude_md in [false, true] {
let dir = tempfile::tempdir().unwrap();
if with_claude_md {
fs::write(dir.path().join("CLAUDE.md"), "# Notes\n").unwrap();
}
let run = |check: bool| {
install_instructions(dir.path(), check)
.and_then(|_| install_skill(dir.path(), check))
.and_then(|_| install_agent(dir.path(), Agent::Claude, check))
};
run(false).unwrap();
run(true).unwrap();
let snapshot = |name: &str| fs::read_to_string(dir.path().join(name)).ok();
let files = [
"AGENTS.md",
"CLAUDE.md",
".mcp.json",
".claude/settings.json",
SKILL_RELATIVE_PATH,
];
let before: Vec<_> = files.iter().map(|f| snapshot(f)).collect();
let steps = install_agent(dir.path(), Agent::Claude, false).unwrap();
assert!(
steps
.iter()
.all(|s| s.outcome == InstallOutcome::AlreadyUpToDate),
"{steps:?}"
);
let after: Vec<_> = files.iter().map(|f| snapshot(f)).collect();
assert_eq!(before, after);
assert_eq!(snapshot("CLAUDE.md").is_some(), with_claude_md);
if with_claude_md {
assert!(snapshot("CLAUDE.md").unwrap().contains("@AGENTS.md"));
}
}
}
#[test]
fn agents_are_detected_from_what_the_project_already_has() {
let dir = tempfile::tempdir().unwrap();
assert!(detect_agents(dir.path()).is_empty());
fs::write(dir.path().join("CLAUDE.md"), "").unwrap();
fs::create_dir(dir.path().join(".codex")).unwrap();
assert_eq!(detect_agents(dir.path()), vec![Agent::Claude, Agent::Codex]);
fs::create_dir(dir.path().join(".cursor")).unwrap();
assert_eq!(
detect_agents(dir.path()),
vec![Agent::Claude, Agent::Codex, Agent::Cursor]
);
}
/// The Claude Code plugin in `plugin/scryer` ships copies of what `scryer init` writes. They
/// must not drift: regenerate with the commands in the messages.
#[test]
fn the_plugin_matches_what_init_writes() {
let plugin = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../plugin/scryer");
for (relative, content) in generate_skill_files() {
assert_eq!(
read(&plugin.join("skills/scryer").join(relative)),
content,
"regenerate with: scryer init --skill-dir plugin/scryer/skills/scryer"
);
}
let mcp: serde_json::Value =
serde_json::from_str(&read(&plugin.join(".mcp.json"))).unwrap();
assert_eq!(
mcp,
json!({ "mcpServers": { "scryer": mcp_server_entry(Agent::Claude) } }),
"plugin/scryer/.mcp.json differs from the entry `scryer init --agent claude` writes"
);
let manifest: serde_json::Value =
serde_json::from_str(&read(&plugin.join(".claude-plugin/plugin.json"))).unwrap();
assert_eq!(manifest["name"], "scryer");
assert_eq!(
manifest["version"],
env!("CARGO_PKG_VERSION"),
"bump plugin/scryer/.claude-plugin/plugin.json with the crate version"
);
let hooks: serde_json::Value =
serde_json::from_str(&read(&plugin.join("hooks/hooks.json"))).unwrap();
let hook = &hooks["hooks"]["PreToolUse"][0];
assert_eq!(hook["matcher"], "Grep|Bash");
assert_eq!(hook["hooks"][0]["command"], "scryer hook pre-grep");
}
}