Skip to main content

ninox_core/
hooks.rs

1use anyhow::Result;
2use std::{
3    collections::HashMap,
4    path::{Path, PathBuf},
5};
6
7// ---------------------------------------------------------------------------
8// Metadata types
9// ---------------------------------------------------------------------------
10
11#[derive(Debug, Clone, Default)]
12pub struct SessionMetadata {
13    pub pr_number: Option<u64>,
14    pub pr_url:    Option<String>,
15    pub branch:    Option<String>,
16}
17
18// ---------------------------------------------------------------------------
19// Wrapper scripts
20// ---------------------------------------------------------------------------
21
22/// The `gh` wrapper script. Intercepts `gh pr create`, extracts the PR URL
23/// from output, and writes it to the session metadata JSON file.
24///
25/// Env vars consumed at runtime (injected by ninox when spawning the tmux session):
26///   ATHENE_SESSION    — session ID used as metadata filename
27///   ATHENE_DATA_DIR   — directory where {ATHENE_SESSION}.json lives
28const GH_WRAPPER: &str = r#"#!/usr/bin/env bash
29# Ninox gh wrapper — intercepts gh pr create to record PR metadata.
30set -euo pipefail
31
32# Locate the real gh binary (skip ourselves).
33_real_gh=""
34IFS=: read -ra _path_parts <<< "$PATH"
35for _dir in "${_path_parts[@]}"; do
36    _candidate="$_dir/gh"
37    if [[ "$_candidate" != "$0" && -x "$_candidate" ]]; then
38        _real_gh="$_candidate"
39        break
40    fi
41done
42if [[ -z "$_real_gh" ]]; then
43    echo "ninox: gh not found in PATH (excluding wrapper)" >&2
44    exit 1
45fi
46
47# Run the real gh and tee output so we can parse it.
48if [[ "${1:-}" == "pr" && "${2:-}" == "create" ]]; then
49    _output=$("$_real_gh" "$@" 2>&1)
50    _exit=$?
51    echo "$_output"
52    if [[ $_exit -eq 0 && -n "${ATHENE_SESSION:-}" && -n "${ATHENE_DATA_DIR:-}" ]]; then
53        _pr_url=$(echo "$_output" | grep -oE 'https?://[^/]+/[^/]+/[^/]+/pull/[0-9]+' | head -1)
54        if [[ -n "$_pr_url" ]]; then
55            _pr_num=$(echo "$_pr_url" | grep -oE '[0-9]+$')
56            _meta_file="${ATHENE_DATA_DIR}/${ATHENE_SESSION}.json"
57            mkdir -p "$(dirname "$_meta_file")"
58            _tmp="${_meta_file}.tmp.$$"
59            if [[ -f "$_meta_file" ]]; then
60                _existing=$(cat "$_meta_file")
61            else
62                _existing="{}"
63            fi
64            if command -v jq &>/dev/null; then
65                echo "$_existing" | jq \
66                    --arg url "$_pr_url" \
67                    --arg num "$_pr_num" \
68                    '. + {"agentReportedPrUrl": $url, "agentReportedPrNumber": $num, "agentReportedState": "pr_created"}' \
69                    > "$_tmp" && mv "$_tmp" "$_meta_file"
70            else
71                # Fallback: node (likely available alongside gh).
72                # PR URL and number are passed via env vars, not interpolated into the
73                # script string, to avoid shell injection from external GitHub output.
74                NINOX_PR_URL="$_pr_url" NINOX_PR_NUM="$_pr_num" node -e "
75                    const fs = require('fs');
76                    const url = process.env.NINOX_PR_URL;
77                    const num = process.env.NINOX_PR_NUM;
78                    const f = '${_meta_file}';
79                    const m = JSON.parse(fs.existsSync(f) ? fs.readFileSync(f,'utf8') : '{}');
80                    m.agentReportedPrUrl = url;
81                    m.agentReportedPrNumber = num;
82                    m.agentReportedState = 'pr_created';
83                    fs.writeFileSync(f + '.tmp.\$\$', JSON.stringify(m,null,2));
84                    fs.renameSync(f + '.tmp.\$\$', f);
85                " 2>/dev/null || true
86            fi
87        fi
88    fi
89    exit $_exit
90else
91    exec "$_real_gh" "$@"
92fi
93"#;
94
95/// The `git` wrapper script. Intercepts branch creation to record branch name.
96const GIT_WRAPPER: &str = r#"#!/usr/bin/env bash
97# Ninox git wrapper — records branch name on checkout -b / switch -c.
98set -euo pipefail
99
100_real_git=""
101IFS=: read -ra _path_parts <<< "$PATH"
102for _dir in "${_path_parts[@]}"; do
103    _candidate="$_dir/git"
104    if [[ "$_candidate" != "$0" && -x "$_candidate" ]]; then
105        _real_git="$_candidate"
106        break
107    fi
108done
109if [[ -z "$_real_git" ]]; then
110    echo "ninox: git not found in PATH (excluding wrapper)" >&2
111    exit 1
112fi
113
114# Run the real git command first.
115"$_real_git" "$@"
116_exit=$?
117
118# On success, capture branch name for checkout -b / switch -c.
119if [[ $_exit -eq 0 && -n "${ATHENE_SESSION:-}" && -n "${ATHENE_DATA_DIR:-}" ]]; then
120    _branch=""
121    if [[ "${1:-}" == "checkout" && "${2:-}" == "-b" && -n "${3:-}" ]]; then
122        _branch="${3}"
123    elif [[ "${1:-}" == "switch" && "${2:-}" == "-c" && -n "${3:-}" ]]; then
124        _branch="${3}"
125    fi
126    if [[ -n "$_branch" ]]; then
127        _meta_file="${ATHENE_DATA_DIR}/${ATHENE_SESSION}.json"
128        mkdir -p "$(dirname "$_meta_file")"
129        if command -v jq &>/dev/null; then
130            _tmp="${_meta_file}.tmp.$$"
131            _existing=$([ -f "$_meta_file" ] && cat "$_meta_file" || echo "{}")
132            echo "$_existing" | jq --arg b "$_branch" '. + {"branch": $b}' \
133                > "$_tmp" && mv "$_tmp" "$_meta_file"
134        fi
135    fi
136fi
137
138exit $_exit
139"#;
140
141// ---------------------------------------------------------------------------
142// Public API
143// ---------------------------------------------------------------------------
144
145/// Install `gh` and `git` wrapper scripts to the given directory.
146/// Called with the Ninox bin dir (`~/.config/ninox/bin/`) in production.
147pub fn install_wrappers_to(bin_dir: &Path) -> Result<()> {
148    std::fs::create_dir_all(bin_dir)?;
149    write_executable(bin_dir.join("gh"),  GH_WRAPPER)?;
150    write_executable(bin_dir.join("git"), GIT_WRAPPER)?;
151    Ok(())
152}
153
154/// Install wrappers to the default Ninox bin dir.
155pub fn install_wrappers() -> Result<()> {
156    install_wrappers_to(&crate::config::AppConfig::ninox_bin_dir())
157}
158
159/// Write a thin `ninox` shim to the Ninox bin dir that forwards all arguments
160/// to the currently-running executable. This ensures that when an orchestrator
161/// runs `ninox spawn` (and `~/.config/ninox/bin` is first in PATH), it always
162/// invokes the same build that is currently running — not a stale system install.
163pub fn install_self_shim(current_exe: &Path) -> Result<()> {
164    let bin_dir = crate::config::AppConfig::ninox_bin_dir();
165    std::fs::create_dir_all(&bin_dir)?;
166    let exe = current_exe.to_string_lossy().replace('\'', "'\\''");
167    let script = format!(
168        "#!/usr/bin/env bash\nexec '{}' \"$@\"\n",
169        exe
170    );
171    write_executable(bin_dir.join("ninox"), &script)?;
172    Ok(())
173}
174
175/// Read session metadata from `{dir}/{session_id}.json`.
176/// Returns empty `SessionMetadata` if the file does not exist or is malformed.
177pub fn read_session_metadata(dir: &Path, session_id: &str) -> Result<SessionMetadata> {
178    let path = dir.join(format!("{session_id}.json"));
179    if !path.exists() {
180        return Ok(SessionMetadata::default());
181    }
182    let raw = std::fs::read_to_string(&path)?;
183    let map: HashMap<String, serde_json::Value> = match serde_json::from_str(&raw) {
184        Ok(m)  => m,
185        Err(_) => return Ok(SessionMetadata::default()),
186    };
187    let pr_number = map.get("agentReportedPrNumber")
188        .and_then(|v| v.as_str())
189        .and_then(|s| s.parse::<u64>().ok());
190    let pr_url = map.get("agentReportedPrUrl")
191        .and_then(|v| v.as_str())
192        .map(|s| s.to_string());
193    let branch = map.get("branch")
194        .and_then(|v| v.as_str())
195        .map(|s| s.to_string());
196    Ok(SessionMetadata { pr_number, pr_url, branch })
197}
198
199// ---------------------------------------------------------------------------
200// Internal helpers
201// ---------------------------------------------------------------------------
202
203fn write_executable(path: PathBuf, content: &str) -> Result<()> {
204    // Atomic write: write to temp, then rename.
205    let tmp = path.with_extension("tmp");
206    std::fs::write(&tmp, content)?;
207    #[cfg(unix)]
208    {
209        use std::os::unix::fs::PermissionsExt;
210        std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o755))?;
211    }
212    std::fs::rename(&tmp, &path)?;
213    Ok(())
214}
215
216#[cfg(test)]
217mod tests {
218    use super::*;
219    use tempfile::tempdir;
220
221    #[test]
222    fn install_wrappers_creates_executables() {
223        let dir = tempdir().unwrap();
224        install_wrappers_to(dir.path()).unwrap();
225        assert!(dir.path().join("gh").exists());
226        assert!(dir.path().join("git").exists());
227        #[cfg(unix)]
228        {
229            use std::os::unix::fs::PermissionsExt;
230            let gh_mode = std::fs::metadata(dir.path().join("gh"))
231                .unwrap().permissions().mode();
232            assert!(gh_mode & 0o111 != 0, "gh wrapper should be executable");
233        }
234    }
235
236    #[test]
237    fn read_session_metadata_parses_pr_number() {
238        let dir = tempdir().unwrap();
239        let metadata = serde_json::json!({
240            "agentReportedPrNumber": "42",
241            "agentReportedPrUrl": "https://github.com/org/repo/pull/42",
242            "branch": "feat/my-fix"
243        });
244        std::fs::write(
245            dir.path().join("s1.json"),
246            serde_json::to_string(&metadata).unwrap(),
247        ).unwrap();
248        let m = read_session_metadata(dir.path(), "s1").unwrap();
249        assert_eq!(m.pr_number, Some(42));
250        assert_eq!(m.branch.as_deref(), Some("feat/my-fix"));
251    }
252
253    #[test]
254    fn read_session_metadata_returns_default_on_missing_file() {
255        let dir = tempdir().unwrap();
256        let m = read_session_metadata(dir.path(), "nonexistent").unwrap();
257        assert_eq!(m.pr_number, None);
258        assert_eq!(m.branch, None);
259    }
260
261    #[test]
262    fn read_session_metadata_handles_malformed_json() {
263        let dir = tempdir().unwrap();
264        std::fs::write(dir.path().join("bad.json"), "not json").unwrap();
265        let m = read_session_metadata(dir.path(), "bad").unwrap();
266        assert_eq!(m.pr_number, None);
267    }
268}