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") => install_lefthook()?,
Some("git") => 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",
"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/).");
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
}
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(())
}
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(())
}