use anyhow::{bail, Context, Result};
use pushkin_core::legacy;
use serde_json::{json, Value};
use crate::agents::Agent;
use super::{CLAUDE_SETTINGS, CONSENT_FILE, CONSENT_VERSION, PUSHKIN_MARKER};
const FOOTPRINT: &str = "pushkin init will touch exactly these files:\n \
<agent config> — add gate hook entries (marker: pushkin-v1)\n \
.pushkin/consent.json — record this consent\n\
All data stays local; nothing leaves this machine.";
pub fn run_with_args(agent: Option<&str>, remove_agent: Option<&str>) -> Result<i32> {
if let Some(name) = remove_agent {
return remove(name);
}
migrate_legacy_footprint()?;
ensure_consent()?;
match agent {
None | Some("claude") => install_claude()?,
Some("codex") => install_codex()?,
Some("auggie") => install_auggie()?,
Some("hermes") => install_hermes()?,
Some("opencode") => install_opencode()?,
Some("lefthook") => {
super::features::ensure_git_hooks_enabled()?;
install_lefthook()?;
}
Some("git") => {
super::features::ensure_git_hooks_enabled()?;
install_git_shim()?;
}
Some("agents-md") => install_agents_md()?,
Some(unknown) => {
match Agent::parse(unknown) {
Ok(_) => unreachable!("all agents handled above"),
Err(error) => bail!(
"{error} (or 'lefthook' for the pre-commit floor, \
'agents-md' for the managed instructions block)"
),
}
}
}
Ok(0)
}
pub fn migrate_legacy_footprint() -> Result<()> {
let renamed = legacy::migrate_dirs(std::path::Path::new("."))
.context("legacy pre-rename state found but not migratable")?;
for dir in renamed {
println!(
"pushkin: migrated legacy dir {} -> {} (one-time rename; contents preserved).",
dir.old.display(),
dir.new.display()
);
}
Ok(())
}
fn ensure_consent() -> Result<()> {
if let Ok(text) = std::fs::read_to_string(CONSENT_FILE) {
if let Ok(existing) = serde_json::from_str::<Value>(&text) {
if existing.get("version").and_then(Value::as_u64) == Some(u64::from(CONSENT_VERSION)) {
println!("pushkin: consent already recorded; refreshing install.");
return Ok(());
}
}
}
println!("{FOOTPRINT}");
if let Some(parent) = std::path::Path::new(CONSENT_FILE).parent() {
std::fs::create_dir_all(parent).context("cannot create .pushkin/")?;
}
let ack = json!({ "version": CONSENT_VERSION });
std::fs::write(CONSENT_FILE, serde_json::to_string_pretty(&ack)?)
.context("cannot write consent file")?;
Ok(())
}
fn hook_command(agent: &str) -> String {
format!("{BINARY} hook {agent}")
}
const BINARY: &str = "pushkin";
pub fn install_claude() -> Result<()> {
let command = hook_command("claude");
let mut settings = read_json_or_empty(CLAUDE_SETTINGS)?;
let hooks = ensure_object_entry(&mut settings, "hooks")?;
set_marked_entry(
hooks,
"PreToolUse",
json!({
"_pushkin": PUSHKIN_MARKER,
"matcher": "Write|Edit|MultiEdit|Read|NotebookRead|Bash",
"hooks": [{ "type": "command", "command": command }]
}),
)?;
set_marked_entry(
hooks,
"Stop",
json!({
"_pushkin": PUSHKIN_MARKER,
"hooks": [{ "type": "command", "command": command }]
}),
)?;
set_marked_entry(
hooks,
"SessionStart",
json!({
"_pushkin": PUSHKIN_MARKER,
"hooks": [{ "type": "command", "command": command }]
}),
)?;
write_json(CLAUDE_SETTINGS, &settings)?;
println!("pushkin: claude hooks installed ({CLAUDE_SETTINGS}).");
Ok(())
}
fn install_codex() -> Result<()> {
let command = hook_command("codex");
let path = ".codex/hooks.json";
let mut settings = read_json_or_empty(path)?;
let hooks = ensure_object_entry(&mut settings, "hooks")?;
set_marked_entry(
hooks,
"PreToolUse",
json!({
"_pushkin": PUSHKIN_MARKER,
"matcher": "apply_patch|write_file",
"hooks": [{ "type": "command", "command": command }]
}),
)?;
write_json(path, &settings)?;
let rules = "prefix_rule([\"rm\", \"pushkin.toml\"], decision=\"forbidden\")\n\
prefix_rule([\"mv\", \".claude/settings.json\"], decision=\"forbidden\")\n\
prefix_rule([\"echo\"], decision=\"allow\")\n";
std::fs::create_dir_all(".codex/rules").context("cannot create .codex/rules")?;
std::fs::write(".codex/rules/pushkin.rules", rules).context("cannot write execpolicy floor")?;
println!("pushkin: codex hooks + execpolicy floor installed (.codex/).");
println!(
"NOTE (F56): codex requires a persisted HOOK TRUST grant before it will run\n\
this hook. Until it is granted the hook DOES NOT FIRE and this repo is\n\
UNGATED for codex. pushkin cannot verify this grant from here. Establish it by\n\
running codex once interactively and approving, or pass\n\
--dangerously-bypass-hook-trust for automation that vets its hook sources.\n\
Project trust (`trust_level` in ~/.codex/config.toml) is a DIFFERENT setting\n\
and does not cover this. The execpolicy floor and the pre-commit floor are\n\
unaffected."
);
Ok(())
}
fn install_auggie() -> Result<()> {
std::fs::create_dir_all(".augment/hooks").context("cannot create .augment/hooks")?;
let script_path = ".augment/hooks/pushkin.sh";
let script = format!(
"#!/bin/sh\n# GENERATED by pushkin init — do not hand-edit. marker: {PUSHKIN_MARKER}\n{}",
fail_open_guard(&hook_command("auggie"))
);
std::fs::write(script_path, script).context("cannot write hook script")?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(script_path, std::fs::Permissions::from_mode(0o755))
.context("cannot mark hook script executable")?;
}
let script_absolute =
std::fs::canonicalize(script_path).context("cannot resolve hook script path")?;
let path = ".augment/settings.json";
let mut settings = read_json_or_empty(path)?;
let hooks = ensure_object_entry(&mut settings, "hooks")?;
set_marked_entry(
hooks,
"PreToolUse",
json!({
"_pushkin": PUSHKIN_MARKER,
"matcher": "save-file|str-replace-editor",
"hooks": [{ "type": "command", "command": script_absolute.to_string_lossy() }]
}),
)?;
write_json(path, &settings)?;
println!("pushkin: auggie hooks installed (.augment/settings.json + hooks/pushkin.sh).");
Ok(())
}
fn install_hermes() -> Result<()> {
let hermes_home = std::env::var("HERMES_HOME")
.unwrap_or_else(|_| format!("{}/.hermes", std::env::var("HOME").unwrap_or_default()));
let plugin_dir = format!("{hermes_home}/plugins/pushkin-gate");
std::fs::create_dir_all(&plugin_dir).with_context(|| format!("cannot create {plugin_dir}"))?;
let manifest = "# GENERATED by pushkin init — do not hand-edit. marker: pushkin-v1\n\
name: pushkin-gate\n\
version: 0.1.0\n\
description: Pushkin pre-write gate (self-scoping; active only in repos with pushkin.toml)\n\
entry: __init__.py\n";
std::fs::write(format!("{plugin_dir}/plugin.yaml"), manifest)
.context("cannot write plugin.yaml")?;
let plugin = format!(
"# GENERATED by pushkin init — do not hand-edit. marker: pushkin-v1\n\
# Self-scoping Pushkin gate for Hermes (pre_tool_call).\n\
import json\n\
import os\n\
import subprocess\n\n\
PUSHKIN_BIN = {binary_quoted}\n\
WRITE_TOOLS = {{\"write_file\", \"patch\"}}\n\n\n\
def pushkin_gate(tool_name, args, task_id, **kwargs):\n\
\x20\x20\x20\x20if tool_name not in WRITE_TOOLS:\n\
\x20\x20\x20\x20\x20\x20\x20\x20return None\n\
\x20\x20\x20\x20if not os.path.exists(\"pushkin.toml\"):\n\
\x20\x20\x20\x20\x20\x20\x20\x20return None # self-scoping: only gated repos\n\
\x20\x20\x20\x20payload = json.dumps({{\n\
\x20\x20\x20\x20\x20\x20\x20\x20\"session_id\": task_id or \"hermes-session\",\n\
\x20\x20\x20\x20\x20\x20\x20\x20\"tool_name\": tool_name,\n\
\x20\x20\x20\x20\x20\x20\x20\x20\"tool_input\": args,\n\
\x20\x20\x20\x20}})\n\
\x20\x20\x20\x20try:\n\
\x20\x20\x20\x20\x20\x20\x20\x20result = subprocess.run(\n\
\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20[PUSHKIN_BIN, \"hook\", \"hermes\"],\n\
\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20input=payload, capture_output=True, text=True, timeout=20,\n\
\x20\x20\x20\x20\x20\x20\x20\x20)\n\
\x20\x20\x20\x20except (OSError, subprocess.TimeoutExpired) as error:\n\
\x20\x20\x20\x20\x20\x20\x20\x20# Advisory tier is fail-open BY DESIGN, but never silently.\n\
\x20\x20\x20\x20\x20\x20\x20\x20print(f\"pushkin plugin: gate invocation failed: {{error}}\")\n\
\x20\x20\x20\x20\x20\x20\x20\x20return None\n\
\x20\x20\x20\x20try:\n\
\x20\x20\x20\x20\x20\x20\x20\x20verdict = json.loads(result.stdout or \"{{}}\")\n\
\x20\x20\x20\x20except json.JSONDecodeError:\n\
\x20\x20\x20\x20\x20\x20\x20\x20return None\n\
\x20\x20\x20\x20if verdict.get(\"action\") == \"block\":\n\
\x20\x20\x20\x20\x20\x20\x20\x20return verdict\n\
\x20\x20\x20\x20return None\n\n\n\
def register(ctx):\n\
\x20\x20\x20\x20ctx.register_hook(\"pre_tool_call\", pushkin_gate)\n",
binary_quoted = serde_json::json!(BINARY),
);
std::fs::write(format!("{plugin_dir}/__init__.py"), plugin)
.context("cannot write hermes plugin")?;
let _ = std::fs::remove_dir_all(format!(
"{hermes_home}/plugins/{}",
legacy::HERMES_DRAFT_DIR
));
let _ = std::fs::remove_dir_all(format!(
"{hermes_home}/plugins/{}",
legacy::HERMES_PLUGIN_DIR
));
println!(
"pushkin: hermes pre_tool_call plugin installed ({plugin_dir}).\n\
Enable it once with `hermes plugins enable pushkin-gate` if not auto-enabled.\n\
NOTE: the Hermes tier is advisory and fail-open — hooks are best-effort\n\
there; pair with the lefthook pre-commit floor for hard enforcement."
);
Ok(())
}
fn install_opencode() -> Result<()> {
std::fs::create_dir_all(".opencode/plugin").context("cannot create .opencode/plugin")?;
let plugin = format!(
"// GENERATED by pushkin init — do not hand-edit. marker: {PUSHKIN_MARKER}\n\
// Thin relay: invokes `{command}` on every\n\
// tool.execute.before and throws on a deny verdict (throw = block).\n\
import {{ spawnSync }} from \"node:child_process\";\n\n\
export const PushkinPlugin = async (ctx: {{ directory?: string }}) => {{\n\
const repoDir = ctx?.directory ?? process.cwd();\n\
return {{\n\
\"tool.execute.before\": async (input: unknown, output: unknown) => {{\n\
const payload = JSON.stringify({{ ...(input as object), args: (output as {{ args?: unknown }}).args }});\n\
const result = spawnSync({binary:?}, [\"hook\", \"opencode\"], {{ input: payload, encoding: \"utf8\", cwd: repoDir }});\n\
if (result.error || result.status !== 0) {{\n\
// Broken relay must be loud, not a silent allow (charter conduct 4.4).\n\
console.error(`pushkin plugin: gate invocation failed: ${{result.error ?? result.stderr}}`);\n\
return;\n\
}}\n\
const verdict = JSON.parse(result.stdout || \"{{}}\");\n\
if (verdict.decision === \"deny\") throw new Error(verdict.reason);\n\
}},\n\
}};\n\
}};\n",
binary = BINARY,
command = hook_command("opencode"),
);
std::fs::write(".opencode/plugin/pushkin.ts", plugin).context("cannot write plugin")?;
let mut config = read_json_or_empty("opencode.json")?;
let permissions = ensure_object_entry(&mut config, "permission")?;
permissions
.as_object_mut()
.context("permission must be an object")?
.insert("task".to_owned(), json!("ask"));
write_json("opencode.json", &config)?;
println!("pushkin: opencode plugin + permission block installed (.opencode/, opencode.json).");
Ok(())
}
const AGENTS_MD: &str = "AGENTS.md";
const BLOCK_BEGIN: &str =
"<!-- pushkin:begin pushkin-v1 — GENERATED from pushkin.toml; do not hand-edit -->";
const BLOCK_END: &str = "<!-- pushkin:end pushkin-v1 -->";
fn install_agents_md() -> Result<()> {
let manifest = super::load_manifest()?;
let digest = super::instructions::render_digest(&manifest)?;
let block = format!("{BLOCK_BEGIN}\n{digest}{BLOCK_END}\n");
let existing = match std::fs::read_to_string(AGENTS_MD) {
Ok(text) => text,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => String::new(),
Err(error) => return Err(error).context("cannot read AGENTS.md"),
};
let updated = splice_managed_block(&existing, &block);
std::fs::write(AGENTS_MD, updated).context("cannot write AGENTS.md")?;
println!("pushkin: AGENTS.md managed block installed (between pushkin markers).");
Ok(())
}
fn splice_managed_block(existing: &str, block: &str) -> String {
if let Some((prefix, tail)) = split_around_block(existing) {
return format!("{prefix}{block}{tail}");
}
if existing.is_empty() {
return block.to_owned();
}
let separator = if existing.ends_with("\n\n") {
""
} else if existing.ends_with('\n') {
"\n"
} else {
"\n\n"
};
format!("{existing}{separator}{block}")
}
fn strip_managed_block(existing: &str) -> String {
let Some((prefix, tail)) = split_around_block(existing) else {
return existing.to_owned();
};
let mut head = prefix.to_owned();
while head.ends_with("\n\n") {
head.pop();
}
format!("{head}{tail}")
}
fn split_around_block(existing: &str) -> Option<(&str, &str)> {
let begin = existing.find(BLOCK_BEGIN)?;
let end_marker = existing.find(BLOCK_END)?;
let end = end_marker + BLOCK_END.len();
let tail = existing.get(end..)?;
let tail = tail.strip_prefix('\n').unwrap_or(tail);
Some((&existing[..begin], tail))
}
pub const LEFTHOOK_FILE: &str = "lefthook.yml";
pub const LEFTHOOK_COMMAND: &str = "pushkin check --staged --json";
const LEFTHOOK_INSTALL_HINT: &str = "install with: cargo install pushkin";
fn lefthook_guard() -> String {
fail_open_guard(LEFTHOOK_COMMAND)
}
fn fail_open_guard(command: &str) -> String {
format!(
"if [ -n \"$(command -v pushkin)\" ] && [ -f pushkin.toml ]; then\n \
exec {command}\n\
else\n \
echo 'pushkin: not installed or no pushkin.toml; failing open \
({LEFTHOOK_INSTALL_HINT})' >&2\n \
exit 0\n\
fi\n"
)
}
const LEFTHOOK_BEGIN: &str =
" # pushkin:begin pushkin-v3 — GENERATED by pushkin init; do not hand-edit";
const LEFTHOOK_END: &str = " # pushkin:end pushkin-v3";
fn lefthook_block() -> String {
let indented = lefthook_guard()
.lines()
.fold(String::new(), |mut acc, line| {
use std::fmt::Write as _;
let _ = writeln!(acc, " {line}");
acc
});
format!("{LEFTHOOK_BEGIN}\n pushkin:\n run: |\n{indented}{LEFTHOOK_END}\n")
}
pub const LEFTHOOK_MARKER: &str = "pushkin-v3";
fn lefthook_full_file() -> String {
format!(
"# GENERATED by pushkin init — do not hand-edit. marker: {LEFTHOOK_MARKER}\n\
pre-commit:\n commands:\n{}",
lefthook_block()
)
}
pub fn install_lefthook() -> Result<()> {
let existing = match std::fs::read_to_string(LEFTHOOK_FILE) {
Ok(text) => Some(text),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
Err(error) => return Err(error).context("cannot read lefthook.yml"),
};
let updated = match existing {
None => lefthook_full_file(),
Some(text) => splice_lefthook_block(&text),
};
std::fs::write(LEFTHOOK_FILE, updated).context("cannot write lefthook.yml")?;
println!(
"pushkin: lefthook pre-commit floor installed ({LEFTHOOK_FILE}); \
the hook runs `{LEFTHOOK_COMMAND}` when pushkin is on PATH, and \
fails open with a notice when it is not."
);
Ok(())
}
fn splice_lefthook_block(existing: &str) -> String {
let block = lefthook_block();
if let Some((prefix, tail)) = split_around_lefthook_block(existing) {
let prefix = without_pushkin_comment_lines(prefix, true);
let tail = without_pushkin_comment_lines(tail, true);
return format!("{prefix}{block}{tail}");
}
if let Some(stale) = strip_stale_pushkin_command(existing) {
return splice_lefthook_block(&stale);
}
if let Some((insertion, entry_indent)) = commands_insertion_point(existing) {
let (head, tail) = existing.split_at(insertion);
let block = reindent_block(&block, entry_indent);
return format!("{head}{block}{tail}");
}
let separator = if existing.ends_with('\n') { "" } else { "\n" };
format!("{existing}{separator}pre-commit:\n commands:\n{block}")
}
struct ScalarTracker {
scalar_indent: Option<usize>,
}
impl ScalarTracker {
fn new() -> Self {
Self {
scalar_indent: None,
}
}
fn content_line(&mut self, line: &str) -> bool {
let trimmed = line.trim();
if let Some(indent) = self.scalar_indent {
if trimmed.is_empty() || line_indent(line) > indent {
return true;
}
self.scalar_indent = None;
}
if introduces_block_scalar(trimmed) {
self.scalar_indent = Some(line_indent(line));
}
false
}
}
fn line_indent(line: &str) -> usize {
line.len() - line.trim_start().len()
}
fn introduces_block_scalar(trimmed: &str) -> bool {
if trimmed.starts_with('#') {
return false;
}
let Some((_, value)) = trimmed.split_once(':') else {
return false;
};
matches!(value.trim(), "|" | "|-" | "|+" | ">" | ">-" | ">+")
}
fn split_around_lefthook_block(existing: &str) -> Option<(&str, &str)> {
let mut begin: Option<usize> = None;
let mut offset = 0usize;
let mut scalars = ScalarTracker::new();
for line in existing.split_inclusive('\n') {
if scalars.content_line(line) {
offset += line.len();
continue;
}
let trimmed = line.trim();
match begin {
None if trimmed.starts_with("# pushkin:begin pushkin-v") => begin = Some(offset),
Some(at) if trimmed.starts_with("# pushkin:end pushkin-v") => {
let tail = existing.get(offset + line.len()..).unwrap_or("");
return Some((&existing[..at], tail));
}
_ => {}
}
offset += line.len();
}
None
}
pub(crate) fn without_pushkin_comment_lines(text: &str, keep_current: bool) -> String {
let mut kept = String::new();
let mut scalars = ScalarTracker::new();
for line in text.split_inclusive('\n') {
let in_scalar = scalars.content_line(line);
let trimmed = line.trim();
let ours = !in_scalar
&& (trimmed.starts_with("# pushkin:begin pushkin-v")
|| trimmed.starts_with("# pushkin:end pushkin-v")
|| trimmed.starts_with("# GENERATED by pushkin init"));
if ours && !(keep_current && trimmed.contains(LEFTHOOK_MARKER)) {
continue;
}
kept.push_str(line);
}
kept
}
pub(crate) fn has_stale_pushkin_comments(text: &str) -> bool {
without_pushkin_comment_lines(text, true) != text
}
fn strip_stale_pushkin_command(existing: &str) -> Option<String> {
if !existing.contains("pushkin") {
return None;
}
let mut kept: Vec<&str> = Vec::new();
let mut dropped = false;
let mut skipping = false;
for line in existing.lines() {
let trimmed = line.trim();
if trimmed == "pushkin:" {
skipping = true;
dropped = true;
continue;
}
if skipping {
let indent = line.len() - line.trim_start().len();
if trimmed.is_empty() || indent > 4 {
continue;
}
skipping = false;
}
if trimmed.starts_with("# GENERATED by pushkin init") {
dropped = true;
continue;
}
kept.push(line);
}
if !dropped {
return None;
}
let mut text = kept.join("\n");
if !text.ends_with('\n') {
text.push('\n');
}
Some(text)
}
fn commands_insertion_point(existing: &str) -> Option<(usize, usize)> {
let mut offset = 0usize;
let mut in_precommit = false;
let mut found: Option<(usize, usize)> = None;
for line in existing.split_inclusive('\n') {
if let Some((at, commands_indent)) = found {
let trimmed = line.trim();
if !trimmed.is_empty() && !trimmed.starts_with('#') {
let indent = line_indent(line);
let entry = if indent > commands_indent {
indent
} else {
commands_indent + 2
};
return Some((at, entry));
}
offset += line.len();
continue;
}
let trimmed = line.trim_end();
if trimmed.starts_with("pre-commit:") {
in_precommit = true;
} else if in_precommit && trimmed.trim() == "commands:" {
found = Some((offset + line.len(), line_indent(line)));
} else if !line.starts_with(' ')
&& !trimmed.is_empty()
&& !trimmed.starts_with("pre-commit:")
{
in_precommit = false;
}
offset += line.len();
}
found.map(|(at, commands_indent)| (at, commands_indent + 2))
}
fn reindent_block(block: &str, entry_indent: usize) -> String {
const CANONICAL_ENTRY_INDENT: usize = 4;
if entry_indent == CANONICAL_ENTRY_INDENT {
return block.to_owned();
}
let mut out = String::new();
for line in block.split_inclusive('\n') {
let body = line.trim_start_matches(' ');
let base = line.len() - body.len();
let shifted = (base + entry_indent).saturating_sub(CANONICAL_ENTRY_INDENT);
out.push_str(&" ".repeat(shifted));
out.push_str(body);
}
out
}
pub(crate) fn remove_lefthook() -> Result<()> {
let Ok(existing) = std::fs::read_to_string(LEFTHOOK_FILE) else {
println!("pushkin: lefthook floor not present; nothing to remove.");
return Ok(());
};
let stripped = match split_around_lefthook_block(&existing) {
Some((prefix, tail)) => without_pushkin_comment_lines(&format!("{prefix}{tail}"), false),
None => strip_stale_pushkin_command(&existing).unwrap_or(existing),
};
if lefthook_is_only_scaffolding(&stripped) {
std::fs::remove_file(LEFTHOOK_FILE).context("cannot remove lefthook.yml")?;
println!("pushkin: lefthook floor removed (file was pushkin's alone).");
return Ok(());
}
std::fs::write(LEFTHOOK_FILE, stripped).context("cannot write lefthook.yml")?;
println!("pushkin: lefthook floor removed; other commands preserved.");
Ok(())
}
fn lefthook_is_only_scaffolding(text: &str) -> bool {
text.lines().all(|line| {
let trimmed = line.trim();
trimmed.is_empty()
|| trimmed == "pre-commit:"
|| trimmed == "commands:"
|| trimmed.starts_with("# GENERATED by pushkin init")
|| trimmed.starts_with("# pushkin:begin pushkin-v")
|| trimmed.starts_with("# pushkin:end pushkin-v")
})
}
fn git_shim_file() -> String {
format!(
"#!/bin/sh\n# GENERATED by pushkin init — do not hand-edit. marker: {PUSHKIN_MARKER}\n{}",
lefthook_guard()
)
}
pub(crate) fn is_pushkin_shim(text: &str) -> bool {
text.contains("GENERATED by pushkin init") && text.contains(PUSHKIN_MARKER)
}
pub fn install_git_shim() -> Result<()> {
let Some(git_dir) = super::git::git_dir() else {
anyhow::bail!(
"not a git repository — the native shim installs into .git/hooks; run \
inside a repo (or use `--agent lefthook` for a config-file floor)"
);
};
if let Some(hooks_path) = super::git::hooks_path_override() {
println!(
"pushkin: hooks are owned by a hook manager (core.hooksPath = \
{hooks_path}); nothing was written. Add this to that manager's \
pre-commit stage to gate commits:\n pushkin check --staged --json"
);
return Ok(());
}
let hooks_dir = git_dir.join("hooks");
let target = hooks_dir.join("pre-commit");
if let Ok(existing) = std::fs::read_to_string(&target) {
if !is_pushkin_shim(&existing) {
let manager = if existing.contains("lefthook") {
" (this looks like lefthook — `pushkin init --agent lefthook` manages \
that floor)"
} else if existing.contains("husky") {
" (this looks like husky)"
} else {
""
};
println!(
"pushkin: an existing pre-commit hook is not pushkin's and was left \
untouched{manager}. Add this to it to gate commits:\n \
pushkin check --staged --json"
);
return Ok(());
}
}
std::fs::create_dir_all(&hooks_dir).context("cannot create the hooks directory")?;
std::fs::write(&target, git_shim_file()).context("cannot write the pre-commit shim")?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o755))
.context("cannot mark the shim executable")?;
}
println!(
"pushkin: native git pre-commit shim installed ({}); the hook runs \
`{LEFTHOOK_COMMAND}` when pushkin is on PATH, and fails open with a \
notice when it is not.",
target.display()
);
Ok(())
}
pub(crate) fn remove_git_shim() -> Result<()> {
let Some(git_dir) = super::git::git_dir() else {
println!("pushkin: not a git repository; no shim to remove.");
return Ok(());
};
let target = git_dir.join("hooks").join("pre-commit");
match std::fs::read_to_string(&target) {
Ok(existing) if is_pushkin_shim(&existing) => {
std::fs::remove_file(&target).context("cannot remove the pre-commit shim")?;
println!("pushkin: native git shim removed.");
}
Ok(_) => println!("pushkin: the pre-commit hook is not pushkin's; left in place."),
Err(_) => println!("pushkin: native git shim not present; nothing to remove."),
}
Ok(())
}
fn remove(name: &str) -> Result<i32> {
match name {
"claude" => {
let mut settings = read_json_or_empty(CLAUDE_SETTINGS)?;
strip_marked_entries(&mut settings);
write_json(CLAUDE_SETTINGS, &settings)?;
println!("pushkin: claude entries removed; user hooks preserved.");
Ok(0)
}
"codex" => {
let _ = std::fs::remove_file(".codex/rules/pushkin.rules");
let _ = std::fs::remove_file(legacy::CODEX_RULES);
let mut settings = read_json_or_empty(".codex/hooks.json")?;
strip_marked_entries(&mut settings);
write_json(".codex/hooks.json", &settings)?;
println!("pushkin: codex entries removed.");
Ok(0)
}
"auggie" => {
let _ = std::fs::remove_file(".augment/hooks/pushkin.sh");
let _ = std::fs::remove_file(legacy::AUGGIE_SCRIPT);
let mut settings = read_json_or_empty(".augment/settings.json")?;
strip_marked_entries(&mut settings);
write_json(".augment/settings.json", &settings)?;
println!("pushkin: auggie entries removed.");
Ok(0)
}
"hermes" => {
let _ = std::fs::remove_file(".hermes/hooks/pushkin.json");
let _ = std::fs::remove_file(legacy::HERMES_HOOK);
println!("pushkin: hermes hook removed.");
Ok(0)
}
"opencode" => {
let _ = std::fs::remove_file(".opencode/plugin/pushkin.ts");
let _ = std::fs::remove_file(legacy::OPENCODE_PLUGIN);
println!(
"pushkin: opencode plugin removed (permission block left; it is user policy)."
);
Ok(0)
}
"lefthook" => {
remove_lefthook()?;
Ok(0)
}
"git" => {
remove_git_shim()?;
Ok(0)
}
"agents-md" => {
match std::fs::read_to_string(AGENTS_MD) {
Err(_) => println!("pushkin: AGENTS.md not present; nothing to remove."),
Ok(existing) => {
std::fs::write(AGENTS_MD, strip_managed_block(&existing))
.context("cannot write AGENTS.md")?;
println!("pushkin: AGENTS.md managed block removed; user content preserved.");
}
}
Ok(0)
}
unknown => match Agent::parse(unknown) {
Ok(_) => unreachable!("all agents handled above"),
Err(error) => bail!("{error}"),
},
}
}
fn strip_marked_entries(settings: &mut Value) {
if let Some(hooks) = settings.get_mut("hooks").and_then(Value::as_object_mut) {
for (_, entries) in hooks.iter_mut() {
if let Some(list) = entries.as_array_mut() {
list.retain(|entry| {
entry.get("_pushkin").is_none() && entry.get(legacy::MARKER_KEY).is_none()
});
}
}
}
}
pub fn read_json_or_empty(path: &str) -> Result<Value> {
match std::fs::read_to_string(path) {
Err(_) => Ok(json!({})),
Ok(text) => serde_json::from_str(&text)
.with_context(|| format!("{path} exists but is not valid JSON")),
}
}
pub fn write_json(path: &str, value: &Value) -> Result<()> {
if let Some(parent) = std::path::Path::new(path).parent() {
if !parent.as_os_str().is_empty() {
std::fs::create_dir_all(parent)
.with_context(|| format!("cannot create {}", parent.display()))?;
}
}
let mut rendered = serde_json::to_string_pretty(value)?;
rendered.push('\n');
std::fs::write(path, rendered).with_context(|| format!("cannot write {path}"))
}
pub fn ensure_object_entry<'a>(root: &'a mut Value, key: &str) -> Result<&'a mut Value> {
Ok(root
.as_object_mut()
.context("config root must be a JSON object")?
.entry(key)
.or_insert_with(|| json!({})))
}
pub fn set_marked_entry(hooks: &mut Value, event: &str, entry: Value) -> Result<()> {
let list = hooks
.as_object_mut()
.context("hooks must be a JSON object")?
.entry(event)
.or_insert_with(|| json!([]));
let entries = list
.as_array_mut()
.context("hook event must hold an array")?;
entries.retain(|candidate| {
candidate.get("_pushkin").is_none() && candidate.get(legacy::MARKER_KEY).is_none()
});
entries.push(entry);
Ok(())
}