use crate::{
Result,
common::{extension, parts},
harness::{Context, Harness, Part, PartResult, Reads, Scope},
hook::{Answer, Event, HookInput, Output, ToolCall, ToolKind, claude_names},
integration::{Integration, Item},
};
#[derive(Debug, Clone, Copy, Default)]
pub struct Pi;
const ID: &str = "pi";
const TEMPLATE: &str = include_str!("extension.ts");
fn base(scope: Scope) -> &'static str {
match scope {
Scope::User => ".pi/agent",
_ => ".pi",
}
}
fn instructions_file(cx: &Context) -> String {
let dir = match cx.scope {
Scope::User => ".pi/agent/",
_ => "",
};
["AGENTS.override.md", "AGENTS.md", "CLAUDE.md"]
.iter()
.map(|n| format!("{dir}{n}"))
.find(|p| cx.is_file(p))
.unwrap_or_else(|| format!("{dir}AGENTS.md"))
}
fn extension(tool: &str, integration: &Integration) -> String {
extension::fill(TEMPLATE, ID, tool, integration)
}
fn tool_kind(name: &str) -> ToolKind {
match name {
"bash" | "powershell" => ToolKind::Shell,
"read" | "grep" | "find" | "ls" => ToolKind::Read,
"edit" | "write" => ToolKind::Write,
n if n.starts_with("mcp") => ToolKind::Mcp,
_ => ToolKind::Other,
}
}
fn location(item: Item, cx: &Context) -> Option<String> {
let base = base(cx.scope);
Some(match item {
Item::Instructions => instructions_file(cx),
Item::Skills => ".agents/skills".into(),
Item::Hooks => format!("{base}/extensions"),
Item::Mcp => format!("{base}/mcp.json"),
Item::Commands => format!("{base}/prompts"),
_ => return None,
})
}
fn claude_name(name: &str) -> Option<&'static str> {
match name {
"bash" | "powershell" => Some("Bash"),
"read" => Some("Read"),
"grep" => Some("Grep"),
"find" => Some("Glob"),
"ls" => Some("LS"),
"edit" => Some("Edit"),
"write" => Some("Write"),
_ => None,
}
}
impl Harness for Pi {
fn id(&self) -> &str {
ID
}
fn program(&self) -> Option<&str> {
Some("pi")
}
fn claude_tool(&self, call: &ToolCall) -> ToolCall {
claude_names::named(call, claude_name(&call.name))
}
fn scopes(&self) -> &[Scope] {
&[Scope::Project, Scope::User]
}
fn render(&self, integration: &Integration, cx: &Context) -> Result<Vec<Part>> {
let mut out = Vec::new();
for item in integration.items() {
let Some(at) = location(item, cx) else {
continue;
};
out.push(match item {
Item::Instructions => {
parts::instructions(&at, integration.instructions_block().unwrap_or_default())
}
Item::Skills => parts::skills(&at, integration),
Item::Hooks => Part::files(
item.as_str(),
at,
vec![(format!("{}.ts", cx.tool), extension(&cx.tool, integration))],
),
Item::Mcp => parts::mcp(&at, "mcpServers", integration, |s| s.to_json()),
Item::Commands => parts::commands(&at, ".md", integration, |c| c.to_markdown()),
_ => continue,
});
}
Ok(out)
}
fn reads(&self, item: Item, cx: &Context) -> Result<Reads> {
Ok(Reads::always(location(item, cx)))
}
fn parse_hook(&self, event: Event, text: &str) -> serde_json::Result<HookInput> {
extension::parse(ID, event, text, tool_kind)
}
fn answer(&self, event: Event, answer: &Answer) -> Result<Output> {
extension::answer(ID, event, answer)
}
fn notes(&self, cx: &Context, parts: &[PartResult]) -> Vec<String> {
if !parts.iter().any(PartResult::changed) {
return Vec::new();
}
let mut out = Vec::new();
if cx.scope == Scope::Project && parts.iter().any(PartResult::wrote) {
out.push("Pi loads the project's .pi files only once you trust the project".into());
}
out.push("run /reload in Pi to load the changes".into());
out
}
}
#[cfg(test)]
mod tests {
use std::{fs, time::Duration};
use super::*;
use crate::{
harness::{Action, Profile, State},
integration::{Agent, Command, Hook, McpServer, Skill},
test_support::temp_dir,
};
fn full() -> Integration {
Integration::new()
.instructions("Use t.\n")
.skill(Skill::new("t", "Use t.", "# T\n"))
.hook(Hook::new(Event::Stop, "t hook {harness} {event}"))
.hook(
Hook::new(Event::PreTool, "t hook {harness} {event}")
.timeout(Duration::from_secs(5)),
)
.mcp_server(McpServer::stdio("t", "t", ["mcp"]))
.allow_command("t")
.agent(Agent::new("r", "Reviews.", "Review.\n"))
.command(Command::new("c", "Checks.", "Check $ARGUMENTS.\n"))
}
#[test]
fn renders_every_item_per_scope() {
let names = |scope| -> Vec<String> {
let cx = Context::new("t", scope, "/nowhere", None);
Pi.render(&full(), &cx)
.unwrap()
.iter()
.map(|p| format!("{} {}", p.name(), p.location()))
.collect()
};
assert_eq!(
names(Scope::Project),
[
"instructions AGENTS.md",
"skills .agents/skills",
"hooks .pi/extensions",
"mcp .pi/mcp.json",
"commands .pi/prompts"
]
);
assert_eq!(
names(Scope::User),
[
"instructions .pi/agent/AGENTS.md",
"skills .agents/skills",
"hooks .pi/agent/extensions",
"mcp .pi/agent/mcp.json",
"commands .pi/agent/prompts"
]
);
assert_eq!(Pi.scopes(), [Scope::Project, Scope::User]);
let cx = Context::new("t", Scope::Project, "/nowhere", None);
let p = Profile::new(Pi.render(&full(), &cx).unwrap());
assert!(p.part("hooks").unwrap().same_content(&Part::files(
"hooks",
".pi/extensions",
vec![("t.ts".into(), extension("t", &full()))]
)));
}
#[test]
fn the_extension_carries_the_hooks() {
let ts = extension("my\ntool", &full());
assert!(
ts.starts_with("// Written by my tool harness install"),
"{ts}"
);
assert!(ts.contains("const TOOL: string = \"my\\ntool\";"), "{ts}");
assert!(
ts.contains(r#"const HOOKS: Spec[] = [{"event":"pre-tool","command":"t hook pi pre-tool","timeout":5000},{"event":"stop","command":"t hook pi stop"}];"#)
|| ts.contains(r#"const HOOKS: Spec[] = [{"event":"stop","command":"t hook pi stop"},{"event":"pre-tool","command":"t hook pi pre-tool","timeout":5000}];"#),
"{ts}"
);
assert!(!ts.contains("__"), "every placeholder filled: {ts}");
}
#[test]
fn the_first_instructions_file_wins() {
let dir = temp_dir("pi-rule");
let cx = Context::new("t", Scope::Project, &dir, None);
assert_eq!(instructions_file(&cx), "AGENTS.md");
fs::write(dir.join("CLAUDE.md"), "c").unwrap();
assert_eq!(instructions_file(&cx), "CLAUDE.md");
fs::write(dir.join("AGENTS.md"), "a").unwrap();
assert_eq!(instructions_file(&cx), "AGENTS.md");
fs::write(dir.join("AGENTS.override.md"), "o").unwrap();
assert_eq!(instructions_file(&cx), "AGENTS.override.md");
assert_eq!(
Pi.reads(Item::Instructions, &cx).unwrap(),
Reads::always(["AGENTS.override.md"])
);
fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn input_and_answers() {
let i = Pi
.parse_hook(
Event::PreTool,
r#"{"event":"pre-tool","session_id":"s","transcript_path":"/t","cwd":"/w","tool_name":"bash","tool_input":{"command":"ls"},"continuing":false}"#,
)
.unwrap();
assert_eq!(i.cwd.as_deref(), Some("/w"));
assert_eq!(
(i.session_id.as_deref(), i.transcript_path.as_deref()),
(Some("s"), Some("/t"))
);
assert_eq!(i.tool.unwrap().kind, ToolKind::Shell);
assert!(
Pi.parse_hook(Event::Stop, r#"{"continuing":true}"#)
.unwrap()
.continuing
);
for (n, k) in [
("grep", ToolKind::Read),
("edit", ToolKind::Write),
("mcp_x", ToolKind::Mcp),
("x", ToolKind::Other),
] {
assert_eq!(tool_kind(n), k);
}
let out = |e, a: Answer| Pi.answer(e, &a).map(|o| o.stdout);
assert_eq!(
out(Event::Stop, Answer::Allow { stderr: None }).unwrap(),
r#"{"answer":"allow"}"#
);
assert_eq!(
out(Event::PreTool, Answer::Deny { reason: "r".into() }).unwrap(),
r#"{"answer":"deny","reason":"r"}"#
);
assert_eq!(
out(Event::Stop, Answer::Continue { reason: "r".into() }).unwrap(),
r#"{"answer":"continue","reason":"r"}"#
);
assert_eq!(
out(Event::PostTool, Answer::Context { text: "c".into() }).unwrap(),
r#"{"answer":"context","text":"c"}"#
);
assert_eq!(
out(Event::SessionStart, Answer::Context { text: "c".into() }).unwrap(),
r#"{"answer":"context","text":"c"}"#
);
assert!(out(Event::Stop, Answer::Context { text: "c".into() }).is_err());
assert!(out(Event::PromptSubmit, Answer::Deny { reason: "r".into() }).is_err());
}
#[test]
fn notes_after_writes() {
let part = |action| PartResult {
part: "hooks".into(),
state: State::Absent,
action,
path: String::new(),
by: None,
in_place: false,
};
let cx = Context::new("t", Scope::Project, "/nowhere", None);
assert!(Pi.notes(&cx, &[part(None)]).is_empty());
assert_eq!(Pi.notes(&cx, &[part(Some(Action::Created))]).len(), 2);
assert_eq!(
Pi.notes(&cx, &[part(Some(Action::Removed))]),
["run /reload in Pi to load the changes"]
);
let user = Context::new("t", Scope::User, "/nowhere", None);
assert_eq!(
Pi.notes(&user, &[part(Some(Action::Created))]),
["run /reload in Pi to load the changes"]
);
}
}