1use super::schema::{
2 BUNDLE_ARTIFACTS_DIR, BUNDLE_MANIFEST_FILE_NAME, BundleArtifact, BundleManifest,
3};
4use super::sources::{git_context, image_processing, macos_agent, screen_record};
5use super::{EXIT_OK, EXIT_RUNTIME_ERROR};
6use clap::{Args, ValueEnum};
7use serde::Serialize;
8use serde_json::json;
9use std::path::{Path, PathBuf};
10use std::process::Command;
11
12const DEFAULT_OUTPUT_NAMESPACE: &str = "agentctl-debug-bundle";
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum, Default)]
15pub enum BundleOutputFormat {
16 Text,
17 #[default]
18 Json,
19}
20
21#[derive(Debug, Args)]
22pub struct BundleArgs {
23 #[arg(long)]
25 pub output_dir: Option<PathBuf>,
26
27 #[arg(long, value_enum, default_value_t = BundleOutputFormat::Json)]
29 pub format: BundleOutputFormat,
30}
31
32#[derive(Debug, Serialize)]
33struct BundleCommandOutput<'a> {
34 manifest_path: String,
35 manifest: &'a BundleManifest,
36}
37
38#[derive(Debug)]
39struct CommandCapture {
40 success: bool,
41 exit_code: Option<i32>,
42 stdout: String,
43 stderr: String,
44 spawn_error: Option<String>,
45}
46
47#[derive(Debug, Serialize)]
48struct CommandInvocation<'a> {
49 program: &'a str,
50 args: Vec<&'a str>,
51}
52
53#[derive(Debug, Serialize)]
54struct CommandArtifactPayload<'a> {
55 source: &'a str,
56 command: CommandInvocation<'a>,
57 success: bool,
58 exit_code: Option<i32>,
59 stdout: String,
60 stderr: String,
61 #[serde(skip_serializing_if = "Option::is_none")]
62 spawn_error: Option<String>,
63}
64
65pub fn run(args: BundleArgs) -> i32 {
66 let output_dir = resolve_output_dir(args.output_dir.as_deref());
67 let manifest = match collect_bundle(&output_dir) {
68 Ok(manifest) => manifest,
69 Err(error) => {
70 eprintln!("agentctl debug bundle: {error}");
71 return EXIT_RUNTIME_ERROR;
72 }
73 };
74
75 let manifest_path = manifest_path_for(&output_dir);
76 let output = BundleCommandOutput {
77 manifest_path: path_to_string(&manifest_path),
78 manifest: &manifest,
79 };
80
81 match args.format {
82 BundleOutputFormat::Json => emit_json(&output),
83 BundleOutputFormat::Text => {
84 emit_text(&output);
85 EXIT_OK
86 }
87 }
88}
89
90pub fn collect_bundle(output_dir: &Path) -> Result<BundleManifest, String> {
91 std::fs::create_dir_all(output_dir).map_err(|error| {
92 format!(
93 "failed to create output directory `{}`: {error}",
94 output_dir.display()
95 )
96 })?;
97 std::fs::create_dir_all(output_dir.join(BUNDLE_ARTIFACTS_DIR)).map_err(|error| {
98 format!(
99 "failed to create artifact directory `{}`: {error}",
100 output_dir.join(BUNDLE_ARTIFACTS_DIR).display()
101 )
102 })?;
103
104 let artifacts = vec![
105 git_context::collect(output_dir),
106 macos_agent::collect(output_dir),
107 screen_record::collect(output_dir),
108 image_processing::collect(output_dir),
109 ];
110 let manifest = BundleManifest::from_artifacts(path_to_string(output_dir), artifacts);
111 let manifest_path = manifest_path_for(output_dir);
112 write_json_file(&manifest_path, &manifest).map_err(|error| {
113 format!(
114 "failed to write manifest `{}`: {error}",
115 manifest_path.display()
116 )
117 })?;
118
119 Ok(manifest)
120}
121
122pub fn resolve_output_dir(explicit: Option<&Path>) -> PathBuf {
123 if let Some(path) = explicit {
124 return path.to_path_buf();
125 }
126
127 codex_out_dir().join(DEFAULT_OUTPUT_NAMESPACE)
128}
129
130pub(crate) fn collect_command_artifact(
131 output_dir: &Path,
132 id: &'static str,
133 relative_path: &'static str,
134 program: &'static str,
135 args: &[&'static str],
136) -> BundleArtifact {
137 let capture = run_command(program, args);
138 let payload = CommandArtifactPayload {
139 source: id,
140 command: CommandInvocation {
141 program,
142 args: args.to_vec(),
143 },
144 success: capture.success,
145 exit_code: capture.exit_code,
146 stdout: capture.stdout.clone(),
147 stderr: capture.stderr.clone(),
148 spawn_error: capture.spawn_error.clone(),
149 };
150
151 let normalized_relative_path = normalize_relative_path(relative_path);
152 let mut issues = Vec::new();
153 if let Err(error) = write_json_artifact(output_dir, &normalized_relative_path, &payload) {
154 issues.push(format!("failed to persist command artifact: {error}"));
155 }
156
157 if !capture.success {
158 issues.push(command_failure_message(program, &capture));
159 }
160
161 if issues.is_empty() {
162 BundleArtifact::collected(id, normalized_relative_path)
163 } else {
164 BundleArtifact::failed(id, normalized_relative_path, issues.join("; "))
165 }
166}
167
168pub(crate) fn write_json_artifact<T: Serialize>(
169 output_dir: &Path,
170 relative_path: &str,
171 payload: &T,
172) -> Result<(), String> {
173 let path = output_dir.join(relative_path);
174 write_json_file(&path, payload).map_err(|error| format!("{}: {error}", path.display()))
175}
176
177pub(crate) fn normalize_relative_path(path: &str) -> String {
178 path.trim_start_matches("./").replace('\\', "/")
179}
180
181fn emit_json(output: &BundleCommandOutput<'_>) -> i32 {
182 match serde_json::to_string_pretty(output) {
183 Ok(encoded) => {
184 println!("{encoded}");
185 EXIT_OK
186 }
187 Err(error) => {
188 eprintln!("agentctl debug bundle: failed to render json output: {error}");
189 EXIT_RUNTIME_ERROR
190 }
191 }
192}
193
194fn emit_text(output: &BundleCommandOutput<'_>) {
195 println!("manifest_path: {}", output.manifest_path);
196 println!("schema_version: {}", output.manifest.schema_version);
197 println!("manifest_version: {}", output.manifest.manifest_version);
198 println!("output_dir: {}", output.manifest.output_dir);
199 println!("partial_failure: {}", output.manifest.partial_failure);
200 println!(
201 "summary: total={} collected={} failed={}",
202 output.manifest.summary.total_artifacts,
203 output.manifest.summary.collected,
204 output.manifest.summary.failed
205 );
206 println!("artifacts:");
207 for artifact in &output.manifest.artifacts {
208 let line = json!({
209 "id": artifact.id,
210 "path": artifact.path,
211 "status": artifact.status,
212 "error": artifact.error,
213 });
214 println!("- {}", line);
215 }
216}
217
218fn run_command(program: &str, args: &[&str]) -> CommandCapture {
219 match Command::new(program).args(args).output() {
220 Ok(output) => CommandCapture {
221 success: output.status.success(),
222 exit_code: output.status.code(),
223 stdout: String::from_utf8_lossy(&output.stdout).to_string(),
224 stderr: String::from_utf8_lossy(&output.stderr).to_string(),
225 spawn_error: None,
226 },
227 Err(error) => CommandCapture {
228 success: false,
229 exit_code: None,
230 stdout: String::new(),
231 stderr: String::new(),
232 spawn_error: Some(error.to_string()),
233 },
234 }
235}
236
237fn command_failure_message(program: &str, capture: &CommandCapture) -> String {
238 if let Some(spawn_error) = capture.spawn_error.as_deref() {
239 return format!("failed to launch `{program}`: {spawn_error}");
240 }
241
242 let mut message = match capture.exit_code {
243 Some(code) => format!("`{program}` exited with status code {code}"),
244 None => format!("`{program}` terminated without an exit code"),
245 };
246
247 let stderr_excerpt = capture.stderr.trim();
248 if !stderr_excerpt.is_empty() {
249 message.push_str(": ");
250 message.push_str(stderr_excerpt.replace('\n', " ").as_str());
251 }
252
253 message
254}
255
256fn write_json_file<T: Serialize>(path: &Path, payload: &T) -> Result<(), String> {
257 if let Some(parent) = path.parent() {
258 std::fs::create_dir_all(parent).map_err(|error| {
259 format!(
260 "failed to create parent dir `{}`: {error}",
261 parent.display()
262 )
263 })?;
264 }
265 let body = serde_json::to_vec_pretty(payload)
266 .map_err(|error| format!("failed to serialize json payload: {error}"))?;
267 std::fs::write(path, body).map_err(|error| format!("failed to write file: {error}"))
268}
269
270fn manifest_path_for(output_dir: &Path) -> PathBuf {
271 output_dir.join(BUNDLE_MANIFEST_FILE_NAME)
272}
273
274fn path_to_string(path: &Path) -> String {
275 path.to_string_lossy().to_string()
276}
277
278fn codex_out_dir() -> PathBuf {
279 if let Ok(codex_home) = std::env::var("CODEX_HOME") {
280 return PathBuf::from(codex_home).join("out");
281 }
282 if let Some(home) = std::env::var_os("HOME") {
283 return PathBuf::from(home).join(".codex").join("out");
284 }
285 PathBuf::from(".codex").join("out")
286}