scryer-cli 0.1.0

CLI tool and shared daemon runner for Scryer code intelligence
use std::fs;
use std::path::{Path, PathBuf};

pub const SCRYER_START_SENTINEL: &str = "<!-- scryer:start -->";
pub const SCRYER_END_SENTINEL: &str = "<!-- scryer:end -->";

/// Generate the canonical Scryer agent instructions wrapped in sentinels.
pub fn generate_agent_instructions() -> String {
    format!(
        r#"{SCRYER_START_SENTINEL}
# Code Intelligence (Scryer MCP & CLI)

This repository is indexed by **Scryer**, a Tree-Sitter and Tree-sitter Stack Graphs code intelligence server and CLI.
Scryer provides deterministic AST retrieval, call graph analysis, and cross-file definition resolution across Rust, Python, and TypeScript.

## Navigation Guidelines & Exploration Best Practices

- **Work Outside-In with AST Outlines**: When exploring unfamiliar code, run `get_file_outline` first to inspect structural declarations and exact line bounds without loading implementation bodies.
- **Targeted Line Reading**: Once you identify the target symbol or function from an outline or definition jump, read only that specific slice or function body to understand or edit logic.
- **Symbol & Navigation Priority**: Do NOT use `grep` or `ripgrep` as the primary way to locate symbol definitions, callers, or references in code—use `resolve_definition`, `find_references`, and `get_file_outline` first.
- **Avoid Loading Full Source Files for Scouting**: Do NOT dump entire large source files into context to search for symbols. Reserve whole-file reading for configs, documentation, tests, or when you explicitly need the full file context.
- **Verify Contracts**: Do NOT guess complex struct fields, enum variants, or method signatures when writing or refactoring code; inspect them with `get_type_contract`.
- **Grep for Non-Structural Searches**: Use `grep` or text search for string literals, error logs, user-facing messages, comments, environment variables, non-code files (Markdown, JSON, YAML, TOML, Dockerfiles), or as a fallback when AST indexing does not resolve a symbol.

## Invocation Modes (Choose the mode supported by your environment)

### 1. Direct MCP Tools (Codex, Cursor, Claude Code, Windsurf)
If Scryer MCP tools are available directly in your agent environment, invoke them by name:
- `get_file_outline(file_path: "src/main.rs")`
- `resolve_definition(file_path: "src/main.rs", line: 42, column: 10)`
- `find_references(symbol: "MySymbol")`
- `get_type_contract(type_name: "MyType")`

### 2. MCP Gateway Tools (Google Antigravity / Gemini CLI)
In Antigravity, Scryer tools are lazily loaded. Dispatch all tool calls through the `call_mcp_tool` gateway:
```json
call_mcp_tool(
  ServerName: "Scryer",
  ToolName: "get_file_outline",
  Arguments: {{ "file_path": "src/main.rs" }}
)
```

### 3. CLI Subcommands & Shell Fallback (Terminal / Non-MCP Environments)
If your agent operates via shell commands or lacks direct MCP tooling, run Scryer tools directly via the `scryer` CLI binary:
```bash
# Discover all tools and flags
scryer --help
scryer get-file-outline --help

# Direct tool invocations (kebab-case or snake_case)
scryer get-file-outline src/main.rs
scryer get-file-outline --file-path src/main.rs --compact
scryer get-type-contract MyType
scryer resolve-definition src/main.rs 42 10
scryer find-references MySymbol
scryer trace-call-hierarchy my_func --direction callers
scryer list-projects
```

## Tool Selection Priority
1. `get_file_outline` (`scryer get-file-outline`) — Inspect functions, structs, traits, enums, and signatures in a file without reading implementation bodies.
2. `get_type_contract` (`scryer get-type-contract`) — View fields, enum variants, and trait implementations for a specific type.
3. `get_enclosing_scope` (`scryer get-enclosing-scope`) — Find the smallest enclosing function, impl, or type at a given file and line.
4. `resolve_definition` (`scryer resolve-definition`) — Jump directly to a symbol's definition across files using precise line/column coordinates.
5. `find_references` (`scryer find-references`) — Find all usages of a symbol in the active project; set `scope_level: "dependencies"` to track workspace usages of external symbols, or `"workspace_all"` across all projects.
6. `get_symbol_docs` (`scryer get-symbol-docs`) — Inspect public documentation, signatures, and code examples for external crate or workspace symbols.
7. `get_crate_outline` (`scryer get-crate-outline`) — Orient yourself in an external crate's public API structure.
8. `search_dependency_symbols` (`scryer search-dependency-symbols`) — Find unknown or partially-named types, traits, or functions across dependencies.
9. `trace_call_hierarchy` (`scryer trace-call-hierarchy`) — Trace inbound callers or outbound callees of a function or method (up to depth 5).
10. `calculate_blast_radius` (`scryer calculate-blast-radius`) — Assess downstream impact, dependents, and affected files before refactoring or deleting symbols.
11. `query_adrs` / `get_symbol_invariants` (`scryer query-adrs`) — Search Architectural Decision Records and design invariants.
12. `record_adr` / `delete_adr` — Record or remove Architectural Decision Records in `docs/adr/`.

## Project & Index Management
- `list_projects` (`scryer list-projects`) / `switch_active_project` — List registered projects and change the session's active project.
- `index_workspace` (`scryer index`) / `get_indexing_status` (`scryer status`) — Trigger an incremental or full re-index and check scanner progress. Pass `project: "0"` or `"dependencies"` to inspect the global Cargo dependency cache.
- `delete_project` (`scryer delete`) — Unregister a project and delete its index data (source files are not touched).

## Payload Budgets & Selective Querying
- Responses enforce a default 1,000-token budget to protect context windows.
- To receive full output without truncation, pass `no_truncate: true` or specify a custom `max_tokens: N`.
- Prefer narrower queries and pagination over large dumps:
  - `get_indexing_status`: `project` (e.g. `"0"` or `"dependencies"` for the external Cargo cache).
  - `get_file_outline`: `query`, `kinds` (e.g. `["struct"]`), `start_line`/`end_line`, `compact: true`, `offset`/`limit`.
  - `find_references`: `file_filter`, `role`, `scope_level`, `offset`/`limit`.
  - `get_crate_outline`: `module_path`, `version`.
  - `search_dependency_symbols`: `crate_name`, `kind`, `offset`/`limit`.
  - `trace_call_hierarchy`: `file_filter`, `limit`.
  - `calculate_blast_radius`: `include_symbols: false` (to inspect affected files only), `limit`.
  - `query_adrs` / `get_symbol_invariants`: `status`, `compact: true`, `offset`/`limit`.
{SCRYER_END_SENTINEL}"#
    )
}

/// 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,
}

/// 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))
            }
        }
    }
}

/// 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());
        fs::write(&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 {
        fs::write(&file_path, new_content)?;
    }

    Ok(outcome)
}

/// Generate the canonical Scryer agent skill for Antigravity.
pub fn generate_agent_skill() -> String {
    r#"---
name: scryer
description: >-
  Code intelligence and navigation using the Scryer MCP server.
  MANDATORY: Execute FIRST whenever exploring, navigating, locating symbols,
  finding references, or preparing to edit code in Rust, Python, or TypeScript.
---

# Scryer Code Intelligence Navigation Playbook

Always prioritize Scryer code intelligence tools before reading raw source code or performing refactors.

## Invocation in Antigravity
In Antigravity, MCP tools are lazy-loaded. Invoke all Scryer tools via `call_mcp_tool`:
```json
call_mcp_tool(
  ServerName: "Scryer",
  ToolName: "...",
  Arguments: { ... }
)
```
If MCP tools are unavailable, use the `scryer` CLI binary directly (`scryer --help`, `scryer get-file-outline <file>`).

## Navigation & Editing Workflows

### 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`.

### 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: "callers"`.
"#
    .to_string()
}

/// Install or update the Scryer skill in `.agents/skills/scryer/SKILL.md`.
pub fn install_skill(project_root: &Path, check_only: bool) -> anyhow::Result<InstallOutcome> {
    let skill_path = project_root.join(".agents/skills/scryer/SKILL.md");
    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)?;
        }
        fs::write(&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 check_only {
        anyhow::bail!("Skill in `{}` is outdated", skill_path.display());
    }

    fs::write(&skill_path, content)?;
    Ok(InstallOutcome::Updated)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_instructions_contain_sentinels() {
        let text = generate_agent_instructions();
        assert!(text.starts_with(SCRYER_START_SENTINEL));
        assert!(text.ends_with(SCRYER_END_SENTINEL));
        assert!(text.contains("get_file_outline"));
        assert!(text.contains("calculate_blast_radius"));
        assert!(text.contains("call_mcp_tool"));
        assert!(text.contains("Codex"));
        assert!(text.contains("scryer get-file-outline"));
        assert!(text.contains("scryer --help"));
    }

    #[test]
    fn test_skill_contains_gateway_and_cli() {
        let text = generate_agent_skill();
        assert!(text.contains("call_mcp_tool"));
        assert!(text.contains("scryer get-file-outline"));
        assert!(text.contains("ServerName: \"Scryer\""));
    }

    #[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);
    }
}