1use anyhow::Result;
2use std::{
3 collections::HashMap,
4 path::{Path, PathBuf},
5};
6
7#[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
18const 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
95const 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
141pub 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
154pub fn install_wrappers() -> Result<()> {
156 install_wrappers_to(&crate::config::AppConfig::ninox_bin_dir())
157}
158
159pub 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
175pub 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
199fn write_executable(path: PathBuf, content: &str) -> Result<()> {
204 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}