Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,13 +92,15 @@ That's it. From here every commit is attributed to whichever agent (or human) wr

> **Note:** `agentdiff configure` installs capture scripts globally, but capture only fires in repos where `agentdiff init` has been run (the `.git/agentdiff/` directory must exist). Running `configure` on its own does not track any repo — you must also run `agentdiff init` inside each repo you want to track.

> **AGENTS.md:** `agentdiff configure` also writes (or updates) an `## AgentDiff` section in `AGENTS.md` in the current directory. This file is the emerging standard for multi-agent repo context — Codex, Cursor, Copilot, and other tools read it to understand repo conventions. The section is idempotent: re-running configure updates it without duplicating or touching the rest of your `AGENTS.md`. Use `--no-agents-md` to skip.

---

## Commands

| Command | Description |
|---------|-------------|
| `agentdiff configure` | Install global agent capture hooks — run once per machine |
| `agentdiff configure` | Install global agent capture hooks and write `AGENTS.md` context — run once per machine |
| `agentdiff init` | Initialize tracking in current repository (required per repo) |
| `agentdiff install-ci` | Write CI workflow YAMLs to `.github/workflows/` — run once per repo |
| `agentdiff list` | List attribution entries |
Expand Down
4 changes: 4 additions & 0 deletions src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,10 @@ pub struct ConfigureArgs {
/// Skip MCP server registration with Claude Code
#[arg(long)]
pub no_mcp: bool,

/// Skip writing/updating the AgentDiff section in AGENTS.md
#[arg(long)]
pub no_agents_md: bool,
}

#[derive(Args, Debug)]
Expand Down
254 changes: 254 additions & 0 deletions src/configure/agents_md.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,254 @@
use crate::util::{dim, ok};
use anyhow::Result;
use std::path::Path;

const AGENTS_MD_START: &str = "<!-- agentdiff: managed block — do not edit -->";
const AGENTS_MD_END: &str = "<!-- end agentdiff -->";

fn managed_block() -> String {
format!(
"{start}\n\
## AgentDiff\n\
\n\
[AgentDiff](https://github.com/codeprakhar25/agentdiff) tracks which AI agent \
wrote which lines of code in this repository. Every file edit made through a \
configured agent is captured and stored as a signed `AgentTrace` record in \
`.git/agentdiff/traces/`. Attribution is computed per-commit and covers all \
configured agents: Claude Code, Cursor, Codex, Copilot, Windsurf, OpenCode, \
and Gemini.\n\
\n\
### Before editing traced files\n\
\n\
Check attribution context to understand prior AI contributions:\n\
\n\
```bash\n\
agentdiff context path/to/file\n\
agentdiff context path/to/file --json\n\
```\n\
\n\
### Before committing\n\
\n\
Let the git hooks run — do **not** bypass them with `--no-verify`. The \
`pre-commit` hook computes per-file attribution, and the `post-commit` hook \
signs and stores the trace. Skipping either breaks the attribution ledger.\n\
\n\
### When reviewing PRs\n\
\n\
```bash\n\
agentdiff report --format markdown # Aggregate attribution summary\n\
agentdiff blame src/main.rs # Line-level attribution\n\
agentdiff diff HEAD # Attribution changes in a commit\n\
```\n\
\n\
### Attribution conventions\n\
\n\
- Files you edit without an AI agent are attributed to `human`\n\
- Files changed by an agent use its name: `claude-code`, `cursor`, `codex`, etc.\n\
- Copilot inline completions are tracked for stats but excluded from file attribution\n\
- When multiple agents touch a file in one session, the majority-lines agent wins\n\
{end}",
start = AGENTS_MD_START,
end = AGENTS_MD_END,
)
}

pub fn step_configure_agents_md(repo_root: &Path) -> Result<()> {
// AGENTS.md is a per-project file. Skip when not inside a git repository
// (e.g. `agentdiff configure` run from a home directory for machine-global setup).
if !repo_root.join(".git").exists() {
println!(
"{} AGENTS.md skipped (not a git repository: {})",
dim(),
repo_root.display()
);
return Ok(());
}

let agents_md_path = repo_root.join("AGENTS.md");
let block = managed_block();

let existing = std::fs::read_to_string(&agents_md_path).unwrap_or_default();

if let Some(start_pos) = existing.find(AGENTS_MD_START) {
if let Some(rel_end) = existing[start_pos..].find(AGENTS_MD_END) {
Comment thread
greptile-apps[bot] marked this conversation as resolved.
let end_pos = start_pos + rel_end + AGENTS_MD_END.len();
let current_block = &existing[start_pos..end_pos];
if current_block == block {
println!("{} AGENTS.md AgentDiff section already up-to-date", dim());
return Ok(());
}
// Update existing block in place.
let updated = format!("{}{}{}", &existing[..start_pos], block, &existing[end_pos..]);
std::fs::write(&agents_md_path, updated)?;
println!(
"{} AGENTS.md AgentDiff section updated in {}",
ok(),
agents_md_path.display()
);
return Ok(());
}
// Start sentinel present but no end sentinel — file is malformed. Warn and skip
// rather than appending a second block and corrupting the file further.
eprintln!(
"warn: AGENTS.md has an opening agentdiff sentinel but no closing sentinel; \
skipping to avoid creating a duplicate block. Fix manually: add \
`{}` after the managed section.",
AGENTS_MD_END
);
return Ok(());
}

// Append block to file (create if absent).
let separator = if existing.is_empty() || existing.ends_with('\n') {
"\n"
} else {
"\n\n"
};
let updated = format!("{}{}{}\n", existing, separator, block);
std::fs::write(&agents_md_path, updated)?;

if existing.is_empty() {
println!(
"{} AGENTS.md created with AgentDiff section at {}",
ok(),
agents_md_path.display()
);
} else {
println!(
"{} AGENTS.md AgentDiff section added to {}",
ok(),
agents_md_path.display()
);
}
Ok(())
}

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

fn tmp_dir() -> std::path::PathBuf {
let dir = std::env::temp_dir()
.join(format!("agentdiff-agents-md-{}", std::process::id()));
fs::create_dir_all(&dir).unwrap();
dir
}

/// Create a fake git repo root (just a `.git` dir) so the git-repo guard passes.
fn fake_git_repo(dir: &std::path::Path) {
fs::create_dir_all(dir.join(".git")).unwrap();
}

#[test]
fn creates_agents_md_from_scratch() {
let dir = tmp_dir().join("scratch");
fs::create_dir_all(&dir).unwrap();
fake_git_repo(&dir);

step_configure_agents_md(&dir).unwrap();

let content = fs::read_to_string(dir.join("AGENTS.md")).unwrap();
assert!(content.contains(AGENTS_MD_START));
assert!(content.contains(AGENTS_MD_END));
assert!(content.contains("## AgentDiff"));
assert!(content.contains("agentdiff context path/to/file"));

let _ = fs::remove_dir_all(&dir);
}

#[test]
fn idempotent_when_block_unchanged() {
let dir = tmp_dir().join("idempotent");
fs::create_dir_all(&dir).unwrap();
fake_git_repo(&dir);

step_configure_agents_md(&dir).unwrap();
let first = fs::read_to_string(dir.join("AGENTS.md")).unwrap();

step_configure_agents_md(&dir).unwrap();
let second = fs::read_to_string(dir.join("AGENTS.md")).unwrap();

assert_eq!(first, second);
let _ = fs::remove_dir_all(&dir);
}

#[test]
fn updates_existing_block_without_duplicating() {
let dir = tmp_dir().join("update");
fs::create_dir_all(&dir).unwrap();
fake_git_repo(&dir);

// Write a stale block.
let stale = format!(
"# My Project\n\n{start}\n## AgentDiff\n\nOld content here.\n{end}\n\n## Other section\n",
start = AGENTS_MD_START,
end = AGENTS_MD_END,
);
fs::write(dir.join("AGENTS.md"), &stale).unwrap();

step_configure_agents_md(&dir).unwrap();

let content = fs::read_to_string(dir.join("AGENTS.md")).unwrap();
// Should have exactly one managed block.
assert_eq!(content.matches(AGENTS_MD_START).count(), 1);
assert_eq!(content.matches(AGENTS_MD_END).count(), 1);
// Old content replaced.
assert!(!content.contains("Old content here."));
// Surrounding content preserved.
assert!(content.contains("# My Project"));
assert!(content.contains("## Other section"));

let _ = fs::remove_dir_all(&dir);
}

#[test]
fn appends_to_existing_agents_md_without_managed_block() {
let dir = tmp_dir().join("append");
fs::create_dir_all(&dir).unwrap();
fake_git_repo(&dir);

let pre_existing = "# My Project\n\nSome existing content.\n";
fs::write(dir.join("AGENTS.md"), pre_existing).unwrap();

step_configure_agents_md(&dir).unwrap();

let content = fs::read_to_string(dir.join("AGENTS.md")).unwrap();
assert!(content.starts_with("# My Project"));
assert!(content.contains("Some existing content."));
assert!(content.contains(AGENTS_MD_START));
assert!(content.contains("## AgentDiff"));

let _ = fs::remove_dir_all(&dir);
}

#[test]
fn skips_when_not_a_git_repo() {
let dir = tmp_dir().join("not-git");
fs::create_dir_all(&dir).unwrap();
// No .git directory — should skip without error.
step_configure_agents_md(&dir).unwrap();
assert!(!dir.join("AGENTS.md").exists());
let _ = fs::remove_dir_all(&dir);
}

#[test]
fn warns_and_skips_on_unclosed_sentinel() {
let dir = tmp_dir().join("malformed");
fs::create_dir_all(&dir).unwrap();
fake_git_repo(&dir);

// Write a file with an opening sentinel but no closing sentinel.
let malformed = format!("# Project\n\n{start}\n## AgentDiff\n\nTruncated block with no end.\n", start = AGENTS_MD_START);
fs::write(dir.join("AGENTS.md"), &malformed).unwrap();

step_configure_agents_md(&dir).unwrap();

// File must be unchanged — no duplicate block appended.
let content = fs::read_to_string(dir.join("AGENTS.md")).unwrap();
assert_eq!(content.matches(AGENTS_MD_START).count(), 1, "must not duplicate start sentinel");
assert!(!content.contains(AGENTS_MD_END), "end sentinel must not be added silently");

let _ = fs::remove_dir_all(&dir);
}
}
11 changes: 9 additions & 2 deletions src/configure/mod.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
mod agents_md;
mod antigravity;
mod claude;
mod codex;
Expand All @@ -12,7 +13,7 @@ use crate::util::{dim, ok, warn};
use anyhow::{Context, Result};
use colored::Colorize;
use dialoguer::{theme::ColorfulTheme, MultiSelect};
use std::{fs, io::IsTerminal, path::Path, process::Command};
use std::{fs, io::IsTerminal, path::Path, path::PathBuf, process::Command};

// Script sources embedded at compile time.
const CLAUDE_CAPTURE_SCRIPT: &str = include_str!("../../scripts/capture-claude.py");
Expand All @@ -28,7 +29,7 @@ const RECORD_CONTEXT_SCRIPT: &str = include_str!("../../scripts/record-context.p
const WRITE_NOTE_SCRIPT: &str = include_str!("../../scripts/write-note.py");

/// Configure global agent hooks — run once per machine, no git repo required.
pub fn run_configure(config: &mut Config, args: &ConfigureArgs) -> Result<()> {
pub fn run_configure(config: &mut Config, args: &ConfigureArgs, repo_root: &Path) -> Result<()> {
println!("{}", "agentdiff configure".bold().cyan());
println!();
println!(
Expand Down Expand Up @@ -86,6 +87,11 @@ pub fn run_configure(config: &mut Config, args: &ConfigureArgs) -> Result<()> {
copilot::step_configure_copilot(config)?;
}

// Step 10 — write/update AgentDiff section in AGENTS.md (repo root, if available)
if !args.no_agents_md {
Comment thread
greptile-apps[bot] marked this conversation as resolved.
agents_md::step_configure_agents_md(repo_root)?;
}

// Save updated config
config.save()?;
println!(
Expand Down Expand Up @@ -712,6 +718,7 @@ mod tests {
no_opencode: false,
no_copilot: false,
no_mcp: false,
no_agents_md: false,
}
}

Expand Down
2 changes: 1 addition & 1 deletion src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ fn main() -> anyhow::Result<()> {
match cli.command {
Command::Configure(args) => {
let mut cfg = config;
configure::run_configure(&mut cfg, &args)
configure::run_configure(&mut cfg, &args, &repo_root)
}
Command::Init(args) => {
let mut cfg = config;
Expand Down
Loading