use std::fs;
use std::path::{Path, PathBuf};
pub const SCRYER_START_SENTINEL: &str = "<!-- scryer:start -->";
pub const SCRYER_END_SENTINEL: &str = "<!-- scryer:end -->";
pub const SKILL_RELATIVE_PATH: &str = ".agents/skills/scryer/SKILL.md";
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.
- An empty result with a `notes` entry means the name or path didn't match, not that nothing uses it. Lines are 1-based, columns 0-based.
- 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}"#
)
}
pub fn resolve_agents_file(target: &Path) -> PathBuf {
if target.is_dir() {
target.join("AGENTS.md")
} else {
target.to_path_buf()
}
}
#[derive(Debug, PartialEq, Eq)]
pub enum InstallOutcome {
Created,
Appended,
Updated,
AlreadyUpToDate,
}
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))
}
}
}
}
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(())
}
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)
}
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 and stack graphs. Answers come from parsed code with exact line spans, so prefer it to grep and to reading whole files.
## 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`) → `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. 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.
- **Need file or crate structure** → `get_file_outline` (declarations with line spans, no bodies) / `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), `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
- **Read only what you need**: after `inspect_symbol` or `get_file_outline`, read the specific line range you need rather than the whole file.
- **Don't grep for symbols**: definitions, callers and references come from parsed code, so they don't match comments or string literals the way grep does.
- **Verify contracts**: 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.
- **Conventions**: lines are 1-based, columns 0-based. `file_path` is project-relative or absolute and must lie inside a registered project. `project` is optional; if you pass it, it must be a registered project's slug, ID or root path, and anything else is an error (`list_projects` shows them).
- **Stale results**: if `inspect_symbol` notes "file changed since index", or results reference lines that moved, run `index_workspace`.
## Invoking the 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
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), `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), `delete_project` (`scryer delete`; removes index data only), `get_token_savings_metrics`
## Payload Budgets & Selective Querying
Responses are capped at about 1,000 tokens. Prefer narrower queries and pagination; `no_truncate: true` or `max_tokens: N` lifts the cap.
- `inspect_symbol`: `include` (e.g. `["snippet"]`), `snippet_lines`, `limit` (callers/callees shown), `file_path`. 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), `start_line`/`end_line`, `compact: true`, `offset`/`limit`.
- `find_references`: `file_filter`, `role` (`call`, `type_annotation`, `import`, `reexport`, `read`, `write`), `scope_level` (`project`, `workspace_all`, `file`, `dependencies`), `offset`/`limit`.
- `search_symbols`: `kinds`, `file_filter`, `scope_level` (`project`, `dependencies`), `crate_name`, `compact: true`, `offset`/`limit`.
- `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`.
## 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()
}
const SKILL_FRONTMATTER_PREFIX: &str = "---\nname: scryer\n";
pub fn install_skill(project_root: &Path, check_only: bool) -> anyhow::Result<InstallOutcome> {
let skill_path = project_root.join(SKILL_RELATIVE_PATH);
let content = generate_agent_skill();
if !skill_path.exists() {
if check_only {
anyhow::bail!("Skill file `{}` does not exist", skill_path.display());
}
if let Some(parent) = skill_path.parent() {
fs::create_dir_all(parent)?;
}
write_atomic(&skill_path, &content)?;
return Ok(InstallOutcome::Created);
}
let existing = fs::read_to_string(&skill_path)?;
if existing.trim() == content.trim() {
return Ok(InstallOutcome::AlreadyUpToDate);
}
if !existing.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()
);
}
if check_only {
anyhow::bail!("Skill in `{}` is outdated", skill_path.display());
}
write_atomic(&skill_path, &content)?;
Ok(InstallOutcome::Updated)
}
#[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));
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"));
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_instructions_omit_removed_tools() {
for text in [generate_agent_instructions(), generate_agent_skill()] {
assert!(!text.contains("get_symbol_docs"));
assert!(!text.contains("get_symbol_invariants"));
assert!(!text.contains("--direction callers"));
}
}
#[test]
fn test_skill_holds_the_full_playbook() {
let text = generate_agent_skill();
assert!(text.starts_with(SKILL_FRONTMATTER_PREFIX));
for needle in [
"Decision Tree",
"`tokio::sync::Mutex`",
"call_mcp_tool",
"ServerName: \"Scryer\"",
"Codex",
"scryer get-file-outline",
"scryer --help",
"scryer inspect-symbol",
"Payload Budgets",
"Working Rules",
"Grep is still right",
"`see_also`",
"Navigation & Editing Workflows",
] {
assert!(text.contains(needle), "skill lacks {needle}");
}
assert!(text.len() > 2 * generate_agent_instructions().len());
}
#[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}'"
);
}
}
#[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(), generate_agent_skill()] {
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
);
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
);
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"));
}
#[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"
);
}
}