Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,8 +93,8 @@ codex-profiles load --label work --force
<td>Save current <code>auth.json</code><br/>Optional label</td>
</tr>
<tr>
<td width="43%"><code>codex-profiles load</code><br/><code>(--label &lt;name&gt; | --id &lt;profile-id&gt;)</code><br/><code>[--force]</code></td>
<td>Load a saved profile<br/>Choose a target profile and force when needed</td>
<td width="43%"><code>codex-profiles load</code><br/><code>(--label &lt;name&gt; | --id &lt;profile-id&gt;)</code><br/><code>[--force] [--with-status]</code></td>
<td>Load a saved profile<br/>Optionally show status after loading</td>
</tr>
<tr>
<td width="43%"><code>codex-profiles list</code><br/><code>[--show-id] [--json]</code></td>
Expand Down
3 changes: 3 additions & 0 deletions src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,9 @@ pub enum Commands {
/// Continue without saving the current unsaved profile first
#[arg(long)]
force: bool,
/// Show status for the loaded profile after a successful load
#[arg(long)]
with_status: bool,
},
/// List saved profiles
List {
Expand Down
7 changes: 6 additions & 1 deletion src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,12 @@ fn run(cli: Cli) -> Result<(), String> {

match cli.command {
Commands::Save { label } => save_profile(&paths, label, json),
Commands::Load { label, id, force } => load_profile(&paths, label, id, force, json),
Commands::Load {
label,
id,
force,
with_status,
} => load_profile(&paths, label, id, force, with_status, json),
Commands::List { show_id } => list_profiles(&paths, json, show_id),
Commands::Export { label, id, output } => export_profiles(&paths, label, id, output, json),
Commands::Import { input } => import_profiles(&paths, input, json),
Expand Down
117 changes: 84 additions & 33 deletions src/profiles.rs
Original file line number Diff line number Diff line change
Expand Up @@ -389,6 +389,7 @@ pub fn load_profile(
label: Option<String>,
id: Option<String>,
force: bool,
with_status: bool,
json: bool,
) -> Result<(), String> {
let use_color_err = use_color_stderr();
Expand Down Expand Up @@ -464,15 +465,25 @@ pub fn load_profile(
label.clone(),
);
store.save(paths)?;
drop(store);

let mut profile_json = serde_json::json!({
"id": selected_id,
"label": label,
});
if json && with_status {
match current_status_json_value(paths) {
Ok(status) => {
profile_json["status"] = status;
}
Err(err) => {
profile_json["status_error"] = serde_json::Value::String(normalize_error(&err));
}
}
}

if json {
let result = CommandResultJson::success(
"load",
serde_json::json!({
"id": selected_id,
"label": label,
}),
);
let result = CommandResultJson::success("load", profile_json);
result.print()?;
return Ok(());
}
Expand All @@ -482,6 +493,13 @@ pub fn load_profile(
use_color_out,
);
print_output_block(&message);
if with_status && let Err(err) = loaded_profile_status(paths) {
let message = format!(
"Loaded profile, but status retrieval failed: {}",
normalize_error(&err)
);
eprintln!("{}", format_warning(&message, use_color_err));
}
Ok(())
}

Expand Down Expand Up @@ -634,6 +652,25 @@ pub fn status_profiles(
return status_selected_profile(paths, label.as_deref(), id.as_deref(), json);
}

status_current_profile(paths, json)
}

fn status_current_profile(paths: &Paths, json: bool) -> Result<(), String> {
let (current_entry, ctx) = current_status_entry(paths, json)?;
if json {
return print_current_status_json(current_entry);
}
if let Some(entry) = current_entry {
let lines = render_entries(std::slice::from_ref(&entry), &ctx, false);
print_output_block(&lines.join("\n"));
} else {
let message = format_no_profiles(paths, ctx.use_color);
print_output_block(&message);
}
Ok(())
}

fn current_status_entry(paths: &Paths, json: bool) -> Result<(Option<Entry>, ListCtx), String> {
let snapshot = load_snapshot(paths, false)?;
let current_saved_id = current_saved_id(paths, &snapshot.tokens);
let mut ctx = ListCtx::new(paths, true, false, false);
Expand All @@ -642,17 +679,28 @@ pub fn status_profiles(
}
let labels = &snapshot.labels;
let tokens_map = &snapshot.tokens;
let current_entry = make_current(paths, current_saved_id.as_deref(), labels, tokens_map, &ctx);
if json {
return print_current_status_json(current_entry);
}
if let Some(entry) = current_entry {
let lines = render_entries(&[entry], &ctx, false);
print_output_block(&lines.join("\n"));
} else {
let current = make_current(paths, current_saved_id.as_deref(), labels, tokens_map, &ctx);
Ok((current, ctx))
}

fn current_status_json_value(paths: &Paths) -> Result<serde_json::Value, String> {
let (current, _) = current_status_entry(paths, true)?;
let payload = current.map(status_profile_json);
serde_json::to_value(payload).map_err(|err| crate::msg1(PROFILE_ERR_SERIALIZE_INDEX, err))
}

fn loaded_profile_status(paths: &Paths) -> Result<(), String> {
let (current_entry, ctx) = current_status_entry(paths, false)?;
let Some(entry) = current_entry else {
let message = format_no_profiles(paths, ctx.use_color);
print_output_block(&message);
return Ok(());
};
if let Some(err) = entry.error_summary.as_deref() {
return Err(err.to_string());
}
let lines = render_entry_details(&entry);
print_output_block(&lines.join("\n"));
Ok(())
}

Expand Down Expand Up @@ -2001,24 +2049,7 @@ fn render_entries(entries: &[Entry], ctx: &ListCtx, allow_plain_spacing: bool) -
} else {
entry_lines.push(header);
entry_lines.push(String::new());
entry_lines.extend(entry.details.iter().flat_map(|line| {
if line.is_empty() {
vec![String::new()]
} else {
line.lines()
.enumerate()
.map(|(index, part)| {
if part.is_empty() {
String::new()
} else if index == 0 {
format!(" {part}")
} else {
part.to_string()
}
})
.collect::<Vec<_>>()
}
}));
entry_lines.extend(render_entry_details(entry));
}
lines.extend(entry_lines);
if idx + 1 < entries.len() {
Expand All @@ -2031,6 +2062,26 @@ fn render_entries(entries: &[Entry], ctx: &ListCtx, allow_plain_spacing: bool) -
lines
}

fn render_entry_details(entry: &Entry) -> Vec<String> {
let mut details = Vec::new();
for line in &entry.details {
if line.is_empty() {
details.push(String::new());
continue;
}
details.extend(line.lines().enumerate().map(|(index, part)| {
if part.is_empty() {
String::new()
} else if index == 0 {
format!(" {part}")
} else {
part.to_string()
}
}));
}
details
}

fn push_separator(lines: &mut Vec<String>, allow_plain_spacing: bool) {
if !is_plain() || allow_plain_spacing {
lines.push(String::new());
Expand Down
60 changes: 60 additions & 0 deletions tests/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1400,6 +1400,44 @@ fn ui_load_by_id_command() {
assert!(env.read_auth().contains(BETA_ACCOUNT));
}

#[test]
fn ui_load_with_status_command() {
let env = TestEnv::new();
seed_profiles(&env);
seed_alpha(&env);
let usage_body = r#"{"rate_limit":{"primary_window":{"used_percent":20,"limit_window_seconds":18000,"reset_at":2000000000}}}"#;
let (usage_addr, usage_handle) = start_usage_server(usage_body, 1).expect("usage server");
env.write_config(&format!("http://{usage_addr}/backend-api"));

let output = env.run(&["load", "--label", "beta", "--with-status"]);
assert!(output.contains("Loaded profile"));
assert!(output.contains(BETA_EMAIL));
assert_eq!(output.matches(BETA_EMAIL).count(), 1, "{output}");
assert!(!output.contains(ALPHA_EMAIL));
assert!(output.contains("80% left"), "{output}");
assert!(env.read_auth().contains(BETA_ACCOUNT));

let _ = usage_handle.join();
}

#[test]
fn ui_load_with_status_keeps_load_success_when_usage_fails() {
let env = TestEnv::new();
seed_profiles(&env);
seed_alpha(&env);
env.write_config("http://127.0.0.1:1/backend-api");

let output = env.run_output(&["load", "--label", "beta", "--with-status"]);
assert!(output.status.success());
let stdout = ascii_only(String::from_utf8_lossy(&output.stdout).as_ref());
let stderr = ascii_only(String::from_utf8_lossy(&output.stderr).as_ref());
assert!(stdout.contains("Loaded profile"));
assert!(stdout.contains(BETA_EMAIL));
assert!(stderr.contains("Loaded profile, but status retrieval failed"));
assert!(stderr.contains("Could not reach usage service"));
assert!(env.read_auth().contains(BETA_ACCOUNT));
}

#[test]
fn ui_load_by_id_force_skips_unsaved_prompt() {
let env = TestEnv::new();
Expand Down Expand Up @@ -2780,6 +2818,28 @@ fn json_load_returns_success_shape() {
assert!(profile.get("default").is_none());
}

#[test]
fn json_load_with_status_returns_success_shape() {
let env = TestEnv::new();
seed_alpha(&env);
env.run(&["save", "--label", "alpha"]);
let usage_body = r#"{"rate_limit":{"primary_window":{"used_percent":20,"limit_window_seconds":18000,"reset_at":2000000000}}}"#;
let (usage_addr, usage_handle) = start_usage_server(usage_body, 1).expect("usage server");
env.write_config(&format!("http://{usage_addr}/backend-api"));

let raw = env.run(&["load", "--label", "alpha", "--with-status", "--json"]);
let v = parse_json(&raw);

assert_eq!(v["command"], "load");
assert_eq!(v["success"], true);
let profile = &v["profile"];
assert_eq!(profile["label"], "alpha");
assert_eq!(profile["status"]["id"], ALPHA_ID);
assert_eq!(profile["status"]["usage"]["state"], "ok");

let _ = usage_handle.join();
}

#[test]
fn json_delete_returns_success_shape() {
let env = TestEnv::new();
Expand Down