use std::{
collections::BTreeSet,
env, fs,
io::{self, Read},
path::{Path, PathBuf},
};
use anyhow::{Context, Result, anyhow};
use objects::fs_atomic::write_file_atomic;
use repo::Repository;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use verbs::integration_plan::{
HarnessSelectionPlan, IntegrationHarnessError, IntegrationHarnessScopeError,
IntegrationScopeError, IntegrationScopeKind, PathModeKind, classify_opencode_plugin_path_mode,
claude_settings_has_relay, codex_config_has_relay, doctor_status_line, drifted_status_token,
empty_integrations_message, healthy_status_token, installed_message,
integration_capabilities as plan_integration_capabilities, is_timeline_capability_path,
list_status_line, missing_status_token, normalize_harness_names, parse_scope,
path_mode_from_absolute_flag, plan_harness_selection, relative_heddle_invocation,
uninstalled_message, upgraded_message, validate_harness_scope as plan_validate_harness_scope,
validate_install_plan as plan_validate_install_plan,
};
use super::{
advice::RecoveryAdvice,
next_action::{NextActionValidationContext, write_full_command_json},
};
use crate::{
cli::{
Cli, IntegrationCommands, IntegrationInstallArgs, IntegrationRelayArgs,
IntegrationStampArgs, IntegrationTargetArgs, should_output_json,
},
harness,
};
const MANIFEST_FILE: &str = "integrations.toml";
pub(crate) use heddle_cli_contract::cli::commands::wire::bridge::IntegrationStatusOutput as IntegrationStatus;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "kebab-case")]
enum IntegrationScope {
Repo,
User,
}
impl IntegrationScope {
fn parse(value: &str) -> Result<Self> {
parse_scope(value)
.map(IntegrationScope::from)
.map_err(|err| match err {
IntegrationScopeError::Invalid { value } => anyhow!(RecoveryAdvice::invalid_usage(
"integration_scope_invalid",
format!("invalid integration scope: {value}"),
"Use `--scope repo` or `--scope user`.",
"heddle integration install --scope repo",
)),
})
}
fn as_kind(&self) -> IntegrationScopeKind {
match self {
Self::Repo => IntegrationScopeKind::Repo,
Self::User => IntegrationScopeKind::User,
}
}
}
impl From<IntegrationScopeKind> for IntegrationScope {
fn from(kind: IntegrationScopeKind) -> Self {
match kind {
IntegrationScopeKind::Repo => Self::Repo,
IntegrationScopeKind::User => Self::User,
}
}
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "kebab-case")]
enum PathMode {
#[default]
Relative,
Absolute,
}
impl PathMode {
fn as_kind(self) -> PathModeKind {
match self {
Self::Relative => PathModeKind::Relative,
Self::Absolute => PathModeKind::Absolute,
}
}
}
impl From<PathModeKind> for PathMode {
fn from(kind: PathModeKind) -> Self {
match kind {
PathModeKind::Relative => Self::Relative,
PathModeKind::Absolute => Self::Absolute,
}
}
}
struct HeddleInvocation(String);
impl HeddleInvocation {
fn resolve(mode: PathMode) -> Result<Self> {
Ok(match mode {
PathMode::Relative => HeddleInvocation(relative_heddle_invocation().to_string()),
PathMode::Absolute => {
let exe = std::env::current_exe()
.context("resolving current executable for integration install")?;
HeddleInvocation(shell_escape(&exe))
}
})
}
fn raw(mode: PathMode) -> Result<String> {
Ok(match mode {
PathMode::Relative => relative_heddle_invocation().to_string(),
PathMode::Absolute => std::env::current_exe()
.context("resolving current executable for integration install")?
.display()
.to_string(),
})
}
}
impl std::fmt::Display for HeddleInvocation {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct InstalledIntegration {
harness: String,
scope: IntegrationScope,
method: String,
paths: Vec<String>,
status: String,
heddle_version: String,
#[serde(default)]
path_mode: PathMode,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
struct IntegrationManifest {
#[serde(default)]
integrations: Vec<InstalledIntegration>,
}
pub fn cmd_integration(cli: &Cli, command: IntegrationCommands) -> Result<()> {
let repo = cli.open_repo()?;
match command {
IntegrationCommands::List => list_integrations(cli, &repo),
IntegrationCommands::Install(args) => install_integrations(cli, &repo, args),
IntegrationCommands::Doctor => doctor_integrations(cli, &repo),
IntegrationCommands::Uninstall(args) => uninstall_integrations(cli, &repo, args),
IntegrationCommands::Upgrade(args) => upgrade_integrations(cli, &repo, args),
IntegrationCommands::Relay(args) => relay_integration(&repo, args),
IntegrationCommands::Stamp(args) => stamp_integration(&repo, args),
}
}
pub fn maybe_prompt_init_install(
cli: &Cli,
repo: &Repository,
args: &crate::cli::InitArgs,
) -> Result<()> {
let json = should_output_json(cli, Some(repo.config()));
let harnesses = prompt_init_install_decision(cli, repo.root(), args, json)?;
perform_init_install(cli, repo, args, &harnesses)
}
pub fn prompt_init_install_decision(
_cli: &Cli,
root: &Path,
args: &crate::cli::InitArgs,
_json: bool,
) -> Result<Vec<String>> {
let harnesses = if args.no_harness_install {
Vec::new()
} else {
match &args.install_harnesses {
Some(selection) => resolve_selection_for_root(root, selection)?,
None => Vec::new(),
}
};
if !harnesses.is_empty() {
validate_install_plan(&harnesses, &args.harness_install_scope)?;
}
Ok(harnesses)
}
pub fn perform_init_install(
cli: &Cli,
repo: &Repository,
args: &crate::cli::InitArgs,
harnesses: &[String],
) -> Result<()> {
if harnesses.is_empty() {
return Ok(());
}
install_selected(
cli,
repo,
harnesses,
IntegrationScope::parse(&args.harness_install_scope)?,
args.harness_install_force,
PathMode::Relative,
)
}
fn list_integrations(cli: &Cli, repo: &Repository) -> Result<()> {
let manifest = load_manifest(repo)?;
let statuses = manifest
.integrations
.into_iter()
.map(|entry| integration_status(repo, &entry))
.collect::<Result<Vec<_>>>()?;
if should_output_json(cli, Some(repo.config())) {
write_full_command_json(
&statuses,
NextActionValidationContext::without_repo(&["integration", "list"]),
)?;
} else if statuses.is_empty() {
println!("{}", empty_integrations_message());
} else {
for status in statuses {
println!(
"{}",
list_status_line(
&status.harness,
&status.scope,
&status.status,
&status.method
)
);
if !status.capabilities.is_empty() {
println!(" capabilities: {}", status.capabilities.join(", "));
}
for path in status.paths {
println!(" {}", path);
}
}
}
Ok(())
}
fn install_integrations(cli: &Cli, repo: &Repository, args: IntegrationInstallArgs) -> Result<()> {
let harnesses = if args.harnesses.is_empty() {
detect_harnesses(repo)?
} else {
normalize_harnesses(args.harnesses)?
};
let path_mode = PathMode::from(path_mode_from_absolute_flag(args.absolute_path));
install_selected(
cli,
repo,
&harnesses,
IntegrationScope::parse(&args.scope)?,
args.force,
path_mode,
)
}
fn install_selected(
cli: &Cli,
repo: &Repository,
harnesses: &[String],
scope: IntegrationScope,
force: bool,
path_mode: PathMode,
) -> Result<()> {
let mut manifest = load_manifest(repo)?;
for harness in harnesses {
match harness.as_str() {
"codex" => install_codex(repo, &mut manifest, &scope, force, path_mode)?,
"claude-code" => install_claude(repo, &mut manifest, &scope, force, path_mode)?,
"opencode" => install_opencode(repo, &mut manifest, &scope, force, path_mode)?,
other => return Err(anyhow!(unsupported_harness_advice(other))),
}
}
save_manifest(repo, &manifest)?;
if !should_output_json(cli, Some(repo.config())) {
println!("{}", installed_message(harnesses));
}
Ok(())
}
fn doctor_integrations(cli: &Cli, repo: &Repository) -> Result<()> {
let manifest = load_manifest(repo)?;
let statuses = manifest
.integrations
.iter()
.map(|entry| integration_status(repo, entry))
.collect::<Result<Vec<_>>>()?;
if should_output_json(cli, Some(repo.config())) {
write_full_command_json(
&statuses,
NextActionValidationContext::without_repo(&["integration", "list"]),
)?;
} else if statuses.is_empty() {
println!("{}", empty_integrations_message());
} else {
for status in statuses {
println!(
"{}",
doctor_status_line(
&status.harness,
&status.scope,
&status.path_mode,
status.healthy,
&status.status,
)
);
}
}
Ok(())
}
fn uninstall_integrations(cli: &Cli, repo: &Repository, args: IntegrationTargetArgs) -> Result<()> {
let mut manifest = load_manifest(repo)?;
let targets = target_harnesses(&manifest, args.harnesses)?;
for harness in &targets {
uninstall_one(repo, &mut manifest, harness)?;
}
save_manifest(repo, &manifest)?;
if !should_output_json(cli, Some(repo.config())) {
println!("{}", uninstalled_message(&targets));
}
Ok(())
}
fn upgrade_integrations(cli: &Cli, repo: &Repository, args: IntegrationTargetArgs) -> Result<()> {
let mut manifest = load_manifest(repo)?;
let targets = target_harnesses(&manifest, args.harnesses)?;
for harness in &targets {
let existing = manifest
.integrations
.iter()
.find(|entry| &entry.harness == harness)
.cloned();
let scope = existing
.as_ref()
.map(|entry| entry.scope.clone())
.unwrap_or(IntegrationScope::Repo);
let path_mode = match existing.as_ref() {
Some(entry) => detect_path_mode(harness.as_str(), entry).unwrap_or(entry.path_mode),
None => PathMode::default(),
};
match harness.as_str() {
"codex" => install_codex(repo, &mut manifest, &scope, true, path_mode)?,
"claude-code" => install_claude(repo, &mut manifest, &scope, true, path_mode)?,
"opencode" => install_opencode(repo, &mut manifest, &scope, true, path_mode)?,
other => return Err(anyhow!(unsupported_harness_advice(other))),
}
}
save_manifest(repo, &manifest)?;
if !should_output_json(cli, Some(repo.config())) {
println!("{}", upgraded_message(&targets));
}
Ok(())
}
fn detect_path_mode(harness: &str, entry: &InstalledIntegration) -> Option<PathMode> {
let path = PathBuf::from(entry.paths.first()?);
let contents = fs::read_to_string(&path).ok()?;
match harness {
"claude-code" => {
let root: Value = serde_json::from_str(&contents).ok()?;
let cmd = root
.get("hooks")
.and_then(Value::as_object)?
.values()
.find_map(|groups| {
groups.as_array()?.iter().find_map(|group| {
group
.get("hooks")?
.as_array()?
.iter()
.find_map(|h| h.get("command")?.as_str().map(str::to_string))
})
})
.or_else(|| {
root.get("statusLine")?
.get("command")?
.as_str()
.map(str::to_string)
})?;
Some(path_mode_from_command(&cmd))
}
"codex" => {
let value: toml::Value = toml::from_str(&contents).ok()?;
if let Some(cmd) = value.get("hooks").and_then(|hooks| {
hooks.as_table()?.values().find_map(|event| {
event.as_array()?.iter().find_map(|group| {
group
.get("hooks")?
.as_array()?
.iter()
.find_map(|hook| hook.get("command")?.as_str().map(str::to_string))
})
})
}) {
return Some(path_mode_from_command(&cmd));
}
let arr = value.get("notify")?.as_array()?;
let cmd = arr.get(2)?.as_str()?;
Some(path_mode_from_command(cmd))
}
"opencode" => classify_opencode_plugin_path_mode(&contents).map(PathMode::from),
_ => None,
}
}
fn path_mode_from_command(cmd: &str) -> PathMode {
PathMode::from(verbs::integration_plan::classify_command_path_mode(cmd))
}
#[cfg(test)]
fn classify_command_path_mode(cmd: &str) -> PathMode {
path_mode_from_command(cmd)
}
fn relay_integration(repo: &Repository, args: IntegrationRelayArgs) -> Result<()> {
let mut payload = String::new();
io::stdin().read_to_string(&mut payload)?;
harness::relay_harness_event(repo, &args.harness, &args.event, &payload)
}
fn stamp_integration(repo: &Repository, args: IntegrationStampArgs) -> Result<()> {
let mut payload = String::new();
io::stdin().read_to_string(&mut payload)?;
crate::identity_stamp::stamp_bytes(repo.root(), &args.harness, &payload)?;
Ok(())
}
fn manifest_path(repo: &Repository) -> PathBuf {
repo.root().join(".heddle/state").join(MANIFEST_FILE)
}
fn load_manifest(repo: &Repository) -> Result<IntegrationManifest> {
let path = manifest_path(repo);
if !path.exists() {
return Ok(IntegrationManifest::default());
}
let contents = fs::read_to_string(path)?;
Ok(toml::from_str(&contents)?)
}
fn save_manifest(repo: &Repository, manifest: &IntegrationManifest) -> Result<()> {
let path = manifest_path(repo);
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let contents = toml::to_string_pretty(manifest)?;
write_file_atomic(&path, contents.as_bytes())?;
Ok(())
}
fn integration_status(
_repo: &Repository,
entry: &InstalledIntegration,
) -> Result<IntegrationStatus> {
let mut healthy = true;
let mut status = healthy_status_token().to_string();
for path in &entry.paths {
if !Path::new(path).exists() {
healthy = false;
status = missing_status_token().to_string();
}
}
if healthy && entry.harness == "claude-code" {
let settings = entry.paths.first().map(PathBuf::from);
if let Some(path) = settings
&& fs::read_to_string(&path)
.map(|contents| !claude_settings_has_relay(&contents))
.unwrap_or(true)
{
healthy = false;
status = drifted_status_token().to_string();
}
}
if healthy && entry.harness == "codex" {
let path = entry.paths.first().map(PathBuf::from);
if let Some(path) = path
&& fs::read_to_string(&path)
.map(|contents| !codex_config_has_relay(&contents))
.unwrap_or(true)
{
healthy = false;
status = drifted_status_token().to_string();
}
}
let capability_paths = integration_capability_paths(entry);
Ok(IntegrationStatus {
harness: entry.harness.clone(),
scope: entry.scope.as_kind().as_str().to_string(),
method: entry.method.clone(),
status,
healthy,
paths: entry.paths.clone(),
path_mode: entry.path_mode.as_kind().as_str().to_string(),
capabilities: plan_integration_capabilities(&entry.harness, !capability_paths.is_empty()),
capability_paths,
})
}
fn integration_capability_paths(entry: &InstalledIntegration) -> Vec<String> {
entry
.paths
.iter()
.filter(|path| is_timeline_capability_path(path))
.cloned()
.collect()
}
fn detect_harnesses(repo: &Repository) -> Result<Vec<String>> {
Ok(detect_harnesses_for_root(repo.root()))
}
fn detect_harnesses_for_root(root: &Path) -> Vec<String> {
let mut found = BTreeSet::new();
for harness in ["codex", "claude", "opencode"] {
if command_on_path(harness) {
let normalized = match harness {
"claude" => "claude-code",
other => other,
};
found.insert(normalized.to_string());
}
}
if root.join(".claude").exists() {
found.insert("claude-code".to_string());
}
if root.join(".opencode").exists() {
found.insert("opencode".to_string());
}
found.into_iter().collect()
}
fn command_on_path(bin: &str) -> bool {
env::var_os("PATH")
.map(|paths| env::split_paths(&paths).collect::<Vec<_>>())
.into_iter()
.flatten()
.any(|dir| dir.join(bin).exists())
}
fn resolve_selection_for_root(root: &Path, selection: &str) -> Result<Vec<String>> {
match plan_harness_selection(selection).map_err(harness_error_advice)? {
HarnessSelectionPlan::None => Ok(Vec::new()),
HarnessSelectionPlan::Auto => Ok(detect_harnesses_for_root(root)),
HarnessSelectionPlan::Explicit(names) => Ok(names),
}
}
fn normalize_harnesses(harnesses: Vec<String>) -> Result<Vec<String>> {
normalize_harness_names(harnesses).map_err(harness_error_advice)
}
fn harness_error_advice(err: IntegrationHarnessError) -> anyhow::Error {
match err {
IntegrationHarnessError::Unsupported { harness } => {
anyhow!(unsupported_harness_advice(&harness))
}
}
}
fn unsupported_harness_advice(harness: &str) -> RecoveryAdvice {
RecoveryAdvice::invalid_usage(
"integration_harness_unsupported",
format!("unsupported harness: {harness}"),
"Use one of: codex, claude-code, opencode.",
"heddle integration install codex",
)
}
fn target_harnesses(manifest: &IntegrationManifest, requested: Vec<String>) -> Result<Vec<String>> {
if requested.is_empty() {
return Ok(manifest
.integrations
.iter()
.map(|entry| entry.harness.clone())
.collect());
}
normalize_harnesses(requested)
}
fn validate_harness_scope(harness: &str, scope: &IntegrationScope) -> Result<()> {
plan_validate_harness_scope(harness, scope.as_kind()).map_err(|err| match err {
IntegrationHarnessScopeError::CodexRequiresUser => anyhow!(RecoveryAdvice::invalid_usage(
"integration_codex_scope_invalid",
"codex integration currently requires --scope user",
"Rerun the install with `--scope user`.",
"heddle integration install codex --scope user",
)),
})
}
fn validate_install_plan(harnesses: &[String], scope_value: &str) -> Result<()> {
let scope = IntegrationScope::parse(scope_value)?;
plan_validate_install_plan(harnesses, scope.as_kind()).map_err(|err| match err {
IntegrationHarnessScopeError::CodexRequiresUser => anyhow!(RecoveryAdvice::invalid_usage(
"integration_codex_scope_invalid",
"codex integration currently requires --scope user",
"Rerun the install with `--scope user`.",
"heddle integration install codex --scope user",
)),
})
}
fn install_codex(
_repo: &Repository,
manifest: &mut IntegrationManifest,
scope: &IntegrationScope,
force: bool,
path_mode: PathMode,
) -> Result<()> {
validate_harness_scope("codex", scope)?;
let home = env::var("HOME").context("HOME is required for codex integration install")?;
let config_path = PathBuf::from(home).join(".codex").join("config.toml");
let existing = if config_path.exists() {
fs::read_to_string(&config_path)?
} else {
String::new()
};
if existing.contains("notify =")
&& !existing.contains("integration relay codex")
&& !existing.contains("integration stamp codex")
&& !force
{
return Err(anyhow!(
"codex config already defines a non-Heddle notify command; rerun with --force after manual review"
));
}
let mut value = if existing.trim().is_empty() {
toml::Value::Table(toml::map::Map::new())
} else {
toml::from_str(&existing)?
};
let heddle = HeddleInvocation::resolve(path_mode)?;
let stamp = format!("{heddle} integration stamp codex");
let table = value
.as_table_mut()
.ok_or_else(|| anyhow!("codex config root must be a TOML table"))?;
let features = table
.entry("features")
.or_insert_with(|| toml::Value::Table(toml::map::Map::new()));
features
.as_table_mut()
.ok_or_else(|| anyhow!("codex features must be a table"))?
.insert("hooks".to_string(), toml::Value::Boolean(true));
let hooks = table
.entry("hooks")
.or_insert_with(|| toml::Value::Table(toml::map::Map::new()));
let hooks_table = hooks
.as_table_mut()
.ok_or_else(|| anyhow!("codex hooks must be a table"))?;
for event in ["SessionStart", "SubagentStart", "PreToolUse", "Stop"] {
let command = if event == "Stop" {
format!("{stamp} --expire")
} else {
stamp.clone()
};
upsert_codex_hook_event(hooks_table, event, &command);
}
if table.get("notify").is_some_and(|notify| {
let text = notify.to_string();
text.contains("integration relay codex") || text.contains("integration stamp codex")
}) {
table.remove("notify");
}
if let Some(parent) = config_path.parent() {
fs::create_dir_all(parent)?;
}
write_file_atomic(&config_path, toml::to_string_pretty(&value)?.as_bytes())?;
upsert_manifest(
manifest,
InstalledIntegration {
harness: "codex".to_string(),
scope: scope.clone(),
method: "hooks".to_string(),
paths: vec![config_path.display().to_string()],
status: "installed".to_string(),
heddle_version: env!("CARGO_PKG_VERSION").to_string(),
path_mode,
},
);
Ok(())
}
fn install_claude(
repo: &Repository,
manifest: &mut IntegrationManifest,
scope: &IntegrationScope,
_force: bool,
path_mode: PathMode,
) -> Result<()> {
let settings_path = match scope {
IntegrationScope::Repo => repo.root().join(".claude").join("settings.json"),
IntegrationScope::User => PathBuf::from(env::var("HOME")?)
.join(".claude")
.join("settings.json"),
};
let mut root: Value = if settings_path.exists() {
serde_json::from_str(&fs::read_to_string(&settings_path)?)?
} else {
serde_json::json!({})
};
let hooks = root
.as_object_mut()
.ok_or_else(|| anyhow!("claude settings root must be a JSON object"))?
.entry("hooks")
.or_insert_with(|| serde_json::json!({}));
let hooks_obj = hooks
.as_object_mut()
.ok_or_else(|| anyhow!("claude settings hooks must be an object"))?;
let heddle = HeddleInvocation::resolve(path_mode)?;
let repo_flag = match scope {
IntegrationScope::Repo => format!(" --repo {}", shell_escape(repo.root())),
IntegrationScope::User => String::new(),
};
let stamp = format!("{heddle}{repo_flag} integration stamp claude-code");
for event in [
"SessionStart",
"UserPromptSubmit",
"PreToolUse",
"PostToolUse",
"SubagentStart",
"SubagentStop",
"Stop",
"SessionEnd",
] {
let commands = if event == "PreToolUse" {
vec![stamp.clone()]
} else if event == "SessionEnd" {
vec![
format!("{heddle}{repo_flag} integration relay claude-code {event}"),
format!("{stamp} --expire"),
]
} else {
vec![format!(
"{heddle}{repo_flag} integration relay claude-code {event}"
)]
};
upsert_claude_hook_group(hooks_obj, event, commands)?;
}
let root_obj = root
.as_object_mut()
.ok_or_else(|| anyhow!("claude settings root must be a JSON object"))?;
let existing_status = root_obj
.get("statusLine")
.and_then(Value::as_object)
.and_then(|obj| obj.get("command"))
.and_then(Value::as_str)
.map(str::to_string);
let mut status_cmd = stamp;
if let Some(original) = status_line_user_command(existing_status.as_deref()) {
status_cmd.push_str(" --chain ");
status_cmd.push_str(&shell_escape_arg(&original));
}
root_obj.insert(
"statusLine".to_string(),
serde_json::json!({
"type": "command",
"command": status_cmd
}),
);
if let Some(parent) = settings_path.parent() {
fs::create_dir_all(parent)?;
}
write_file_atomic(
&settings_path,
serde_json::to_string_pretty(&root)?.as_bytes(),
)?;
upsert_manifest(
manifest,
InstalledIntegration {
harness: "claude-code".to_string(),
scope: scope.clone(),
method: "hooks+statusline".to_string(),
paths: vec![settings_path.display().to_string()],
status: "installed".to_string(),
heddle_version: env!("CARGO_PKG_VERSION").to_string(),
path_mode,
},
);
Ok(())
}
fn install_opencode(
repo: &Repository,
manifest: &mut IntegrationManifest,
scope: &IntegrationScope,
_force: bool,
path_mode: PathMode,
) -> Result<()> {
let plugin_path = match scope {
IntegrationScope::Repo => repo
.root()
.join(".opencode")
.join("plugins")
.join("heddle.js"),
IntegrationScope::User => PathBuf::from(env::var("HOME")?)
.join(".config")
.join("opencode")
.join("plugins")
.join("heddle.js"),
};
if let Some(parent) = plugin_path.parent() {
fs::create_dir_all(parent)?;
}
let timeline_manifest_path = plugin_path.with_file_name("heddle.timeline.json");
let heddle_raw = HeddleInvocation::raw(path_mode)?;
let baked_repo = match scope {
IntegrationScope::Repo => Some(repo.root().display().to_string()),
IntegrationScope::User => None,
};
let script = opencode_plugin_script(&heddle_raw, baked_repo.as_deref());
write_file_atomic(&plugin_path, script.as_bytes())?;
let capabilities = opencode_timeline_capabilities(repo, &heddle_raw);
write_file_atomic(
&timeline_manifest_path,
serde_json::to_string_pretty(&capabilities)?.as_bytes(),
)?;
upsert_manifest(
manifest,
InstalledIntegration {
harness: "opencode".to_string(),
scope: scope.clone(),
method: "plugin".to_string(),
paths: vec![
plugin_path.display().to_string(),
timeline_manifest_path.display().to_string(),
],
status: "installed".to_string(),
heddle_version: env!("CARGO_PKG_VERSION").to_string(),
path_mode,
},
);
Ok(())
}
fn opencode_plugin_script(exe: &str, repo: Option<&str>) -> String {
let repo_args = match repo {
Some(repo) => format!(r#""--repo", {repo:?}, "#),
None => String::new(),
};
format!(
r#"export default async function() {{
return {{
event: async (input) => {{
const eventObj = input?.event || input;
const event = eventObj?.type || eventObj?.name || input?.type || input?.name || "event";
const expire = ["session.deleted","session.closed","session.end","session.idle","SessionEnd"].includes(event);
const stamp = [{repo_args}"integration", "stamp", "opencode"];
if (expire) stamp.push("--expire");
Bun.spawnSync([{exe:?}, ...stamp], {{
stdin: JSON.stringify(input),
}});
const allowed = new Set(["session.created","session.updated","session.diff","file.edited","tool.execute.before","tool.execute.after","permission.asked","permission.replied"]);
if (allowed.has(event)) {{
Bun.spawnSync([{exe:?}, {repo_args}"integration", "relay", "opencode", event], {{
stdin: JSON.stringify(input),
}});
}}
}},
}};
}}
"#,
repo_args = repo_args,
exe = exe,
)
}
fn opencode_timeline_capabilities(repo: &Repository, heddle_raw: &str) -> Value {
let repo_path = repo.root().display().to_string();
serde_json::json!({
"schema_version": 1,
"producer": "heddle",
"harness": "opencode",
"repo": repo_path,
"binary": heddle_raw,
"privacy": {
"native_payloads": "summaries-and-hashes",
"raw_payloads_synced_by_default": false
},
"timeline": {
"schema_version": 1,
"default_thread": "main",
"default_harness": "opencode",
"output_kinds": ["timeline_log", "timeline_action"],
"selectors": {
"step": ["--step", "<timeline_step_id>"],
"tool_call": [
"--tool-call",
"<opencode_tool_call_id>",
"--harness",
"opencode",
"--session",
"<opencode_session_id>"
],
"undo": ["--undo"],
"redo": ["--redo"],
"current": ["--current"]
},
"verbs": [
{
"name": "timeline_log",
"verb": "log --timeline",
"intent": "Inspect the current timeline cursor, branches, and recent steps.",
"argv": ["--repo", repo_path, "log", "--timeline", "--thread", "<thread>", "--output", "json"],
"mutates_checkout": false
},
{
"name": "timeline_fork_from_tool_call",
"verb": "agent timeline fork",
"intent": "Create a branch from an OpenCode tool call or timeline step before experimenting.",
"argv": ["--repo", repo_path, "agent", "timeline", "fork", "--tool-call", "<opencode_tool_call_id>", "--harness", "opencode", "--session", "<opencode_session_id>", "--reason", "fan-out", "--output", "json"],
"mutates_checkout": false
},
{
"name": "timeline_reset_to_tool_call",
"verb": "agent timeline reset",
"intent": "Move the timeline cursor to an OpenCode tool call and optionally materialize checkout files.",
"argv": ["--repo", repo_path, "agent", "timeline", "reset", "--tool-call", "<opencode_tool_call_id>", "--harness", "opencode", "--session", "<opencode_session_id>", "--materialize", "--mode", "fail-if-dirty", "--output", "json"],
"mutates_checkout": true
},
{
"name": "timeline_undo",
"verb": "agent timeline reset",
"intent": "Move one reversible timeline step backward.",
"argv": ["--repo", repo_path, "agent", "timeline", "reset", "--undo", "--materialize", "--mode", "fail-if-dirty", "--output", "json"],
"mutates_checkout": true
},
{
"name": "timeline_redo",
"verb": "agent timeline reset",
"intent": "Move one reversible timeline step forward.",
"argv": ["--repo", repo_path, "agent", "timeline", "reset", "--redo", "--materialize", "--mode", "fail-if-dirty", "--output", "json"],
"mutates_checkout": true
},
{
"name": "timeline_recover",
"verb": "agent timeline recover",
"intent": "Inspect or complete recovery after an interrupted timeline materialization.",
"argv": ["--repo", repo_path, "agent", "timeline", "recover", "--thread", "<thread>", "--output", "json"],
"mutates_checkout": false
}
]
}
})
}
fn uninstall_one(
repo: &Repository,
manifest: &mut IntegrationManifest,
harness: &str,
) -> Result<()> {
let Some(existing) = manifest
.integrations
.iter()
.find(|entry| entry.harness == harness)
.cloned()
else {
return Ok(());
};
verbs::expire_identity_cursor(repo.root()).map_err(anyhow::Error::from)?;
match harness {
"codex" => {
if let Some(path) = existing.paths.first() {
let config_path = PathBuf::from(path);
if config_path.exists() {
let mut value: toml::Value =
toml::from_str(&fs::read_to_string(&config_path)?)?;
if let Some(table) = value.as_table_mut() {
if let Some(hooks) = table.get_mut("hooks").and_then(|v| v.as_table_mut()) {
for event in ["SessionStart", "SubagentStart", "PreToolUse", "Stop"] {
if let Some(groups) =
hooks.get_mut(event).and_then(|v| v.as_array_mut())
{
groups.retain(|group| !is_heddle_hook_text(&group.to_string()));
if groups.is_empty() {
hooks.remove(event);
}
}
}
if hooks.is_empty() {
table.remove("hooks");
}
}
if table
.get("notify")
.is_some_and(|notify| is_heddle_hook_text(¬ify.to_string()))
{
table.remove("notify");
}
write_file_atomic(
&config_path,
toml::to_string_pretty(&value)?.as_bytes(),
)?;
}
}
}
}
"claude-code" => {
if let Some(path) = existing.paths.first() {
let settings_path = PathBuf::from(path);
if settings_path.exists() {
let mut root: Value =
serde_json::from_str(&fs::read_to_string(&settings_path)?)?;
if let Some(hooks) = root.get_mut("hooks").and_then(Value::as_object_mut) {
for groups in hooks.values_mut() {
if let Some(array) = groups.as_array_mut() {
array.retain(|group| {
let text = group.to_string();
!text.contains("integration relay claude-code")
&& !text.contains("integration stamp claude-code")
});
}
}
}
if let Some(command) = root
.get("statusLine")
.and_then(Value::as_object)
.and_then(|obj| obj.get("command"))
.and_then(Value::as_str)
&& (command.contains("integration relay claude-code StatusLine")
|| command.contains("integration stamp claude-code"))
{
root.as_object_mut().map(|obj| obj.remove("statusLine"));
}
write_file_atomic(
&settings_path,
serde_json::to_string_pretty(&root)?.as_bytes(),
)?;
}
}
}
"opencode" => {
for path in &existing.paths {
let path = PathBuf::from(path);
if path.exists() {
fs::remove_file(path)?;
}
}
}
_ => {}
}
manifest
.integrations
.retain(|entry| entry.harness != harness);
Ok(())
}
fn upsert_manifest(manifest: &mut IntegrationManifest, entry: InstalledIntegration) {
manifest
.integrations
.retain(|existing| existing.harness != entry.harness);
manifest.integrations.push(entry);
manifest
.integrations
.sort_by(|a, b| a.harness.cmp(&b.harness));
}
fn upsert_claude_hook_group(
hooks_obj: &mut serde_json::Map<String, Value>,
event: &str,
commands: impl IntoIterator<Item = String>,
) -> Result<()> {
let hooks: Vec<Value> = commands
.into_iter()
.map(|command| {
serde_json::json!({
"type": "command",
"command": command
})
})
.collect();
let group = serde_json::json!({
"matcher": "*",
"hooks": hooks
});
let entry = hooks_obj
.entry(event.to_string())
.or_insert_with(|| Value::Array(Vec::new()));
let groups = entry
.as_array_mut()
.ok_or_else(|| anyhow!("claude hook event entries must be arrays"))?;
groups.retain(|group| {
let text = group.to_string();
!text.contains("integration relay claude-code")
&& !text.contains("integration stamp claude-code")
});
groups.push(group);
Ok(())
}
fn is_heddle_hook_text(text: &str) -> bool {
text.contains("integration stamp") || text.contains("integration relay")
}
fn upsert_codex_hook_event(
hooks_table: &mut toml::map::Map<String, toml::Value>,
event: &str,
command: &str,
) {
let mut groups = hooks_table
.get(event)
.and_then(toml::Value::as_array)
.cloned()
.unwrap_or_default();
groups.retain(|group| !is_heddle_hook_text(&group.to_string()));
let mut hook = toml::map::Map::new();
hook.insert(
"type".to_string(),
toml::Value::String("command".to_string()),
);
hook.insert(
"command".to_string(),
toml::Value::String(command.to_string()),
);
let mut group = toml::map::Map::new();
group.insert("matcher".to_string(), toml::Value::String("*".to_string()));
group.insert(
"hooks".to_string(),
toml::Value::Array(vec![toml::Value::Table(hook)]),
);
groups.push(toml::Value::Table(group));
hooks_table.insert(event.to_string(), toml::Value::Array(groups));
}
fn status_line_user_command(existing: Option<&str>) -> Option<String> {
let command = existing?.trim();
if command.is_empty() {
return None;
}
if let Some(chained) = extract_status_line_chain(command) {
return Some(chained).filter(|value| !value.trim().is_empty());
}
if command.contains("integration stamp claude-code")
|| command.contains("integration relay claude-code StatusLine")
{
return None;
}
Some(command.to_string())
}
fn extract_status_line_chain(command: &str) -> Option<String> {
if let Some(rest) = command.split_once(" --chain=").map(|(_, rest)| rest) {
return Some(unshell_escape_arg(rest.trim()));
}
command
.split_once(" --chain ")
.map(|(_, rest)| unshell_escape_arg(rest.trim()))
}
fn unshell_escape_arg(value: &str) -> String {
let value = value.trim();
if let Some(inner) = value.strip_prefix('\'').and_then(|s| s.strip_suffix('\'')) {
return inner.replace("'\"'\"'", "'");
}
if let Some(inner) = value.strip_prefix('"').and_then(|s| s.strip_suffix('"')) {
return inner.replace("\\\"", "\"").replace("\\\\", "\\");
}
value.to_string()
}
fn shell_escape(path: &Path) -> String {
shell_escape_arg(&path.display().to_string())
}
fn shell_escape_arg(value: &str) -> String {
format!("'{}'", value.replace('\'', "'\"'\"'"))
}
#[cfg(test)]
mod tests {
use clap::Parser;
use super::*;
use crate::cli::Commands;
struct HomeEnvGuard(Option<std::ffi::OsString>);
impl HomeEnvGuard {
fn set(path: &Path) -> Self {
let original = std::env::var_os("HOME");
unsafe {
std::env::set_var("HOME", path);
}
Self(original)
}
}
impl Drop for HomeEnvGuard {
fn drop(&mut self) {
match self.0.take() {
Some(value) => unsafe { std::env::set_var("HOME", value) },
None => unsafe { std::env::remove_var("HOME") },
}
}
}
fn init_repo() -> (tempfile::TempDir, Repository) {
let temp = tempfile::TempDir::new().unwrap();
let repo = Repository::init_default(temp.path()).unwrap();
(temp, repo)
}
#[test]
fn init_harness_selection_does_not_auto_connect_detected_harnesses() {
let temp = tempfile::TempDir::new().unwrap();
fs::create_dir(temp.path().join(".claude")).unwrap();
let cli = Cli::parse_from(["heddle", "init"]);
let Commands::Init(args) = &cli.command else {
panic!("expected parsed init command");
};
let harnesses = prompt_init_install_decision(&cli, temp.path(), args, false).unwrap();
assert!(
harnesses.is_empty(),
"init must not auto-select detected harnesses without --install-harnesses"
);
}
#[test]
fn claude_repo_install_writes_project_hooks_and_manifest() {
let (_temp, repo) = init_repo();
let mut manifest = IntegrationManifest::default();
install_claude(
&repo,
&mut manifest,
&IntegrationScope::Repo,
false,
PathMode::Relative,
)
.unwrap();
let settings_path = repo.root().join(".claude").join("settings.json");
let contents = fs::read_to_string(&settings_path).unwrap();
assert!(contents.contains("integration relay claude-code SessionStart"));
assert!(contents.contains("integration relay claude-code UserPromptSubmit"));
assert!(contents.contains("integration stamp claude-code"));
assert!(!contents.contains("integration relay claude-code PreToolUse"));
assert!(contents.contains("integration relay claude-code PostToolUse"));
assert!(contents.contains("integration relay claude-code SubagentStop"));
assert!(contents.contains("integration relay claude-code Stop"));
assert!(contents.contains("integration stamp claude-code --expire"));
assert!(contents.contains("integration relay claude-code SessionEnd"));
assert!(contents.contains("statusLine"));
let parsed: Value = serde_json::from_str(&contents).unwrap();
let session_start_cmd = parsed["hooks"]["SessionStart"][0]["hooks"][0]["command"]
.as_str()
.unwrap();
assert!(
session_start_cmd.starts_with("heddle --repo "),
"expected PATH-relative `heddle` invocation, got: {session_start_cmd}"
);
assert!(
!session_start_cmd.starts_with('/'),
"expected no absolute path leading the command, got: {session_start_cmd}"
);
let status_line_cmd = parsed["statusLine"]["command"].as_str().unwrap();
assert!(
status_line_cmd.starts_with("heddle --repo "),
"expected PATH-relative `heddle` invocation in statusLine, got: {status_line_cmd}"
);
assert_eq!(manifest.integrations.len(), 1);
assert_eq!(manifest.integrations[0].harness, "claude-code");
assert_eq!(manifest.integrations[0].path_mode, PathMode::Relative);
}
#[test]
#[serial_test::serial]
fn claude_user_install_discovers_workspace_at_runtime() {
static TEST_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
let _env_lock = TEST_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let (_temp, repo) = init_repo();
let home = tempfile::TempDir::new().unwrap();
let _home_guard = HomeEnvGuard::set(home.path());
let mut manifest = IntegrationManifest::default();
install_claude(
&repo,
&mut manifest,
&IntegrationScope::User,
false,
PathMode::Relative,
)
.unwrap();
let settings_path = home.path().join(".claude").join("settings.json");
let contents = fs::read_to_string(&settings_path).unwrap();
let install_repo = repo.root().display().to_string();
assert!(
!contents.contains(&install_repo),
"user-scoped Claude hooks must not bake the install-time repo, got: {contents}"
);
assert!(
!contents.contains("--repo"),
"user-scoped Claude hooks must discover the workspace from cwd, got: {contents}"
);
assert!(
contents.contains("heddle integration stamp claude-code"),
"user-scoped Claude must still stamp, got: {contents}"
);
assert!(
contents.contains("heddle integration stamp claude-code --expire"),
"user-scoped SessionEnd must expire, got: {contents}"
);
assert!(
contents.contains("heddle integration relay claude-code SessionEnd"),
"user-scoped SessionEnd must still relay close_session, got: {contents}"
);
assert!(
contents.contains("heddle integration relay claude-code SessionStart"),
"user-scoped Claude must still relay without a baked repo, got: {contents}"
);
}
#[test]
fn claude_repo_install_chains_existing_status_line() {
let (_temp, repo) = init_repo();
let settings_path = repo.root().join(".claude").join("settings.json");
fs::create_dir_all(settings_path.parent().unwrap()).unwrap();
fs::write(
&settings_path,
r#"{"statusLine":{"type":"command","command":"echo custom-status"}}"#,
)
.unwrap();
let mut manifest = IntegrationManifest::default();
install_claude(
&repo,
&mut manifest,
&IntegrationScope::Repo,
false,
PathMode::Relative,
)
.unwrap();
let parsed: Value =
serde_json::from_str(&fs::read_to_string(&settings_path).unwrap()).unwrap();
let status_line_cmd = parsed["statusLine"]["command"].as_str().unwrap();
assert!(status_line_cmd.contains("integration stamp claude-code"));
assert!(status_line_cmd.contains("--chain"));
assert!(status_line_cmd.contains("echo custom-status"));
}
#[test]
fn claude_status_line_reinstall_still_chains() {
let (_temp, repo) = init_repo();
let settings_path = repo.root().join(".claude").join("settings.json");
fs::create_dir_all(settings_path.parent().unwrap()).unwrap();
fs::write(
&settings_path,
r#"{"statusLine":{"type":"command","command":"echo custom-status"}}"#,
)
.unwrap();
let mut manifest = IntegrationManifest::default();
install_claude(
&repo,
&mut manifest,
&IntegrationScope::Repo,
false,
PathMode::Relative,
)
.unwrap();
install_claude(
&repo,
&mut manifest,
&IntegrationScope::Repo,
true,
PathMode::Relative,
)
.unwrap();
let parsed: Value =
serde_json::from_str(&fs::read_to_string(&settings_path).unwrap()).unwrap();
let status_line_cmd = parsed["statusLine"]["command"].as_str().unwrap();
assert!(status_line_cmd.contains("integration stamp claude-code"));
assert!(status_line_cmd.contains("--chain"));
assert!(
status_line_cmd.contains("echo custom-status"),
"reinstall/upgrade must keep the user's StatusLine, got: {status_line_cmd}"
);
assert_eq!(
status_line_cmd
.matches("integration stamp claude-code")
.count(),
1,
"must not nest stamp commands: {status_line_cmd}"
);
}
#[test]
fn claude_repo_install_with_absolute_path_bakes_current_exe() {
let (_temp, repo) = init_repo();
let mut manifest = IntegrationManifest::default();
install_claude(
&repo,
&mut manifest,
&IntegrationScope::Repo,
false,
PathMode::Absolute,
)
.unwrap();
let settings_path = repo.root().join(".claude").join("settings.json");
let contents = fs::read_to_string(&settings_path).unwrap();
let parsed: Value = serde_json::from_str(&contents).unwrap();
let exe = std::env::current_exe().unwrap();
let escaped_exe = shell_escape(&exe);
let session_start_cmd = parsed["hooks"]["SessionStart"][0]["hooks"][0]["command"]
.as_str()
.unwrap();
assert!(
session_start_cmd.starts_with(&escaped_exe),
"expected absolute heddle path {escaped_exe} prefix, got: {session_start_cmd}"
);
assert!(
!session_start_cmd.starts_with("heddle "),
"absolute mode must not emit bare `heddle`, got: {session_start_cmd}"
);
let status_line_cmd = parsed["statusLine"]["command"].as_str().unwrap();
assert!(
status_line_cmd.starts_with(&escaped_exe),
"expected absolute heddle path {escaped_exe} prefix in statusLine, got: {status_line_cmd}"
);
assert_eq!(manifest.integrations[0].path_mode, PathMode::Absolute);
}
#[test]
fn opencode_repo_install_and_uninstall_manage_plugin_file() {
let (_temp, repo) = init_repo();
let mut manifest = IntegrationManifest::default();
install_opencode(
&repo,
&mut manifest,
&IntegrationScope::Repo,
false,
PathMode::Relative,
)
.unwrap();
let plugin_path = repo
.root()
.join(".opencode")
.join("plugins")
.join("heddle.js");
assert!(plugin_path.exists());
let plugin_contents = fs::read_to_string(&plugin_path).unwrap();
assert!(
plugin_contents.contains("\"heddle\""),
"opencode plugin should reference PATH-relative `heddle`, got: {plugin_contents}"
);
assert!(
plugin_contents.contains("event?.type") || plugin_contents.contains("eventObj?.type"),
"opencode plugin must read event.type, got: {plugin_contents}"
);
assert!(
plugin_contents.contains("\"integration\", \"stamp\", \"opencode\"")
&& plugin_contents.contains("Bun.spawnSync"),
"opencode writes must go through locked integration stamp, got: {plugin_contents}"
);
assert!(
!plugin_contents.contains("Bun.spawn("),
"OpenCode relays must await via spawnSync so before/after stay ordered, got: {plugin_contents}"
);
assert!(
plugin_contents.contains("session.deleted") && plugin_contents.contains("--expire"),
"plugin must expire the cursor on session end through stamp --expire"
);
assert!(
!plugin_contents.contains("mergeCursor")
&& !plugin_contents.contains("unlinkSync")
&& !plugin_contents.contains("writeFileSync"),
"plugin must not unlocked-RMW the identity sidecar, got: {plugin_contents}"
);
assert!(
!plugin_contents.contains("session.get"),
"plugin must not hunt session.get, got: {plugin_contents}"
);
let timeline_manifest_path = repo
.root()
.join(".opencode")
.join("plugins")
.join("heddle.timeline.json");
assert!(timeline_manifest_path.exists());
let timeline_manifest: Value =
serde_json::from_str(&fs::read_to_string(&timeline_manifest_path).unwrap()).unwrap();
assert_eq!(timeline_manifest["schema_version"], 1);
assert_eq!(timeline_manifest["harness"], "opencode");
assert_eq!(timeline_manifest["timeline"]["schema_version"], 1);
assert_eq!(timeline_manifest["timeline"]["default_harness"], "opencode");
let repo_path = repo.root().display().to_string();
let expected_timeline_commands = [
serde_json::json!({
"name": "timeline_fork_from_tool_call",
"verb": "agent timeline fork",
"argv": ["--repo", repo_path, "agent", "timeline", "fork", "--tool-call", "<opencode_tool_call_id>", "--harness", "opencode", "--session", "<opencode_session_id>", "--reason", "fan-out", "--output", "json"]
}),
serde_json::json!({
"name": "timeline_reset_to_tool_call",
"verb": "agent timeline reset",
"argv": ["--repo", repo_path, "agent", "timeline", "reset", "--tool-call", "<opencode_tool_call_id>", "--harness", "opencode", "--session", "<opencode_session_id>", "--materialize", "--mode", "fail-if-dirty", "--output", "json"]
}),
serde_json::json!({
"name": "timeline_undo",
"verb": "agent timeline reset",
"argv": ["--repo", repo_path, "agent", "timeline", "reset", "--undo", "--materialize", "--mode", "fail-if-dirty", "--output", "json"]
}),
serde_json::json!({
"name": "timeline_redo",
"verb": "agent timeline reset",
"argv": ["--repo", repo_path, "agent", "timeline", "reset", "--redo", "--materialize", "--mode", "fail-if-dirty", "--output", "json"]
}),
serde_json::json!({
"name": "timeline_recover",
"verb": "agent timeline recover",
"argv": ["--repo", repo_path, "agent", "timeline", "recover", "--thread", "<thread>", "--output", "json"]
}),
];
let timeline_commands = timeline_manifest["timeline"]["verbs"].as_array().unwrap();
for expected in expected_timeline_commands {
let actual = timeline_commands
.iter()
.find(|command| command["name"] == expected["name"])
.unwrap();
assert_eq!(actual["verb"], expected["verb"]);
assert_eq!(actual["argv"], expected["argv"]);
let argv = actual["argv"]
.as_array()
.unwrap()
.iter()
.map(|arg| arg.as_str().unwrap());
Cli::try_parse_from(std::iter::once("heddle").chain(argv)).unwrap();
}
let status = integration_status(&repo, &manifest.integrations[0]).unwrap();
assert_eq!(status.capabilities, vec!["timeline"]);
assert_eq!(
status.capability_paths,
vec![timeline_manifest_path.display().to_string()]
);
verbs::write_identity_cursor(
repo.root(),
&verbs::IdentityCursor {
provider: Some("opencode".into()),
model: Some("sonnet".into()),
..verbs::IdentityCursor::default()
},
)
.unwrap();
uninstall_one(&repo, &mut manifest, "opencode").unwrap();
assert!(!plugin_path.exists());
assert!(!timeline_manifest_path.exists());
assert!(manifest.integrations.is_empty());
assert!(
verbs::read_identity_cursor(repo.root()).is_empty(),
"uninstall must expire the identity cursor"
);
}
#[test]
#[serial_test::serial]
fn opencode_user_install_discovers_workspace_at_runtime() {
static TEST_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
let _env_lock = TEST_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let (_temp, repo) = init_repo();
let home = tempfile::TempDir::new().unwrap();
let _home_guard = HomeEnvGuard::set(home.path());
let mut manifest = IntegrationManifest::default();
install_opencode(
&repo,
&mut manifest,
&IntegrationScope::User,
false,
PathMode::Relative,
)
.unwrap();
let plugin_path = home
.path()
.join(".config")
.join("opencode")
.join("plugins")
.join("heddle.js");
let contents = fs::read_to_string(&plugin_path).unwrap();
let install_repo = repo.root().display().to_string();
assert!(
!contents.contains(&install_repo),
"user-scoped plugin must not bake the install-time repo, got: {contents}"
);
assert!(
!contents.contains("--repo"),
"user-scoped plugin must discover the workspace from cwd, got: {contents}"
);
assert!(
contents.contains("\"integration\", \"stamp\", \"opencode\"")
&& contents.contains("Bun.spawnSync"),
"user-scoped plugin must still stamp through the locked path, got: {contents}"
);
}
#[test]
#[serial_test::serial]
fn codex_user_install_writes_workspace_stamp_hooks() {
static TEST_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
let _env_lock = TEST_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let (_temp, repo) = init_repo();
let home = tempfile::TempDir::new().unwrap();
let _home_guard = HomeEnvGuard::set(home.path());
let mut manifest = IntegrationManifest::default();
install_codex(
&repo,
&mut manifest,
&IntegrationScope::User,
false,
PathMode::Relative,
)
.unwrap();
let config_path = home.path().join(".codex").join("config.toml");
let contents = fs::read_to_string(&config_path).unwrap();
assert!(contents.contains("integration stamp codex"));
assert!(contents.contains("SessionStart"));
assert!(contents.contains("PreToolUse"));
assert!(!contents.contains("notify ="));
assert!(!contents.contains("/bin/sh"));
assert!(
!contents.contains("--repo"),
"codex hook must discover the workspace from cwd, got: {contents}"
);
assert!(
contents.contains("heddle integration stamp codex"),
"expected PATH-relative `heddle` in codex hook command, got: {contents}"
);
assert_eq!(
contents
.matches("heddle integration stamp codex --expire\"")
.count(),
1,
"Codex Stop must expire, same as Claude SessionEnd, got: {contents}"
);
assert_eq!(
contents.matches("heddle integration stamp codex\"").count(),
3,
"SessionStart/SubagentStart/PreToolUse must stamp without expiring, got: {contents}"
);
assert!(
contents.contains("[[hooks.Stop]]")
&& contents.contains("heddle integration stamp codex --expire"),
"Stop hook must be the expire command, got: {contents}"
);
assert_eq!(manifest.integrations[0].harness, "codex");
assert_eq!(manifest.integrations[0].path_mode, PathMode::Relative);
verbs::write_identity_cursor(
repo.root(),
&verbs::IdentityCursor {
provider: Some("openai".into()),
model: Some("gpt-5.4".into()),
..verbs::IdentityCursor::default()
},
)
.unwrap();
uninstall_one(&repo, &mut manifest, "codex").unwrap();
assert!(
verbs::read_identity_cursor(repo.root()).is_empty(),
"codex uninstall must expire the identity cursor"
);
let after = fs::read_to_string(&config_path).unwrap();
assert!(
!after.contains("integration stamp"),
"codex uninstall must remove stamp hooks, got: {after}"
);
}
#[test]
fn upgrade_preserves_path_mode_when_absolute() {
let (_temp, repo) = init_repo();
let mut manifest = IntegrationManifest::default();
install_claude(
&repo,
&mut manifest,
&IntegrationScope::Repo,
false,
PathMode::Absolute,
)
.unwrap();
assert_eq!(manifest.integrations[0].path_mode, PathMode::Absolute);
save_manifest(&repo, &manifest).unwrap();
let mut reloaded = load_manifest(&repo).unwrap();
let existing = reloaded
.integrations
.iter()
.find(|entry| entry.harness == "claude-code")
.cloned()
.unwrap();
install_claude(
&repo,
&mut reloaded,
&existing.scope,
true,
existing.path_mode,
)
.unwrap();
assert_eq!(reloaded.integrations[0].path_mode, PathMode::Absolute);
let settings_path = repo.root().join(".claude").join("settings.json");
let contents = fs::read_to_string(&settings_path).unwrap();
let parsed: Value = serde_json::from_str(&contents).unwrap();
let cmd = parsed["hooks"]["SessionStart"][0]["hooks"][0]["command"]
.as_str()
.unwrap();
assert!(
!cmd.starts_with("heddle "),
"upgrade must not silently flip an absolute install to relative, got: {cmd}"
);
}
#[test]
fn upgrade_preserves_path_mode_for_legacy_manifest_with_absolute_install() {
let (_temp, repo) = init_repo();
let mut manifest = IntegrationManifest::default();
install_claude(
&repo,
&mut manifest,
&IntegrationScope::Repo,
false,
PathMode::Absolute,
)
.unwrap();
let settings_path = repo.root().join(".claude").join("settings.json");
let settings_contents = fs::read_to_string(&settings_path).unwrap();
assert!(
!settings_contents.contains("\"heddle --repo "),
"absolute install must NOT have bare `heddle` prefix"
);
manifest.integrations[0].path_mode = PathMode::Absolute; save_manifest(&repo, &manifest).unwrap();
let manifest_path = repo.root().join(".heddle/state").join(MANIFEST_FILE);
let raw = fs::read_to_string(&manifest_path).unwrap();
let stripped: String = raw
.lines()
.filter(|l| !l.trim_start().starts_with("path_mode"))
.collect::<Vec<_>>()
.join("\n");
fs::write(&manifest_path, stripped).unwrap();
let reloaded = load_manifest(&repo).unwrap();
assert_eq!(
reloaded.integrations[0].path_mode,
PathMode::Relative,
"sanity: legacy manifest must deserialize to the field default"
);
let detected =
detect_path_mode("claude-code", &reloaded.integrations[0]).expect("detection succeeds");
assert_eq!(
detected,
PathMode::Absolute,
"detect_path_mode must read the on-disk settings and recognise an absolute install"
);
let mut working = reloaded;
let resolved_mode =
detect_path_mode("claude-code", &working.integrations[0]).unwrap_or(PathMode::Relative);
let scope = working.integrations[0].scope.clone();
install_claude(&repo, &mut working, &scope, true, resolved_mode).unwrap();
let settings_after = fs::read_to_string(&settings_path).unwrap();
let parsed: Value = serde_json::from_str(&settings_after).unwrap();
let cmd = parsed["hooks"]["SessionStart"][0]["hooks"][0]["command"]
.as_str()
.unwrap();
assert!(
!cmd.starts_with("heddle "),
"upgrade of a legacy absolute install must NOT silently flip to PATH-relative, got: {cmd}"
);
}
#[test]
fn classify_command_path_mode_recognises_relative_and_absolute() {
assert_eq!(
classify_command_path_mode(
"heddle --repo /some/path integration relay claude-code Stop"
),
PathMode::Relative,
);
assert_eq!(
classify_command_path_mode(
"/Users/dev/.cargo/bin/heddle --repo /repo integration relay claude-code Stop"
),
PathMode::Absolute,
);
assert_eq!(
classify_command_path_mode(
"'/Users/dev/.cargo/bin/heddle' --repo /repo integration relay claude-code Stop"
),
PathMode::Absolute,
);
}
#[test]
fn upgrade_preserves_path_mode_when_relative() {
let (_temp, repo) = init_repo();
let mut manifest = IntegrationManifest::default();
install_claude(
&repo,
&mut manifest,
&IntegrationScope::Repo,
false,
PathMode::Relative,
)
.unwrap();
save_manifest(&repo, &manifest).unwrap();
let mut reloaded = load_manifest(&repo).unwrap();
let existing = reloaded
.integrations
.iter()
.find(|entry| entry.harness == "claude-code")
.cloned()
.unwrap();
install_claude(
&repo,
&mut reloaded,
&existing.scope,
true,
existing.path_mode,
)
.unwrap();
assert_eq!(reloaded.integrations[0].path_mode, PathMode::Relative);
}
}