chore: import upstream snapshot with attribution
FreeBSD Smoke / FreeBSD Smoke (x86_64) (push) Has been cancelled
CI / Quality Guardrails (push) Has been cancelled
CI / Build & Test (macos-latest) (push) Has been cancelled
CI / Build & Test (ubuntu-latest) (push) Has been cancelled
CI / Build & Test (windows-latest) (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / PowerShell Syntax (push) Has been cancelled
CI / Windows Cross-Target Check (Linux) (push) Has been cancelled
FreeBSD Smoke / FreeBSD Smoke (x86_64) (push) Has been cancelled
CI / Quality Guardrails (push) Has been cancelled
CI / Build & Test (macos-latest) (push) Has been cancelled
CI / Build & Test (ubuntu-latest) (push) Has been cancelled
CI / Build & Test (windows-latest) (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / PowerShell Syntax (push) Has been cancelled
CI / Windows Cross-Target Check (Linux) (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,201 @@
|
||||
use anyhow::Result;
|
||||
use clap::Parser;
|
||||
use jcode::id::new_id;
|
||||
use jcode::message::{Message, ToolDefinition};
|
||||
use jcode::provider::{EventStream, Provider};
|
||||
use jcode::tool::{Registry, ToolContext, ToolExecutionMode};
|
||||
use serde_json::json;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(name = "jcode-harness")]
|
||||
#[command(about = "Run a deterministic tool harness smoke test")]
|
||||
struct Args {
|
||||
/// Use an explicit working directory (defaults to a temp folder).
|
||||
#[arg(long)]
|
||||
cwd: Option<String>,
|
||||
|
||||
/// Include network-backed tools (webfetch/websearch).
|
||||
#[arg(long)]
|
||||
include_network: bool,
|
||||
}
|
||||
|
||||
struct NoopProvider;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl Provider for NoopProvider {
|
||||
async fn complete(
|
||||
&self,
|
||||
_messages: &[Message],
|
||||
_tools: &[ToolDefinition],
|
||||
_system: &str,
|
||||
_resume_session_id: Option<&str>,
|
||||
) -> Result<EventStream> {
|
||||
anyhow::bail!("Noop provider - tool harness does not invoke models.")
|
||||
}
|
||||
|
||||
fn name(&self) -> &str {
|
||||
"noop"
|
||||
}
|
||||
|
||||
fn fork(&self) -> Arc<dyn Provider> {
|
||||
Arc::new(NoopProvider)
|
||||
}
|
||||
|
||||
fn available_models_display(&self) -> Vec<String> {
|
||||
vec![]
|
||||
}
|
||||
|
||||
async fn prefetch_models(&self) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
struct ToolCase {
|
||||
name: &'static str,
|
||||
input: serde_json::Value,
|
||||
label: &'static str,
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
let args = Args::parse();
|
||||
|
||||
let workspace = if let Some(cwd) = args.cwd {
|
||||
PathBuf::from(cwd)
|
||||
} else {
|
||||
create_temp_workspace()?
|
||||
};
|
||||
|
||||
std::fs::create_dir_all(&workspace)?;
|
||||
std::env::set_current_dir(&workspace)?;
|
||||
eprintln!("Harness workspace: {}", workspace.display());
|
||||
|
||||
let provider: Arc<dyn Provider> = Arc::new(NoopProvider);
|
||||
let registry = Registry::new(provider).await;
|
||||
|
||||
let session_id = new_id("harness");
|
||||
let base_ctx = ToolContext {
|
||||
session_id: session_id.clone(),
|
||||
message_id: session_id.clone(),
|
||||
tool_call_id: String::new(),
|
||||
working_dir: Some(workspace.clone()),
|
||||
stdin_request_tx: None,
|
||||
graceful_shutdown_signal: None,
|
||||
execution_mode: ToolExecutionMode::Direct,
|
||||
};
|
||||
|
||||
let mut cases = Vec::new();
|
||||
cases.push(ToolCase {
|
||||
name: "write",
|
||||
label: "write sample.txt",
|
||||
input: json!({"file_path": "sample.txt", "content": "alpha\nbeta\n"}),
|
||||
});
|
||||
cases.push(ToolCase {
|
||||
name: "read",
|
||||
label: "read sample.txt",
|
||||
input: json!({"file_path": "sample.txt"}),
|
||||
});
|
||||
cases.push(ToolCase {
|
||||
name: "edit",
|
||||
label: "edit sample.txt (alpha -> alpha1)",
|
||||
input: json!({"file_path": "sample.txt", "old_string": "alpha", "new_string": "alpha1"}),
|
||||
});
|
||||
cases.push(ToolCase {
|
||||
name: "multiedit",
|
||||
label: "multiedit sample.txt",
|
||||
input: json!({
|
||||
"file_path": "sample.txt",
|
||||
"edits": [
|
||||
{"old_string": "alpha1", "new_string": "alpha2"},
|
||||
{"old_string": "beta", "new_string": "beta1"}
|
||||
]
|
||||
}),
|
||||
});
|
||||
cases.push(ToolCase {
|
||||
name: "patch",
|
||||
label: "patch sample.txt",
|
||||
input: json!({"patch_text": "--- a/sample.txt\n+++ b/sample.txt\n@@ -1,2 +1,3 @@\n alpha2\n beta1\n+gamma\n"}),
|
||||
});
|
||||
cases.push(ToolCase {
|
||||
name: "apply_patch",
|
||||
label: "apply_patch add file",
|
||||
input: json!({"patch_text": "*** Begin Patch\n*** Add File: added.txt\n+added\n*** End Patch\n"}),
|
||||
});
|
||||
cases.push(ToolCase {
|
||||
name: "ls",
|
||||
label: "ls .",
|
||||
input: json!({"path": "."}),
|
||||
});
|
||||
cases.push(ToolCase {
|
||||
name: "bash",
|
||||
label: "bash pwd",
|
||||
input: json!({"command": "pwd"}),
|
||||
});
|
||||
cases.push(ToolCase {
|
||||
name: "invalid",
|
||||
label: "invalid tool call",
|
||||
input: json!({"tool": "unknown", "error": "missing required field"}),
|
||||
});
|
||||
cases.push(ToolCase {
|
||||
name: "todo",
|
||||
label: "todo write",
|
||||
input: json!({"todos": [{"content": "harness task", "status": "pending", "priority": "low", "id": "1"}]}),
|
||||
});
|
||||
cases.push(ToolCase {
|
||||
name: "todo",
|
||||
label: "todo read",
|
||||
input: json!({}),
|
||||
});
|
||||
cases.push(ToolCase {
|
||||
name: "batch",
|
||||
label: "batch ls + read",
|
||||
input: json!({
|
||||
"tool_calls": [
|
||||
{"tool": "ls", "parameters": {"path": "."}},
|
||||
{"tool": "read", "parameters": {"file_path": "sample.txt"}}
|
||||
]
|
||||
}),
|
||||
});
|
||||
|
||||
if args.include_network {
|
||||
cases.push(ToolCase {
|
||||
name: "webfetch",
|
||||
label: "webfetch example.com",
|
||||
input: json!({"url": "https://example.com", "format": "text"}),
|
||||
});
|
||||
cases.push(ToolCase {
|
||||
name: "websearch",
|
||||
label: "websearch rust async",
|
||||
input: json!({"query": "rust async await"}),
|
||||
});
|
||||
}
|
||||
|
||||
for (idx, case) in cases.iter().enumerate() {
|
||||
let ctx = ToolContext {
|
||||
tool_call_id: format!("harness-{}", idx + 1),
|
||||
..base_ctx.clone()
|
||||
};
|
||||
println!("\n== {} ({}) ==", case.name, case.label);
|
||||
match registry.execute(case.name, case.input.clone(), ctx).await {
|
||||
Ok(output) => {
|
||||
if let Some(title) = output.title {
|
||||
println!("[title] {}", title);
|
||||
}
|
||||
println!("{}", output.output);
|
||||
}
|
||||
Err(err) => {
|
||||
println!("[error] {}", err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn create_temp_workspace() -> Result<PathBuf> {
|
||||
let mut path = std::env::temp_dir();
|
||||
path.push(format!("jcode-harness-{}", new_id("run")));
|
||||
Ok(path)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,64 @@
|
||||
use anyhow::{Context, Result, anyhow};
|
||||
use std::env;
|
||||
|
||||
fn usage() -> &'static str {
|
||||
"usage: cargo run --features dev-bins --bin mermaid_side_panel_probe -- <mermaid-file> [--pane-width N] [--pane-height N] [--font-width N] [--font-height N] [--left]"
|
||||
}
|
||||
|
||||
fn parse_u16_arg(args: &mut std::vec::IntoIter<String>, flag: &str) -> Result<u16> {
|
||||
let value = args
|
||||
.next()
|
||||
.ok_or_else(|| anyhow!("missing value for {flag}"))?;
|
||||
value
|
||||
.parse::<u16>()
|
||||
.with_context(|| format!("invalid integer for {flag}: {value}"))
|
||||
}
|
||||
|
||||
fn main() -> Result<()> {
|
||||
let mut args = env::args().skip(1).collect::<Vec<_>>().into_iter();
|
||||
let mut path: Option<String> = None;
|
||||
let mut pane_width: u16 = 36;
|
||||
let mut pane_height: u16 = 30;
|
||||
let mut font_width: u16 = 8;
|
||||
let mut font_height: u16 = 16;
|
||||
let mut centered = true;
|
||||
|
||||
while let Some(arg) = args.next() {
|
||||
match arg.as_str() {
|
||||
"--pane-width" => pane_width = parse_u16_arg(&mut args, "--pane-width")?,
|
||||
"--pane-height" => pane_height = parse_u16_arg(&mut args, "--pane-height")?,
|
||||
"--font-width" => font_width = parse_u16_arg(&mut args, "--font-width")?,
|
||||
"--font-height" => font_height = parse_u16_arg(&mut args, "--font-height")?,
|
||||
"--left" => centered = false,
|
||||
"--help" | "-h" => {
|
||||
println!("{}", usage());
|
||||
return Ok(());
|
||||
}
|
||||
value if value.starts_with('-') => {
|
||||
return Err(anyhow!("unknown flag: {value}\n{}", usage()));
|
||||
}
|
||||
value => {
|
||||
if path.is_some() {
|
||||
return Err(anyhow!(
|
||||
"only one mermaid file path is supported\n{}",
|
||||
usage()
|
||||
));
|
||||
}
|
||||
path = Some(value.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let path = path.ok_or_else(|| anyhow!("missing mermaid file path\n{}", usage()))?;
|
||||
let content = std::fs::read_to_string(&path)
|
||||
.with_context(|| format!("failed to read mermaid file: {path}"))?;
|
||||
let probe = jcode::tui::debug_probe_side_panel_mermaid(
|
||||
&content,
|
||||
pane_width,
|
||||
pane_height,
|
||||
Some((font_width, font_height)),
|
||||
centered,
|
||||
)?;
|
||||
println!("{}", serde_json::to_string_pretty(&probe)?);
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
use clap::{Parser, ValueEnum};
|
||||
use jcode::message::{ContentBlock, Role};
|
||||
use jcode::process_memory;
|
||||
use jcode::session::Session;
|
||||
use jcode::side_panel::{
|
||||
SidePanelPage, SidePanelPageFormat, SidePanelPageSource, SidePanelSnapshot,
|
||||
};
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
#[command(about = "Benchmark heavy-session memory attribution and process footprint")]
|
||||
struct Args {
|
||||
/// Scenario source
|
||||
#[arg(long, value_enum, default_value = "synthetic")]
|
||||
scenario: Scenario,
|
||||
|
||||
/// Saved session id or path (required for --scenario saved)
|
||||
#[arg(long)]
|
||||
session: Option<String>,
|
||||
|
||||
/// Memory mode to benchmark
|
||||
#[arg(long, value_enum, default_value = "local")]
|
||||
mode: BenchMode,
|
||||
|
||||
/// Synthetic turns to generate
|
||||
#[arg(long, default_value_t = 24)]
|
||||
turns: usize,
|
||||
|
||||
/// Synthetic tool input size in KiB per turn
|
||||
#[arg(long, default_value_t = 4)]
|
||||
tool_input_kib: usize,
|
||||
|
||||
/// Synthetic tool output size in KiB per turn
|
||||
#[arg(long, default_value_t = 48)]
|
||||
tool_output_kib: usize,
|
||||
|
||||
/// Synthetic side-panel page count
|
||||
#[arg(long, default_value_t = 0)]
|
||||
side_panel_pages: usize,
|
||||
|
||||
/// Synthetic side-panel content size in KiB per page
|
||||
#[arg(long, default_value_t = 32)]
|
||||
side_panel_page_kib: usize,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, ValueEnum)]
|
||||
enum Scenario {
|
||||
Synthetic,
|
||||
Saved,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, ValueEnum)]
|
||||
enum BenchMode {
|
||||
/// Current local steady state: canonical session + display, provider view only transient
|
||||
Local,
|
||||
/// Simulated pre-refactor duplicate steady state: keep a resident provider copy too
|
||||
Duplicated,
|
||||
}
|
||||
|
||||
fn main() -> anyhow::Result<()> {
|
||||
let args = Args::parse();
|
||||
|
||||
let process_before = process_memory::snapshot_with_source("bench:session-memory:before");
|
||||
let session = load_or_build_session(&args)?;
|
||||
let display_messages = jcode::tui::display_messages_from_session(&session);
|
||||
let side_panel = build_side_panel(&args);
|
||||
|
||||
let resident_provider_messages = match args.mode {
|
||||
BenchMode::Local => Vec::new(),
|
||||
BenchMode::Duplicated => session.messages_for_provider_uncached(),
|
||||
};
|
||||
|
||||
let process_after_build =
|
||||
process_memory::snapshot_with_source("bench:session-memory:after-build");
|
||||
|
||||
let materialized_provider_messages = match args.mode {
|
||||
BenchMode::Local => session.messages_for_provider_uncached(),
|
||||
BenchMode::Duplicated => resident_provider_messages.clone(),
|
||||
};
|
||||
let provider_view_source = match args.mode {
|
||||
BenchMode::Local => "session_materialized",
|
||||
BenchMode::Duplicated => "resident_ui",
|
||||
};
|
||||
|
||||
let client_memory = jcode::tui::transcript_memory_profile(
|
||||
&session,
|
||||
&resident_provider_messages,
|
||||
&materialized_provider_messages,
|
||||
provider_view_source,
|
||||
&display_messages,
|
||||
&side_panel,
|
||||
);
|
||||
|
||||
let process_after_profile =
|
||||
process_memory::snapshot_with_source("bench:session-memory:after-profile");
|
||||
|
||||
drop(materialized_provider_messages);
|
||||
let process_after_drop_transient =
|
||||
process_memory::snapshot_with_source("bench:session-memory:after-drop-transient");
|
||||
|
||||
let payload = serde_json::json!({
|
||||
"scenario": match args.scenario {
|
||||
Scenario::Synthetic => "synthetic",
|
||||
Scenario::Saved => "saved",
|
||||
},
|
||||
"mode": match args.mode {
|
||||
BenchMode::Local => "local",
|
||||
BenchMode::Duplicated => "duplicated",
|
||||
},
|
||||
"config": {
|
||||
"turns": args.turns,
|
||||
"tool_input_kib": args.tool_input_kib,
|
||||
"tool_output_kib": args.tool_output_kib,
|
||||
"side_panel_pages": args.side_panel_pages,
|
||||
"side_panel_page_kib": args.side_panel_page_kib,
|
||||
"session": args.session,
|
||||
},
|
||||
"counts": {
|
||||
"session_messages": session.messages.len(),
|
||||
"display_messages": display_messages.len(),
|
||||
"resident_provider_messages": resident_provider_messages.len(),
|
||||
"side_panel_pages": side_panel.pages.len(),
|
||||
},
|
||||
"process": {
|
||||
"before": process_before,
|
||||
"after_build": process_after_build,
|
||||
"after_profile": process_after_profile,
|
||||
"after_drop_transient": process_after_drop_transient,
|
||||
},
|
||||
"client_memory": client_memory,
|
||||
});
|
||||
|
||||
println!("{}", serde_json::to_string_pretty(&payload)?);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn load_or_build_session(args: &Args) -> anyhow::Result<Session> {
|
||||
match args.scenario {
|
||||
Scenario::Synthetic => Ok(build_synthetic_session(
|
||||
args.turns,
|
||||
args.tool_input_kib,
|
||||
args.tool_output_kib,
|
||||
)),
|
||||
Scenario::Saved => {
|
||||
let Some(value) = args.session.as_deref() else {
|
||||
anyhow::bail!("--session is required with --scenario saved");
|
||||
};
|
||||
let path = std::path::Path::new(value);
|
||||
if path.exists() {
|
||||
Session::load_from_path(path)
|
||||
} else {
|
||||
Session::load(value)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn build_synthetic_session(turns: usize, tool_input_kib: usize, tool_output_kib: usize) -> Session {
|
||||
let mut session = Session::create_with_id(
|
||||
format!("session_memory_bench_{}", std::process::id()),
|
||||
None,
|
||||
Some("session memory bench".to_string()),
|
||||
);
|
||||
let tool_input_bytes = tool_input_kib * 1024;
|
||||
let tool_output_bytes = tool_output_kib * 1024;
|
||||
|
||||
for idx in 0..turns {
|
||||
session.add_message(
|
||||
Role::User,
|
||||
vec![text_block(make_blob(
|
||||
&format!("user turn {idx} - "),
|
||||
768 + (idx % 5) * 64,
|
||||
))],
|
||||
);
|
||||
session.add_message(
|
||||
Role::Assistant,
|
||||
vec![
|
||||
text_block(make_blob(
|
||||
&format!("assistant summary {idx} - "),
|
||||
1024 + (idx % 7) * 96,
|
||||
)),
|
||||
ContentBlock::ToolUse {
|
||||
id: format!("tool_{idx}"),
|
||||
name: "bash".to_string(),
|
||||
input: serde_json::json!({
|
||||
"command": make_blob(&format!("printf 'turn {idx}' && # "), tool_input_bytes),
|
||||
"description": format!("Synthetic tool call {idx}"),
|
||||
}), thought_signature: None, },
|
||||
],
|
||||
);
|
||||
session.add_message(
|
||||
Role::User,
|
||||
vec![ContentBlock::ToolResult {
|
||||
tool_use_id: format!("tool_{idx}"),
|
||||
content: make_blob(&format!("tool output {idx} - "), tool_output_bytes),
|
||||
is_error: None,
|
||||
}],
|
||||
);
|
||||
}
|
||||
|
||||
session
|
||||
}
|
||||
|
||||
fn build_side_panel(args: &Args) -> SidePanelSnapshot {
|
||||
if args.side_panel_pages == 0 {
|
||||
return SidePanelSnapshot::default();
|
||||
}
|
||||
|
||||
let mut pages = Vec::with_capacity(args.side_panel_pages);
|
||||
for idx in 0..args.side_panel_pages {
|
||||
pages.push(SidePanelPage {
|
||||
id: format!("bench_page_{idx}"),
|
||||
title: format!("Bench Page {idx}"),
|
||||
file_path: format!("/tmp/bench_page_{idx}.md"),
|
||||
format: SidePanelPageFormat::Markdown,
|
||||
source: SidePanelPageSource::Managed,
|
||||
content: make_blob(
|
||||
&format!("# Bench Page {idx}\n\n"),
|
||||
args.side_panel_page_kib * 1024,
|
||||
),
|
||||
updated_at_ms: idx as u64,
|
||||
});
|
||||
}
|
||||
|
||||
SidePanelSnapshot {
|
||||
focused_page_id: pages.first().map(|page| page.id.clone()),
|
||||
pages,
|
||||
}
|
||||
}
|
||||
|
||||
fn text_block(text: String) -> ContentBlock {
|
||||
ContentBlock::Text {
|
||||
text,
|
||||
cache_control: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn make_blob(prefix: &str, target_len: usize) -> String {
|
||||
if target_len <= prefix.len() {
|
||||
return prefix[..target_len].to_string();
|
||||
}
|
||||
let mut out = String::with_capacity(target_len);
|
||||
out.push_str(prefix);
|
||||
const CHUNK: &str = "abcdefghijklmnopqrstuvwxyz0123456789 ";
|
||||
while out.len() < target_len {
|
||||
out.push_str(CHUNK);
|
||||
}
|
||||
out.truncate(target_len);
|
||||
out
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
use futures::StreamExt;
|
||||
use jcode::message::{ContentBlock, Message, ToolDefinition};
|
||||
use jcode::provider::Provider;
|
||||
use jcode_provider_claude_cli_runtime::ClaudeProvider;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
println!("Testing deprecated legacy Claude CLI provider...");
|
||||
let provider = ClaudeProvider::new();
|
||||
|
||||
let messages = vec![Message {
|
||||
role: jcode::message::Role::User,
|
||||
content: vec![ContentBlock::Text {
|
||||
text: "Say hello in exactly 5 words.".to_string(),
|
||||
cache_control: None,
|
||||
}],
|
||||
timestamp: None,
|
||||
tool_duration_ms: None,
|
||||
}];
|
||||
|
||||
let tools: Vec<ToolDefinition> = vec![];
|
||||
let system = "You are a helpful assistant.";
|
||||
|
||||
println!("Sending request...");
|
||||
let mut stream = provider.complete(&messages, &tools, system, None).await?;
|
||||
|
||||
println!("Response:");
|
||||
while let Some(event) = stream.next().await {
|
||||
match event {
|
||||
Ok(e) => print!("{:?} ", e),
|
||||
Err(e) => eprintln!("Error: {}", e),
|
||||
}
|
||||
}
|
||||
println!("\nDone!");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,122 @@
|
||||
use super::{SidePanelSource, make_text};
|
||||
use anyhow::{Context, Result};
|
||||
use jcode::side_panel::{
|
||||
SidePanelPage, SidePanelPageFormat, SidePanelPageSource, SidePanelSnapshot,
|
||||
};
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
pub(super) fn make_bench_file(idx: usize, approx_len: usize) -> Result<PathBuf> {
|
||||
let base_dir = std::env::temp_dir().join("jcode_tui_bench");
|
||||
fs::create_dir_all(&base_dir).with_context(|| {
|
||||
format!(
|
||||
"failed to create TUI bench directory {}",
|
||||
base_dir.display()
|
||||
)
|
||||
})?;
|
||||
let file_path = base_dir.join(format!("file_diff_{idx}.rs"));
|
||||
|
||||
let mut content = String::from("fn bench_file() {\n");
|
||||
let repeated = make_text(approx_len);
|
||||
for line_idx in 0..120 {
|
||||
if line_idx == idx % 120 {
|
||||
content.push_str(&format!(
|
||||
" let line_{line_idx} = \"target line {idx}\";\n"
|
||||
));
|
||||
} else {
|
||||
content.push_str(&format!(" let line_{line_idx} = \"{}\";\n", repeated));
|
||||
}
|
||||
}
|
||||
content.push_str("}\n");
|
||||
|
||||
fs::write(&file_path, content)
|
||||
.with_context(|| format!("failed to write bench file {}", file_path.display()))?;
|
||||
Ok(file_path)
|
||||
}
|
||||
|
||||
pub(super) fn make_bench_side_panel(
|
||||
approx_len: usize,
|
||||
source: SidePanelSource,
|
||||
mermaid_count: usize,
|
||||
bench_file_paths: &mut Vec<PathBuf>,
|
||||
) -> Result<SidePanelSnapshot> {
|
||||
let content = make_side_panel_content(approx_len, mermaid_count.max(1));
|
||||
let source_kind = match source {
|
||||
SidePanelSource::Managed => SidePanelPageSource::Managed,
|
||||
SidePanelSource::LinkedFile => SidePanelPageSource::LinkedFile,
|
||||
};
|
||||
|
||||
let file_path = match source {
|
||||
SidePanelSource::Managed => std::env::temp_dir()
|
||||
.join("jcode_tui_bench")
|
||||
.join("side_panel_managed.md"),
|
||||
SidePanelSource::LinkedFile => std::env::temp_dir()
|
||||
.join("jcode_tui_bench")
|
||||
.join("side_panel_linked.md"),
|
||||
};
|
||||
fs::create_dir_all(
|
||||
file_path
|
||||
.parent()
|
||||
.unwrap_or_else(|| std::path::Path::new(".")),
|
||||
)
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"failed to create side-panel bench directory for {}",
|
||||
file_path.display()
|
||||
)
|
||||
})?;
|
||||
fs::write(&file_path, &content).with_context(|| {
|
||||
format!(
|
||||
"failed to write side-panel bench file {}",
|
||||
file_path.display()
|
||||
)
|
||||
})?;
|
||||
bench_file_paths.push(file_path.clone());
|
||||
|
||||
Ok(SidePanelSnapshot {
|
||||
focused_page_id: Some("bench_side_panel".to_string()),
|
||||
pages: vec![SidePanelPage {
|
||||
id: "bench_side_panel".to_string(),
|
||||
title: format!(
|
||||
"Bench Side Panel ({})",
|
||||
match source {
|
||||
SidePanelSource::Managed => "managed",
|
||||
SidePanelSource::LinkedFile => "linked-file",
|
||||
}
|
||||
),
|
||||
file_path: file_path.display().to_string(),
|
||||
format: SidePanelPageFormat::Markdown,
|
||||
source: source_kind,
|
||||
content,
|
||||
updated_at_ms: 1,
|
||||
}],
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn make_side_panel_refresh_content(generation: usize) -> String {
|
||||
format!(
|
||||
"# Linked Refresh Benchmark\n\nGeneration: {generation}\n\n{}\n\n```mermaid\nflowchart TD\n A[Refresh {generation}] --> B[Read file]\n B --> C[Update snapshot]\n C --> D[Reuse width cache]\n```\n",
|
||||
make_text(360)
|
||||
)
|
||||
}
|
||||
|
||||
fn make_side_panel_content(approx_len: usize, mermaid_count: usize) -> String {
|
||||
let mut out = String::new();
|
||||
out.push_str("# Side Panel Benchmark\n\n");
|
||||
for idx in 0..mermaid_count {
|
||||
out.push_str(&format!("## Section {}\n\n", idx + 1));
|
||||
out.push_str(&make_text(approx_len));
|
||||
out.push_str("\n\n");
|
||||
out.push_str("```mermaid\nflowchart TD\n");
|
||||
out.push_str(&format!(
|
||||
" A{idx}[Start {idx}] --> B{idx}[Load content]\n B{idx} --> C{idx}{{Scroll?}}\n C{idx} -- Yes --> D{idx}[Render viewport]\n C{idx} -- No --> E{idx}[Reuse cache]\n D{idx} --> F{idx}[Done]\n E{idx} --> F{idx}[Done]\n"
|
||||
));
|
||||
out.push_str("```\n\n");
|
||||
out.push_str("- scroll interaction\n- markdown wrapping\n- image viewport rendering\n\n");
|
||||
}
|
||||
out.push_str("## Final Notes\n\n");
|
||||
for idx in 0..24 {
|
||||
out.push_str(&format!("- Bench line {:02}: {}\n", idx + 1, make_text(64)));
|
||||
}
|
||||
out
|
||||
}
|
||||
+1188
File diff suppressed because it is too large
Load Diff
+991
@@ -0,0 +1,991 @@
|
||||
use clap::{Parser, Subcommand, ValueEnum};
|
||||
|
||||
use super::provider_init::ProviderChoice;
|
||||
|
||||
#[derive(Copy, Clone, Debug, Eq, PartialEq, ValueEnum)]
|
||||
pub(crate) enum TranscriptModeArg {
|
||||
Insert,
|
||||
Append,
|
||||
Replace,
|
||||
Send,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, Eq, PartialEq, ValueEnum)]
|
||||
pub(crate) enum GoogleAccessTierArg {
|
||||
Full,
|
||||
Readonly,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, Eq, PartialEq, ValueEnum)]
|
||||
pub(crate) enum ProviderAuthArg {
|
||||
/// Send the API key as Authorization: Bearer <key> (OpenAI-compatible default)
|
||||
Bearer,
|
||||
/// Send the API key in an API-key header (defaults to api-key)
|
||||
ApiKey,
|
||||
/// Do not send authentication, useful for localhost model servers
|
||||
None,
|
||||
}
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
#[command(name = "jcode")]
|
||||
#[command(version = jcode_build_meta::VERSION)]
|
||||
#[command(about = "J-Code: A coding agent using Claude Max or ChatGPT Pro subscriptions")]
|
||||
pub(crate) struct Args {
|
||||
/// Provider to use (jcode, claude, openai, openai-api, openrouter, azure, opencode, opencode-go, zai, 302ai, baseten, cortecs, comtegra, deepseek, fpt, firmware, huggingface, moonshotai, nebius, scaleway, stackit, groq, mistral, perplexity, togetherai, deepinfra, xai, nvidia-nim, lmstudio, ollama, chutes, cerebras, alibaba-coding-plan, openai-compatible, cursor, copilot, gemini, antigravity, google, or auto-detect)
|
||||
#[arg(short, long, default_value = "auto", global = true)]
|
||||
pub(crate) provider: ProviderChoice,
|
||||
|
||||
/// Working directory for the local client process
|
||||
#[arg(short = 'C', long, global = true)]
|
||||
pub(crate) cwd: Option<String>,
|
||||
|
||||
/// Working directory to send to a remote server when using --socket
|
||||
#[arg(long, global = true)]
|
||||
pub(crate) remote_working_dir: Option<String>,
|
||||
|
||||
/// Skip the automatic update check
|
||||
#[arg(long, global = true)]
|
||||
pub(crate) no_update: bool,
|
||||
|
||||
/// Auto-update when new version is available (default: true for release builds)
|
||||
#[arg(long, global = true, default_value = "true")]
|
||||
pub(crate) auto_update: bool,
|
||||
|
||||
/// Log tool inputs/outputs and token usage to stderr
|
||||
#[arg(long, global = true)]
|
||||
pub(crate) trace: bool,
|
||||
|
||||
/// Suppress non-error CLI/status output for scripting and wrappers
|
||||
#[arg(long, global = true)]
|
||||
pub(crate) quiet: bool,
|
||||
|
||||
/// Resume a session by ID, or list sessions if no ID provided
|
||||
#[arg(long, global = true, num_args = 0..=1, default_missing_value = "")]
|
||||
pub(crate) resume: Option<String>,
|
||||
|
||||
/// Internal: launched as a freshly spawned window, so skip heavy local resume bootstrap.
|
||||
#[arg(long, global = true, hide = true)]
|
||||
pub(crate) fresh_spawn: bool,
|
||||
|
||||
/// Disable auto-detection of jcode repository and self-dev mode
|
||||
#[arg(long, global = true)]
|
||||
pub(crate) no_selfdev: bool,
|
||||
|
||||
/// Custom socket path for server/client communication
|
||||
#[arg(long, global = true)]
|
||||
pub(crate) socket: Option<String>,
|
||||
|
||||
/// Enable debug socket (broadcasts all TUI state changes)
|
||||
#[arg(long, global = true)]
|
||||
pub(crate) debug_socket: bool,
|
||||
|
||||
/// Model to use (e.g., claude-opus-4-6, gpt-5.5)
|
||||
#[arg(short, long, global = true)]
|
||||
pub(crate) model: Option<String>,
|
||||
|
||||
/// Named provider profile from [providers.<name>] in config.toml.
|
||||
/// Implies --provider openai-compatible for OpenAI-compatible profiles.
|
||||
#[arg(long, global = true)]
|
||||
pub(crate) provider_profile: Option<String>,
|
||||
|
||||
/// Tool profile to expose to the model: full, minimal/lite, or none.
|
||||
#[arg(long, global = true)]
|
||||
pub(crate) tool_profile: Option<String>,
|
||||
|
||||
/// Comma-separated explicit allow-list of tools to expose, e.g. bash,read,write,apply_patch. Use '*' or 'all' for the unrestricted full toolset.
|
||||
#[arg(long, global = true)]
|
||||
pub(crate) tools: Option<String>,
|
||||
|
||||
/// Comma-separated list of tools to hide after applying the selected profile.
|
||||
#[arg(long, global = true)]
|
||||
pub(crate) disabled_tools: Option<String>,
|
||||
|
||||
/// Hide all built-in tools unless --tools or [tools].enabled opts tools back in.
|
||||
#[arg(long, global = true)]
|
||||
pub(crate) disable_base_tools: bool,
|
||||
|
||||
#[command(subcommand)]
|
||||
pub(crate) command: Option<Command>,
|
||||
}
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
pub(crate) enum Command {
|
||||
/// Start the agent server (background daemon)
|
||||
Serve {
|
||||
/// Internal: mark this server as temporary so it can self-clean when its owner exits.
|
||||
#[arg(long, hide = true)]
|
||||
temporary_server: bool,
|
||||
|
||||
/// Internal: owning process pid for a temporary server.
|
||||
#[arg(long, hide = true)]
|
||||
owner_pid: Option<u32>,
|
||||
|
||||
/// Internal: idle shutdown timeout in seconds for a temporary server.
|
||||
#[arg(long, hide = true)]
|
||||
temp_idle_timeout_secs: Option<u64>,
|
||||
|
||||
/// Stable display name for this server in connected clients and session pickers.
|
||||
///
|
||||
/// Useful for long-lived remote runtimes, e.g. `fabian`, `john`, or
|
||||
/// `mount-cloud-fabian`. Unsafe characters are normalized before use.
|
||||
#[arg(long)]
|
||||
server_name: Option<String>,
|
||||
},
|
||||
|
||||
/// Run as an Agent Client Protocol (ACP) adapter backed by the Jcode daemon
|
||||
Acp,
|
||||
|
||||
/// Manage the background server daemon (e.g. `jcode server stop`).
|
||||
Server {
|
||||
#[command(subcommand)]
|
||||
action: ServerCommand,
|
||||
},
|
||||
|
||||
/// Connect to a running server
|
||||
Connect,
|
||||
|
||||
/// Run a single message and exit
|
||||
Run {
|
||||
/// Emit a machine-readable JSON result instead of streaming text
|
||||
#[arg(long, conflicts_with = "ndjson")]
|
||||
json: bool,
|
||||
|
||||
/// Emit newline-delimited JSON events while the response streams
|
||||
#[arg(long, conflicts_with = "json")]
|
||||
ndjson: bool,
|
||||
|
||||
/// The message to send
|
||||
message: String,
|
||||
},
|
||||
|
||||
/// Login to a provider via OAuth, API key, or local credentials
|
||||
Login {
|
||||
/// Provider to log in to. Equivalent to --provider for this command, e.g. `jcode login google`.
|
||||
// Distinct clap id: the global `--provider` flag also has id "provider";
|
||||
// sharing the id makes clap drop the flag inside `login` (so
|
||||
// `jcode login --provider x` errors) and propagate the global default
|
||||
// into this positional.
|
||||
#[arg(value_enum, id = "login_provider", value_name = "PROVIDER")]
|
||||
provider: Option<ProviderChoice>,
|
||||
|
||||
/// Account label for multi-account support (stored labels are auto-numbered)
|
||||
#[arg(long, short = 'a')]
|
||||
account: Option<String>,
|
||||
|
||||
/// Do not try to open a browser locally. Useful over SSH or on headless machines.
|
||||
#[arg(long, alias = "headless")]
|
||||
no_browser: bool,
|
||||
|
||||
/// Print a script-friendly auth URL and persist temporary login state for later completion.
|
||||
#[arg(long, conflicts_with_all = ["callback_url", "auth_code"])]
|
||||
print_auth_url: bool,
|
||||
|
||||
/// Complete a previously printed auth flow using a full callback URL or query string.
|
||||
#[arg(long, conflicts_with = "auth_code")]
|
||||
callback_url: Option<String>,
|
||||
|
||||
/// Complete a previously printed auth flow using a provider-issued authorization code.
|
||||
#[arg(long, conflicts_with = "callback_url")]
|
||||
auth_code: Option<String>,
|
||||
|
||||
/// Emit machine-readable JSON for script-friendly login flows.
|
||||
#[arg(long)]
|
||||
json: bool,
|
||||
|
||||
/// Resume a pending scriptable login flow that does not require callback/code input.
|
||||
#[arg(long, conflicts_with_all = ["print_auth_url", "callback_url", "auth_code"])]
|
||||
complete: bool,
|
||||
|
||||
/// Save credentials without running the post-login live provider validation.
|
||||
/// Useful for offline setup, CI, or when entering credentials before network access is available.
|
||||
#[arg(long)]
|
||||
no_validate: bool,
|
||||
|
||||
/// Gmail/Google access tier for non-interactive flows. Defaults to full.
|
||||
#[arg(long, value_enum)]
|
||||
google_access_tier: Option<GoogleAccessTierArg>,
|
||||
|
||||
/// OpenAI-compatible API base URL. Used with --provider openai-compatible/custom profiles.
|
||||
#[arg(long)]
|
||||
api_base: Option<String>,
|
||||
|
||||
/// OpenAI-compatible API key. If omitted, jcode prompts securely when needed.
|
||||
#[arg(long)]
|
||||
api_key: Option<String>,
|
||||
|
||||
/// Environment variable name to store/use for an OpenAI-compatible API key.
|
||||
#[arg(long)]
|
||||
api_key_env: Option<String>,
|
||||
},
|
||||
|
||||
/// Run in simple REPL mode (no TUI)
|
||||
Repl,
|
||||
|
||||
/// Update jcode to the latest version
|
||||
Update,
|
||||
|
||||
/// Show build/version information in human or JSON form
|
||||
Version {
|
||||
/// Emit JSON instead of plain text
|
||||
#[arg(long)]
|
||||
json: bool,
|
||||
},
|
||||
|
||||
/// Show usage limits for connected providers
|
||||
Usage {
|
||||
/// Emit JSON instead of plain text
|
||||
#[arg(long)]
|
||||
json: bool,
|
||||
},
|
||||
|
||||
/// Self-development mode: run as a canary session on the shared server
|
||||
#[command(alias = "selfdev")]
|
||||
SelfDev {
|
||||
/// Build and test a new canary version before launching
|
||||
#[arg(long)]
|
||||
build: bool,
|
||||
},
|
||||
|
||||
/// Debug socket CLI - interact with running jcode server
|
||||
Debug {
|
||||
/// Debug command to run (list, start, sessions, create_session, message, tool, state, history, etc.)
|
||||
#[arg(default_value = "help")]
|
||||
command: String,
|
||||
|
||||
/// Optional argument for the command
|
||||
#[arg(default_value = "")]
|
||||
arg: String,
|
||||
|
||||
/// Target a specific session by ID
|
||||
#[arg(short = 'S', long)]
|
||||
session: Option<String>,
|
||||
|
||||
/// Connect to specific server socket path
|
||||
#[arg(short = 's', long)]
|
||||
socket: Option<String>,
|
||||
|
||||
/// Wait for response to complete (for message command)
|
||||
#[arg(short, long)]
|
||||
wait: bool,
|
||||
},
|
||||
|
||||
/// Authentication status and validation helpers
|
||||
#[command(subcommand)]
|
||||
Auth(AuthCommand),
|
||||
|
||||
/// Provider discovery and selection helpers
|
||||
#[command(subcommand)]
|
||||
Provider(ProviderCommand),
|
||||
|
||||
/// Memory management commands
|
||||
#[command(subcommand)]
|
||||
Memory(MemoryCommand),
|
||||
|
||||
/// Session management commands
|
||||
#[command(subcommand)]
|
||||
Session(SessionCommand),
|
||||
|
||||
/// Ambient mode management
|
||||
#[command(subcommand)]
|
||||
Ambient(AmbientCommand),
|
||||
|
||||
/// Optional Jcode Cloud/Jade integration commands
|
||||
#[command(subcommand)]
|
||||
Cloud(CloudCommand),
|
||||
|
||||
/// Generate a pairing code for iOS/web client
|
||||
Pair {
|
||||
/// List paired devices instead of generating a code
|
||||
#[arg(long)]
|
||||
list: bool,
|
||||
|
||||
/// Revoke a paired device by name or ID
|
||||
#[arg(long)]
|
||||
revoke: Option<String>,
|
||||
},
|
||||
|
||||
/// Review and respond to pending ambient permission requests
|
||||
Permissions,
|
||||
|
||||
/// Inject externally transcribed text into the active Jcode TUI
|
||||
Transcript {
|
||||
/// Transcript text. If omitted, reads from stdin.
|
||||
text: Option<String>,
|
||||
|
||||
/// How to apply the transcript inside Jcode
|
||||
#[arg(long, value_enum, default_value = "send")]
|
||||
mode: TranscriptModeArg,
|
||||
|
||||
/// Target a specific live session instead of the active TUI
|
||||
#[arg(short = 'S', long)]
|
||||
session: Option<String>,
|
||||
},
|
||||
|
||||
/// Run configured dictation: send to last-focused jcode client or type raw text
|
||||
Dictate {
|
||||
/// Type the transcript into the focused app instead of sending to jcode
|
||||
#[arg(long)]
|
||||
r#type: bool,
|
||||
},
|
||||
|
||||
/// Set up the platform global hotkey to launch jcode
|
||||
SetupHotkey {
|
||||
/// Internal: run as the macOS hotkey listener process.
|
||||
#[arg(long, hide = true)]
|
||||
listen_macos_hotkey: bool,
|
||||
},
|
||||
|
||||
/// Install a launcher so jcode appears in your app launcher
|
||||
SetupLauncher,
|
||||
|
||||
/// Browser automation setup and status
|
||||
Browser {
|
||||
/// Action (setup, status)
|
||||
#[arg(default_value = "setup")]
|
||||
action: String,
|
||||
},
|
||||
|
||||
/// Replay a saved session in the TUI
|
||||
Replay {
|
||||
/// Session ID, name, or path to session JSON file
|
||||
session: String,
|
||||
|
||||
/// Replay related swarm sessions together in a synchronized multi-pane view
|
||||
#[arg(long)]
|
||||
swarm: bool,
|
||||
|
||||
/// Export timeline as JSON instead of playing
|
||||
#[arg(long)]
|
||||
export: bool,
|
||||
|
||||
/// Playback speed multiplier (default: 1.0)
|
||||
#[arg(long, default_value = "1.0")]
|
||||
speed: f64,
|
||||
|
||||
/// Path to an edited timeline JSON file (overrides session timing)
|
||||
#[arg(long)]
|
||||
timeline: Option<String>,
|
||||
|
||||
/// Auto-edit timeline: compress tool call wait times and gaps between prompts
|
||||
#[arg(long)]
|
||||
auto_edit: bool,
|
||||
|
||||
/// Export as video file (auto-generates name if no path given)
|
||||
#[arg(long, default_missing_value = "auto", num_args = 0..=1)]
|
||||
video: Option<String>,
|
||||
|
||||
/// Video width in columns (default: 120)
|
||||
#[arg(long, default_value = "120")]
|
||||
cols: u16,
|
||||
|
||||
/// Video height in rows (default: 40)
|
||||
#[arg(long, default_value = "40")]
|
||||
rows: u16,
|
||||
|
||||
/// Video frames per second (default: 60)
|
||||
#[arg(long, default_value = "60")]
|
||||
fps: u32,
|
||||
|
||||
/// Force centered layout (overrides config)
|
||||
#[arg(long, conflicts_with = "no_centered")]
|
||||
centered: bool,
|
||||
|
||||
/// Force left-aligned (non-centered) layout (overrides config)
|
||||
#[arg(long, conflicts_with = "centered")]
|
||||
no_centered: bool,
|
||||
},
|
||||
|
||||
/// Model management commands
|
||||
#[command(subcommand)]
|
||||
Model(ModelCommand),
|
||||
|
||||
/// Show live verification coverage. With no provider/model, prints the full coverage summary.
|
||||
#[command(name = "provider-test-coverage", alias = "model-status")]
|
||||
ProviderTestCoverage {
|
||||
/// Provider to look up. Omit provider and model to print the full coverage summary.
|
||||
#[arg(value_name = "PROVIDER")]
|
||||
provider_query: Option<String>,
|
||||
|
||||
/// Model to look up. Defaults to the global --model value only when PROVIDER is supplied.
|
||||
#[arg(value_name = "MODEL")]
|
||||
model_query: Option<String>,
|
||||
|
||||
/// Read coverage from this JSON file instead of the default live-test coverage ledger
|
||||
#[arg(long)]
|
||||
coverage_file: Option<String>,
|
||||
|
||||
/// Maximum provider/model pairs to list in the full summary (0 = show all)
|
||||
#[arg(long, default_value_t = 0)]
|
||||
coverage_limit: usize,
|
||||
},
|
||||
|
||||
/// Diagnose why a provider/model or the model picker is broken by walking the
|
||||
/// strict end-to-end checkpoints (catalog, picker, model-switch, chat, streaming, tools).
|
||||
#[command(name = "provider-doctor", alias = "provider-strict-e2e")]
|
||||
ProviderDoctor {
|
||||
/// OpenAI-compatible provider id to diagnose (e.g. cerebras, fpt, nvidia-nim)
|
||||
#[arg(id = "doctor_provider", value_name = "PROVIDER")]
|
||||
provider: String,
|
||||
|
||||
/// How much to exercise: offline (no key/no spend), catalog (key, ~no spend),
|
||||
/// or full (key, spends balance: chat + streaming + tools).
|
||||
#[arg(long, value_name = "TIER", default_value = "catalog")]
|
||||
tier: String,
|
||||
|
||||
/// Emit the report as JSON for scripting
|
||||
#[arg(long)]
|
||||
json: bool,
|
||||
},
|
||||
|
||||
/// Test authentication end-to-end: login (optional), credential probe, refresh, and provider smoke
|
||||
AuthTest {
|
||||
/// Run the provider login flow before validation (interactive/browser-based)
|
||||
#[arg(long)]
|
||||
login: bool,
|
||||
|
||||
/// Test all currently configured supported auth providers instead of just --provider
|
||||
#[arg(long)]
|
||||
all_configured: bool,
|
||||
|
||||
/// Skip the provider runtime smoke prompt
|
||||
#[arg(long)]
|
||||
no_smoke: bool,
|
||||
|
||||
/// Skip the tool-enabled runtime smoke prompt (the same request path used during normal chat)
|
||||
#[arg(long)]
|
||||
no_tool_smoke: bool,
|
||||
|
||||
/// Custom smoke prompt (default asks for AUTH_TEST_OK)
|
||||
#[arg(long)]
|
||||
prompt: Option<String>,
|
||||
|
||||
/// Emit JSON report instead of human-readable output
|
||||
#[arg(long)]
|
||||
json: bool,
|
||||
|
||||
/// Write the full auth-test report JSON to a file
|
||||
#[arg(long)]
|
||||
output: Option<String>,
|
||||
|
||||
/// Show strict live provider/model E2E coverage instead of running auth tests
|
||||
#[arg(long, conflicts_with_all = ["login", "all_configured", "no_smoke", "no_tool_smoke", "prompt"])]
|
||||
coverage: bool,
|
||||
|
||||
/// Fetch live model catalogs and verify context-window resolution for each model with metadata
|
||||
#[arg(long, conflicts_with_all = ["login", "no_smoke", "no_tool_smoke", "prompt", "coverage"])]
|
||||
context_audit: bool,
|
||||
|
||||
/// Read coverage from this JSON file instead of the default live-test coverage ledger
|
||||
#[arg(long, requires = "coverage")]
|
||||
coverage_file: Option<String>,
|
||||
|
||||
/// Maximum uncovered provider/model gaps to show in the text coverage report
|
||||
#[arg(long, requires = "coverage", default_value_t = 50)]
|
||||
coverage_limit: usize,
|
||||
},
|
||||
|
||||
/// Save or restore the current set of open jcode windows across a system reboot
|
||||
Restart {
|
||||
#[command(subcommand)]
|
||||
action: RestartCommand,
|
||||
},
|
||||
|
||||
/// Show a live macOS menu bar indicator with running/streaming session counts
|
||||
#[command(alias = "menu-bar", alias = "statusbar")]
|
||||
Menubar {
|
||||
/// Print the current counts once as text and exit (no menu bar item)
|
||||
#[arg(long)]
|
||||
once: bool,
|
||||
|
||||
/// Emit the current counts as JSON and exit
|
||||
#[arg(long, conflicts_with = "once")]
|
||||
json: bool,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
pub(crate) enum ServerCommand {
|
||||
/// Gracefully reload the running background server onto the newest binary.
|
||||
///
|
||||
/// This is the preferred way to pick up an upgrade: the daemon hands its
|
||||
/// live sessions off to a freshly exec'd server (the same path `/reload`
|
||||
/// uses), so headless/swarm work is preserved instead of being killed. If
|
||||
/// no server is running, this is a no-op. Use `server stop --force` only
|
||||
/// when you need to hard-retire a wedged daemon.
|
||||
Reload {
|
||||
/// Reload even if the running server is already on the newest binary.
|
||||
#[arg(long)]
|
||||
force: bool,
|
||||
|
||||
/// Emit JSON instead of human-readable text
|
||||
#[arg(long)]
|
||||
json: bool,
|
||||
},
|
||||
|
||||
/// Stop the running background server and clear its socket.
|
||||
///
|
||||
/// Prefer `server reload` after an upgrade; it preserves live sessions.
|
||||
/// `stop` terminates the daemon (SIGTERM, escalating to SIGKILL), which
|
||||
/// drops any in-flight headless/swarm sessions, so it requires `--force`
|
||||
/// as a deliberate acknowledgement.
|
||||
Stop {
|
||||
/// Confirm that terminating the daemon (and dropping live sessions) is intended.
|
||||
#[arg(long)]
|
||||
force: bool,
|
||||
|
||||
/// Emit JSON instead of human-readable text
|
||||
#[arg(long)]
|
||||
json: bool,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
pub(crate) enum CloudCommand {
|
||||
/// Upload, list, verify, and view cloud-synced sessions
|
||||
Sessions {
|
||||
#[command(subcommand)]
|
||||
action: CloudSessionsCommand,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
pub(crate) enum CloudSessionsCommand {
|
||||
/// Configure Jade API defaults for cloud sessions on this machine
|
||||
Configure {
|
||||
/// Jade Session API base URL
|
||||
#[arg(long)]
|
||||
api_base: Option<String>,
|
||||
|
||||
/// Jade Session API bearer token. Prefer --api-token-env to avoid shell history.
|
||||
#[arg(long, conflicts_with = "api_token_env")]
|
||||
api_token: Option<String>,
|
||||
|
||||
/// Read the Jade Session API bearer token from this environment variable
|
||||
#[arg(long, conflicts_with = "api_token")]
|
||||
api_token_env: Option<String>,
|
||||
|
||||
/// Optional Jade token id, e.g. dev-admin
|
||||
#[arg(long)]
|
||||
api_token_id: Option<String>,
|
||||
|
||||
/// Default Jade user id for commands that do not pass --user-id
|
||||
#[arg(long)]
|
||||
user_id: Option<String>,
|
||||
|
||||
/// Default private Jade session helper path
|
||||
#[arg(long)]
|
||||
helper: Option<String>,
|
||||
|
||||
/// Remove the saved cloud sessions config
|
||||
#[arg(long)]
|
||||
clear: bool,
|
||||
},
|
||||
|
||||
/// Show saved Jade API defaults for cloud sessions without printing secrets
|
||||
Status {
|
||||
/// Emit JSON instead of human-readable text
|
||||
#[arg(long)]
|
||||
json: bool,
|
||||
},
|
||||
|
||||
/// Upload a specific local session JSON file to Jade cloud storage
|
||||
Upload {
|
||||
/// Path to a local Jcode session JSON file
|
||||
session_file: String,
|
||||
|
||||
/// Upload without Jade's redaction pass
|
||||
#[arg(long)]
|
||||
raw: bool,
|
||||
|
||||
#[command(flatten)]
|
||||
jade: JadeCloudOptions,
|
||||
},
|
||||
|
||||
/// Upload the newest local Jcode session to Jade cloud storage
|
||||
UploadLatest {
|
||||
/// Directory containing local Jcode session JSON files
|
||||
#[arg(long, default_value = "~/.jcode/sessions")]
|
||||
sessions_dir: String,
|
||||
|
||||
/// Upload without Jade's redaction pass
|
||||
#[arg(long)]
|
||||
raw: bool,
|
||||
|
||||
#[command(flatten)]
|
||||
jade: JadeCloudOptions,
|
||||
},
|
||||
|
||||
/// Sync new or changed local sessions to Jade cloud storage (idempotent; safe to schedule)
|
||||
Sync {
|
||||
/// Directory containing local Jcode session JSON files (default: ~/.jcode/sessions)
|
||||
#[arg(long)]
|
||||
sessions_dir: Option<String>,
|
||||
|
||||
/// Only consider sessions modified within this many days (ignored with --all)
|
||||
#[arg(long)]
|
||||
since_days: Option<u64>,
|
||||
|
||||
/// Sync all matching sessions regardless of age
|
||||
#[arg(long)]
|
||||
all: bool,
|
||||
|
||||
/// Maximum number of sessions to upload in this run
|
||||
#[arg(long, default_value_t = 50)]
|
||||
max: usize,
|
||||
|
||||
/// Skip this run if the last sync ran fewer than this many minutes ago (for cron/timers)
|
||||
#[arg(long)]
|
||||
min_interval_mins: Option<u64>,
|
||||
|
||||
/// Upload without Jade's redaction pass
|
||||
#[arg(long)]
|
||||
raw: bool,
|
||||
|
||||
/// Show what would be uploaded without uploading or recording state
|
||||
#[arg(long)]
|
||||
dry_run: bool,
|
||||
|
||||
/// Re-upload sessions even if local sync state says they are unchanged
|
||||
#[arg(long)]
|
||||
force: bool,
|
||||
|
||||
/// Emit JSON instead of human-readable text
|
||||
#[arg(long)]
|
||||
json: bool,
|
||||
|
||||
#[command(flatten)]
|
||||
jade: JadeCloudOptions,
|
||||
},
|
||||
|
||||
/// List cloud-uploaded sessions from the Jade index
|
||||
List {
|
||||
/// Maximum number of sessions to show
|
||||
#[arg(long, default_value_t = 25)]
|
||||
limit: usize,
|
||||
|
||||
/// Emit JSON instead of human-readable text
|
||||
#[arg(long)]
|
||||
json: bool,
|
||||
|
||||
#[command(flatten)]
|
||||
jade: JadeCloudOptions,
|
||||
},
|
||||
|
||||
/// Verify that cloud metadata and the S3 session blob both exist
|
||||
Verify {
|
||||
/// Session ID to verify
|
||||
session_id: String,
|
||||
|
||||
#[command(flatten)]
|
||||
jade: JadeCloudOptions,
|
||||
},
|
||||
|
||||
/// Render a local HTML dashboard of cloud-uploaded sessions from the Jade index
|
||||
Dashboard {
|
||||
/// Maximum number of sessions to include
|
||||
#[arg(long, default_value_t = 100)]
|
||||
limit: usize,
|
||||
|
||||
/// Write the dashboard HTML to this path (default: a temp file)
|
||||
#[arg(long)]
|
||||
output: Option<String>,
|
||||
|
||||
/// Open the generated dashboard in the default browser
|
||||
#[arg(long)]
|
||||
open: bool,
|
||||
|
||||
/// Also download each session and link rows to a local per-session viewer
|
||||
#[arg(long)]
|
||||
with_view: bool,
|
||||
|
||||
#[command(flatten)]
|
||||
jade: JadeCloudOptions,
|
||||
},
|
||||
|
||||
/// Download and view a cloud-uploaded session
|
||||
View {
|
||||
/// Session ID to view
|
||||
session_id: String,
|
||||
|
||||
/// Output format
|
||||
#[arg(long, default_value = "summary")]
|
||||
format: CloudSessionViewFormat,
|
||||
|
||||
/// Write HTML output to this path when --format html is used
|
||||
#[arg(long)]
|
||||
output: Option<String>,
|
||||
|
||||
/// Open the generated HTML file when --format html is used
|
||||
#[arg(long)]
|
||||
open: bool,
|
||||
|
||||
#[command(flatten)]
|
||||
jade: JadeCloudOptions,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Parser, Debug, Clone)]
|
||||
pub(crate) struct JadeCloudOptions {
|
||||
/// Jade user id to pass to the dev helper
|
||||
#[arg(long, default_value = "dev")]
|
||||
pub(crate) user_id: String,
|
||||
|
||||
/// AWS CLI profile used by the private dev Jade helper. If omitted, the helper decides.
|
||||
#[arg(long)]
|
||||
pub(crate) profile: Option<String>,
|
||||
|
||||
/// AWS region used by the private dev Jade helper. If omitted, the helper decides.
|
||||
#[arg(long)]
|
||||
pub(crate) region: Option<String>,
|
||||
|
||||
/// Path to the private Jade session helper. Defaults to $JCODE_JADE_SESSIONS_HELPER or ~/jade/scripts/jade_sessions.py.
|
||||
#[arg(long)]
|
||||
pub(crate) helper: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(ValueEnum, Debug, Clone, Copy)]
|
||||
pub(crate) enum CloudSessionViewFormat {
|
||||
Summary,
|
||||
Json,
|
||||
Html,
|
||||
}
|
||||
|
||||
impl CloudSessionViewFormat {
|
||||
pub(crate) fn as_arg(self) -> &'static str {
|
||||
match self {
|
||||
Self::Summary => "summary",
|
||||
Self::Json => "json",
|
||||
Self::Html => "html",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
pub(crate) enum RestartCommand {
|
||||
/// Save a reboot snapshot of currently active jcode windows
|
||||
Save {
|
||||
/// Restore this reboot snapshot automatically the next time plain `jcode` starts
|
||||
#[arg(long)]
|
||||
auto_restore: bool,
|
||||
},
|
||||
/// Restore the most recently saved reboot snapshot
|
||||
Restore,
|
||||
/// Show the currently saved reboot snapshot
|
||||
Status,
|
||||
/// Remove the currently saved reboot snapshot
|
||||
Clear,
|
||||
}
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
pub(crate) enum ModelCommand {
|
||||
/// List model names you can pass to -m/--model
|
||||
List {
|
||||
/// Emit JSON instead of plain text
|
||||
#[arg(long)]
|
||||
json: bool,
|
||||
|
||||
/// Show provider/selection summary before the list
|
||||
#[arg(long)]
|
||||
verbose: bool,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
pub(crate) enum SessionCommand {
|
||||
/// Rename a saved session's human-readable name/title
|
||||
Rename {
|
||||
/// Session ID or memorable short name, e.g. fox
|
||||
session: String,
|
||||
|
||||
/// New session name/title
|
||||
#[arg(required_unless_present = "clear")]
|
||||
name: Option<String>,
|
||||
|
||||
/// Clear the custom session name/title
|
||||
#[arg(long, conflicts_with = "name")]
|
||||
clear: bool,
|
||||
|
||||
/// Emit JSON instead of human-readable output
|
||||
#[arg(long)]
|
||||
json: bool,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
pub(crate) enum ProviderCommand {
|
||||
/// List provider IDs you can pass to -p/--provider
|
||||
List {
|
||||
/// Emit JSON instead of plain text
|
||||
#[arg(long)]
|
||||
json: bool,
|
||||
},
|
||||
|
||||
/// Show the currently requested and resolved provider selection
|
||||
Current {
|
||||
/// Emit JSON instead of plain text
|
||||
#[arg(long)]
|
||||
json: bool,
|
||||
},
|
||||
|
||||
/// Add a named OpenAI-compatible API provider profile
|
||||
Add {
|
||||
/// Profile name used with --provider-profile and config defaults, e.g. my-gateway
|
||||
name: String,
|
||||
|
||||
/// OpenAI-compatible API base URL, e.g. https://llm.example.com/v1
|
||||
#[arg(long, alias = "api-base")]
|
||||
base_url: String,
|
||||
|
||||
/// Default model id for this provider profile
|
||||
#[arg(short, long)]
|
||||
model: String,
|
||||
|
||||
/// Optional model context window in tokens
|
||||
#[arg(long)]
|
||||
context_window: Option<usize>,
|
||||
|
||||
/// Environment variable name that contains the API key
|
||||
#[arg(long, conflicts_with = "no_api_key")]
|
||||
api_key_env: Option<String>,
|
||||
|
||||
/// API key value to store in jcode's private provider env file. Prefer --api-key-stdin for shell history safety.
|
||||
#[arg(long, conflicts_with_all = ["api_key_stdin", "no_api_key"])]
|
||||
api_key: Option<String>,
|
||||
|
||||
/// Read the API key from stdin and store it in jcode's private provider env file
|
||||
#[arg(long, conflicts_with = "no_api_key")]
|
||||
api_key_stdin: bool,
|
||||
|
||||
/// Configure the provider with no API key/authentication
|
||||
#[arg(long, conflicts_with_all = ["api_key", "api_key_stdin", "api_key_env"])]
|
||||
no_api_key: bool,
|
||||
|
||||
/// Authentication style for the API key
|
||||
#[arg(long, value_enum)]
|
||||
auth: Option<ProviderAuthArg>,
|
||||
|
||||
/// Header name when --auth api-key is used (default: api-key)
|
||||
#[arg(long)]
|
||||
auth_header: Option<String>,
|
||||
|
||||
/// Private env file name under jcode's app config directory for stored API keys
|
||||
#[arg(long)]
|
||||
env_file: Option<String>,
|
||||
|
||||
/// Make this profile the startup default provider/model
|
||||
#[arg(long, alias = "default")]
|
||||
set_default: bool,
|
||||
|
||||
/// Replace an existing profile with the same name
|
||||
#[arg(long)]
|
||||
overwrite: bool,
|
||||
|
||||
/// Allow provider-routing features for OpenRouter-style gateways
|
||||
#[arg(long)]
|
||||
provider_routing: bool,
|
||||
|
||||
/// Fetch/list models from the provider's /models endpoint
|
||||
#[arg(long)]
|
||||
model_catalog: bool,
|
||||
|
||||
/// Emit JSON instead of human-readable setup output
|
||||
#[arg(long)]
|
||||
json: bool,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
pub(crate) enum AuthCommand {
|
||||
/// Show configured authentication status for model/tool providers
|
||||
Status {
|
||||
/// Emit JSON instead of plain text
|
||||
#[arg(long)]
|
||||
json: bool,
|
||||
},
|
||||
/// Diagnose provider auth issues and suggest next steps
|
||||
Doctor {
|
||||
/// Optional provider id or alias to focus diagnosis on one provider
|
||||
#[arg(id = "auth_provider", value_name = "PROVIDER")]
|
||||
provider: Option<String>,
|
||||
|
||||
/// Run live post-login validation for configured providers during diagnosis
|
||||
#[arg(long)]
|
||||
validate: bool,
|
||||
|
||||
/// Emit JSON instead of plain text
|
||||
#[arg(long)]
|
||||
json: bool,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
pub(crate) enum AmbientCommand {
|
||||
/// Show ambient mode status
|
||||
Status,
|
||||
/// Show recent ambient activity log
|
||||
Log,
|
||||
/// Manually trigger an ambient cycle
|
||||
Trigger,
|
||||
/// Stop ambient mode
|
||||
Stop,
|
||||
/// Run an ambient cycle in a visible TUI (internal, spawned by the ambient runner)
|
||||
#[command(hide = true)]
|
||||
RunVisible,
|
||||
}
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
pub(crate) enum MemoryCommand {
|
||||
/// List all stored memories
|
||||
List {
|
||||
/// Filter by scope (project, global, all)
|
||||
#[arg(short, long, default_value = "all")]
|
||||
scope: String,
|
||||
|
||||
/// Filter by tag
|
||||
#[arg(short, long)]
|
||||
tag: Option<String>,
|
||||
},
|
||||
|
||||
/// Search memories by query
|
||||
Search {
|
||||
/// Search query
|
||||
query: String,
|
||||
|
||||
/// Use semantic search (embedding-based) instead of keyword
|
||||
#[arg(short, long)]
|
||||
semantic: bool,
|
||||
},
|
||||
|
||||
/// Export memories to a JSON file
|
||||
Export {
|
||||
/// Output file path
|
||||
output: String,
|
||||
|
||||
/// Export scope (project, global, all)
|
||||
#[arg(short, long, default_value = "all")]
|
||||
scope: String,
|
||||
},
|
||||
|
||||
/// Import memories from a JSON file
|
||||
Import {
|
||||
/// Input file path
|
||||
input: String,
|
||||
|
||||
/// Import scope (project, global)
|
||||
#[arg(short, long, default_value = "project")]
|
||||
scope: String,
|
||||
|
||||
/// Overwrite existing memories with same ID
|
||||
#[arg(long)]
|
||||
overwrite: bool,
|
||||
},
|
||||
|
||||
/// Show memory statistics
|
||||
Stats,
|
||||
|
||||
/// Clear test memory storage (used by debug sessions)
|
||||
ClearTest,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,703 @@
|
||||
use super::*;
|
||||
use crate::cli::provider_init::ProviderChoice;
|
||||
|
||||
#[test]
|
||||
fn test_provider_choice_aliases_parse() {
|
||||
let args = Args::try_parse_from(["jcode", "--provider", "z.ai", "run", "smoke"]).unwrap();
|
||||
assert_eq!(args.provider, ProviderChoice::Zai);
|
||||
|
||||
let args =
|
||||
Args::try_parse_from(["jcode", "--provider", "kimi-for-coding", "run", "smoke"]).unwrap();
|
||||
assert_eq!(args.provider, ProviderChoice::Kimi);
|
||||
|
||||
let args =
|
||||
Args::try_parse_from(["jcode", "--provider", "cerebrascode", "run", "smoke"]).unwrap();
|
||||
assert_eq!(args.provider, ProviderChoice::Cerebras);
|
||||
|
||||
let args = Args::try_parse_from(["jcode", "--provider", "compat", "run", "smoke"]).unwrap();
|
||||
assert_eq!(args.provider, ProviderChoice::OpenaiCompatible);
|
||||
|
||||
let args = Args::try_parse_from(["jcode", "--provider", "bailian", "run", "smoke"]).unwrap();
|
||||
assert_eq!(args.provider, ProviderChoice::AlibabaCodingPlan);
|
||||
|
||||
let args = Args::try_parse_from(["jcode", "--provider", "together", "run", "smoke"]).unwrap();
|
||||
assert_eq!(args.provider, ProviderChoice::TogetherAi);
|
||||
|
||||
let args = Args::try_parse_from(["jcode", "--provider", "grok", "run", "smoke"]).unwrap();
|
||||
assert_eq!(args.provider, ProviderChoice::Xai);
|
||||
|
||||
let args = Args::try_parse_from(["jcode", "--provider", "cgc", "run", "smoke"]).unwrap();
|
||||
assert_eq!(args.provider, ProviderChoice::Comtegra);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serve_server_name_option_parses() {
|
||||
let args =
|
||||
Args::try_parse_from(["jcode", "serve", "--server-name", "mount-cloud/fabian"]).unwrap();
|
||||
match args.command {
|
||||
Some(Command::Serve { server_name, .. }) => {
|
||||
assert_eq!(server_name.as_deref(), Some("mount-cloud/fabian"));
|
||||
}
|
||||
other => panic!("unexpected command: {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remote_working_dir_option_parses() {
|
||||
let args = Args::try_parse_from([
|
||||
"jcode",
|
||||
"--socket",
|
||||
"/tmp/jcode.sock",
|
||||
"--remote-working-dir",
|
||||
"/home/agent/project",
|
||||
])
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
args.remote_working_dir.as_deref(),
|
||||
Some("/home/agent/project")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn model_list_subcommand_parses() {
|
||||
let args = Args::try_parse_from(["jcode", "model", "list", "--json", "--verbose"]).unwrap();
|
||||
match args.command {
|
||||
Some(Command::Model(ModelCommand::List { json, verbose })) => {
|
||||
assert!(json);
|
||||
assert!(verbose);
|
||||
}
|
||||
other => panic!("unexpected command: {:?}", other),
|
||||
}
|
||||
|
||||
let args = Args::try_parse_from([
|
||||
"jcode",
|
||||
"cloud",
|
||||
"sessions",
|
||||
"dashboard",
|
||||
"--limit",
|
||||
"10",
|
||||
"--open",
|
||||
"--with-view",
|
||||
"--user-id",
|
||||
"jeremy",
|
||||
])
|
||||
.unwrap();
|
||||
|
||||
match args.command {
|
||||
Some(Command::Cloud(CloudCommand::Sessions {
|
||||
action:
|
||||
CloudSessionsCommand::Dashboard {
|
||||
limit,
|
||||
output,
|
||||
open,
|
||||
with_view,
|
||||
jade,
|
||||
},
|
||||
})) => {
|
||||
assert_eq!(limit, 10);
|
||||
assert!(output.is_none());
|
||||
assert!(open);
|
||||
assert!(with_view);
|
||||
assert_eq!(jade.user_id, "jeremy");
|
||||
}
|
||||
other => panic!("unexpected command: {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_rename_subcommand_parses() {
|
||||
let args = Args::try_parse_from([
|
||||
"jcode",
|
||||
"session",
|
||||
"rename",
|
||||
"fox",
|
||||
"release planning",
|
||||
"--json",
|
||||
])
|
||||
.unwrap();
|
||||
match args.command {
|
||||
Some(Command::Session(SessionCommand::Rename {
|
||||
session,
|
||||
name,
|
||||
clear,
|
||||
json,
|
||||
})) => {
|
||||
assert_eq!(session, "fox");
|
||||
assert_eq!(name.as_deref(), Some("release planning"));
|
||||
assert!(!clear);
|
||||
assert!(json);
|
||||
}
|
||||
other => panic!("unexpected command: {:?}", other),
|
||||
}
|
||||
|
||||
let args = Args::try_parse_from(["jcode", "session", "rename", "fox", "--clear"]).unwrap();
|
||||
match args.command {
|
||||
Some(Command::Session(SessionCommand::Rename {
|
||||
session,
|
||||
name,
|
||||
clear,
|
||||
json,
|
||||
})) => {
|
||||
assert_eq!(session, "fox");
|
||||
assert!(name.is_none());
|
||||
assert!(clear);
|
||||
assert!(!json);
|
||||
}
|
||||
other => panic!("unexpected command: {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cloud_sessions_subcommands_parse() {
|
||||
let args = Args::try_parse_from([
|
||||
"jcode",
|
||||
"cloud",
|
||||
"sessions",
|
||||
"configure",
|
||||
"--api-base",
|
||||
"https://jade.example",
|
||||
"--api-token-env",
|
||||
"JADE_TOKEN",
|
||||
"--api-token-id",
|
||||
"dev-admin",
|
||||
"--user-id",
|
||||
"jeremy",
|
||||
"--helper",
|
||||
"/tmp/jade_sessions.py",
|
||||
])
|
||||
.unwrap();
|
||||
|
||||
match args.command {
|
||||
Some(Command::Cloud(CloudCommand::Sessions {
|
||||
action:
|
||||
CloudSessionsCommand::Configure {
|
||||
api_base,
|
||||
api_token_env,
|
||||
api_token_id,
|
||||
user_id,
|
||||
helper,
|
||||
clear,
|
||||
..
|
||||
},
|
||||
})) => {
|
||||
assert_eq!(api_base.as_deref(), Some("https://jade.example"));
|
||||
assert_eq!(api_token_env.as_deref(), Some("JADE_TOKEN"));
|
||||
assert_eq!(api_token_id.as_deref(), Some("dev-admin"));
|
||||
assert_eq!(user_id.as_deref(), Some("jeremy"));
|
||||
assert_eq!(helper.as_deref(), Some("/tmp/jade_sessions.py"));
|
||||
assert!(!clear);
|
||||
}
|
||||
other => panic!("unexpected command: {:?}", other),
|
||||
}
|
||||
|
||||
let args = Args::try_parse_from(["jcode", "cloud", "sessions", "status", "--json"]).unwrap();
|
||||
match args.command {
|
||||
Some(Command::Cloud(CloudCommand::Sessions {
|
||||
action: CloudSessionsCommand::Status { json },
|
||||
})) => assert!(json),
|
||||
other => panic!("unexpected command: {:?}", other),
|
||||
}
|
||||
|
||||
let args = Args::try_parse_from([
|
||||
"jcode",
|
||||
"cloud",
|
||||
"sessions",
|
||||
"upload-latest",
|
||||
"--sessions-dir",
|
||||
"/tmp/sessions",
|
||||
"--user-id",
|
||||
"jeremy",
|
||||
"--profile",
|
||||
"test-profile",
|
||||
"--region",
|
||||
"us-east-1",
|
||||
])
|
||||
.unwrap();
|
||||
|
||||
match args.command {
|
||||
Some(Command::Cloud(CloudCommand::Sessions {
|
||||
action:
|
||||
CloudSessionsCommand::UploadLatest {
|
||||
sessions_dir,
|
||||
raw,
|
||||
jade,
|
||||
},
|
||||
})) => {
|
||||
assert_eq!(sessions_dir, "/tmp/sessions");
|
||||
assert!(!raw);
|
||||
assert_eq!(jade.user_id, "jeremy");
|
||||
assert_eq!(jade.profile.as_deref(), Some("test-profile"));
|
||||
assert_eq!(jade.region.as_deref(), Some("us-east-1"));
|
||||
}
|
||||
other => panic!("unexpected command: {:?}", other),
|
||||
}
|
||||
|
||||
let args = Args::try_parse_from([
|
||||
"jcode",
|
||||
"cloud",
|
||||
"sessions",
|
||||
"view",
|
||||
"session_123",
|
||||
"--format",
|
||||
"html",
|
||||
"--open",
|
||||
])
|
||||
.unwrap();
|
||||
|
||||
match args.command {
|
||||
Some(Command::Cloud(CloudCommand::Sessions {
|
||||
action:
|
||||
CloudSessionsCommand::View {
|
||||
session_id,
|
||||
format,
|
||||
open,
|
||||
..
|
||||
},
|
||||
})) => {
|
||||
assert_eq!(session_id, "session_123");
|
||||
assert!(matches!(format, CloudSessionViewFormat::Html));
|
||||
assert!(open);
|
||||
}
|
||||
other => panic!("unexpected command: {:?}", other),
|
||||
}
|
||||
|
||||
let args = Args::try_parse_from([
|
||||
"jcode",
|
||||
"cloud",
|
||||
"sessions",
|
||||
"sync",
|
||||
"--all",
|
||||
"--max",
|
||||
"5",
|
||||
"--dry-run",
|
||||
"--json",
|
||||
"--user-id",
|
||||
"jeremy",
|
||||
])
|
||||
.unwrap();
|
||||
|
||||
match args.command {
|
||||
Some(Command::Cloud(CloudCommand::Sessions {
|
||||
action:
|
||||
CloudSessionsCommand::Sync {
|
||||
sessions_dir,
|
||||
since_days,
|
||||
all,
|
||||
max,
|
||||
min_interval_mins,
|
||||
raw,
|
||||
dry_run,
|
||||
force,
|
||||
json,
|
||||
jade,
|
||||
},
|
||||
})) => {
|
||||
assert!(sessions_dir.is_none());
|
||||
assert!(since_days.is_none());
|
||||
assert!(all);
|
||||
assert_eq!(max, 5);
|
||||
assert!(min_interval_mins.is_none());
|
||||
assert!(!raw);
|
||||
assert!(dry_run);
|
||||
assert!(!force);
|
||||
assert!(json);
|
||||
assert_eq!(jade.user_id, "jeremy");
|
||||
}
|
||||
other => panic!("unexpected command: {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn login_no_browser_flag_parses() {
|
||||
let args = Args::try_parse_from(["jcode", "login", "--no-browser"]).unwrap();
|
||||
match args.command {
|
||||
Some(Command::Login {
|
||||
provider,
|
||||
account,
|
||||
no_browser,
|
||||
print_auth_url,
|
||||
callback_url,
|
||||
auth_code,
|
||||
json,
|
||||
complete,
|
||||
google_access_tier,
|
||||
api_base,
|
||||
api_key,
|
||||
api_key_env,
|
||||
no_validate,
|
||||
}) => {
|
||||
assert!(provider.is_none());
|
||||
assert!(account.is_none());
|
||||
assert!(no_browser);
|
||||
assert!(!print_auth_url);
|
||||
assert!(callback_url.is_none());
|
||||
assert!(auth_code.is_none());
|
||||
assert!(!json);
|
||||
assert!(!complete);
|
||||
assert!(google_access_tier.is_none());
|
||||
assert!(api_base.is_none());
|
||||
assert!(api_key.is_none());
|
||||
assert!(api_key_env.is_none());
|
||||
assert!(!no_validate);
|
||||
}
|
||||
other => panic!("unexpected command: {:?}", other),
|
||||
}
|
||||
|
||||
let args = Args::try_parse_from(["jcode", "login", "--headless"]).unwrap();
|
||||
match args.command {
|
||||
Some(Command::Login { no_browser, .. }) => assert!(no_browser),
|
||||
other => panic!("unexpected command: {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn login_accepts_provider_positional() {
|
||||
let args = Args::try_parse_from(["jcode", "login", "google"]).unwrap();
|
||||
match args.command {
|
||||
Some(Command::Login { provider, .. }) => {
|
||||
assert_eq!(provider, Some(ProviderChoice::Google));
|
||||
}
|
||||
other => panic!("unexpected command: {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn login_openai_compatible_scriptable_flags_parse() {
|
||||
let args = Args::try_parse_from([
|
||||
"jcode",
|
||||
"--provider",
|
||||
"openai-compatible",
|
||||
"--model",
|
||||
"deepseek-v4-flash",
|
||||
"login",
|
||||
"--api-base",
|
||||
"https://api.deepseek.com",
|
||||
"--api-key-env",
|
||||
"DEEPSEEK_API_KEY",
|
||||
])
|
||||
.unwrap();
|
||||
assert_eq!(args.provider, ProviderChoice::OpenaiCompatible);
|
||||
assert_eq!(args.model.as_deref(), Some("deepseek-v4-flash"));
|
||||
match args.command {
|
||||
Some(Command::Login {
|
||||
api_base,
|
||||
api_key_env,
|
||||
..
|
||||
}) => {
|
||||
assert_eq!(api_base.as_deref(), Some("https://api.deepseek.com"));
|
||||
assert_eq!(api_key_env.as_deref(), Some("DEEPSEEK_API_KEY"));
|
||||
}
|
||||
other => panic!("unexpected command: {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn login_openai_compatible_accepts_global_provider_and_model_after_subcommand() {
|
||||
let args = Args::try_parse_from([
|
||||
"jcode",
|
||||
"login",
|
||||
"--provider",
|
||||
"openai-compatible",
|
||||
"--api-base",
|
||||
"https://api.deepseek.com",
|
||||
"--model",
|
||||
"deepseek-v4-flash",
|
||||
])
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(args.provider, ProviderChoice::OpenaiCompatible);
|
||||
assert_eq!(args.model.as_deref(), Some("deepseek-v4-flash"));
|
||||
match args.command {
|
||||
Some(Command::Login { api_base, .. }) => {
|
||||
assert_eq!(api_base.as_deref(), Some("https://api.deepseek.com"));
|
||||
}
|
||||
other => panic!("unexpected command: {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn login_scriptable_flags_parse() {
|
||||
let args = Args::try_parse_from(["jcode", "login", "--print-auth-url", "--json"]).unwrap();
|
||||
match args.command {
|
||||
Some(Command::Login {
|
||||
print_auth_url,
|
||||
json,
|
||||
callback_url,
|
||||
auth_code,
|
||||
complete,
|
||||
google_access_tier,
|
||||
..
|
||||
}) => {
|
||||
assert!(print_auth_url);
|
||||
assert!(json);
|
||||
assert!(callback_url.is_none());
|
||||
assert!(auth_code.is_none());
|
||||
assert!(!complete);
|
||||
assert!(google_access_tier.is_none());
|
||||
}
|
||||
other => panic!("unexpected command: {:?}", other),
|
||||
}
|
||||
|
||||
let args = Args::try_parse_from([
|
||||
"jcode",
|
||||
"login",
|
||||
"--callback-url",
|
||||
"http://localhost:1455/auth/callback?code=x&state=y",
|
||||
])
|
||||
.unwrap();
|
||||
match args.command {
|
||||
Some(Command::Login { callback_url, .. }) => {
|
||||
assert_eq!(
|
||||
callback_url.as_deref(),
|
||||
Some("http://localhost:1455/auth/callback?code=x&state=y")
|
||||
);
|
||||
}
|
||||
other => panic!("unexpected command: {:?}", other),
|
||||
}
|
||||
|
||||
let args = Args::try_parse_from(["jcode", "login", "--auth-code", "abc123"]).unwrap();
|
||||
match args.command {
|
||||
Some(Command::Login { auth_code, .. }) => {
|
||||
assert_eq!(auth_code.as_deref(), Some("abc123"));
|
||||
}
|
||||
other => panic!("unexpected command: {:?}", other),
|
||||
}
|
||||
|
||||
let args = Args::try_parse_from([
|
||||
"jcode",
|
||||
"login",
|
||||
"--complete",
|
||||
"--google-access-tier",
|
||||
"readonly",
|
||||
])
|
||||
.unwrap();
|
||||
match args.command {
|
||||
Some(Command::Login {
|
||||
complete,
|
||||
google_access_tier,
|
||||
..
|
||||
}) => {
|
||||
assert!(complete);
|
||||
assert_eq!(google_access_tier, Some(GoogleAccessTierArg::Readonly));
|
||||
}
|
||||
other => panic!("unexpected command: {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quiet_global_flag_parses() {
|
||||
let args = Args::try_parse_from(["jcode", "--quiet", "model", "list"]).unwrap();
|
||||
assert!(args.quiet);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn acp_subcommand_parses() {
|
||||
let args = Args::try_parse_from(["jcode", "acp"]).unwrap();
|
||||
match args.command {
|
||||
Some(Command::Acp) => {}
|
||||
other => panic!("unexpected command: {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_json_subcommand_parses() {
|
||||
let args = Args::try_parse_from(["jcode", "run", "--json", "hello"]).unwrap();
|
||||
match args.command {
|
||||
Some(Command::Run {
|
||||
json,
|
||||
ndjson,
|
||||
message,
|
||||
}) => {
|
||||
assert!(json);
|
||||
assert!(!ndjson);
|
||||
assert_eq!(message, "hello");
|
||||
}
|
||||
other => panic!("unexpected command: {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_ndjson_subcommand_parses() {
|
||||
let args = Args::try_parse_from(["jcode", "run", "--ndjson", "hello"]).unwrap();
|
||||
match args.command {
|
||||
Some(Command::Run {
|
||||
json,
|
||||
ndjson,
|
||||
message,
|
||||
}) => {
|
||||
assert!(!json);
|
||||
assert!(ndjson);
|
||||
assert_eq!(message, "hello");
|
||||
}
|
||||
other => panic!("unexpected command: {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn version_subcommand_parses() {
|
||||
let args = Args::try_parse_from(["jcode", "version", "--json"]).unwrap();
|
||||
match args.command {
|
||||
Some(Command::Version { json }) => assert!(json),
|
||||
other => panic!("unexpected command: {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn usage_subcommand_parses() {
|
||||
let args = Args::try_parse_from(["jcode", "usage", "--json"]).unwrap();
|
||||
match args.command {
|
||||
Some(Command::Usage { json }) => assert!(json),
|
||||
other => panic!("unexpected command: {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auth_status_subcommand_parses() {
|
||||
let args = Args::try_parse_from(["jcode", "auth", "status", "--json"]).unwrap();
|
||||
match args.command {
|
||||
Some(Command::Auth(AuthCommand::Status { json })) => assert!(json),
|
||||
other => panic!("unexpected command: {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auth_doctor_subcommand_parses() {
|
||||
let args = Args::try_parse_from(["jcode", "auth", "doctor", "openai", "--validate", "--json"])
|
||||
.unwrap();
|
||||
match args.command {
|
||||
Some(Command::Auth(AuthCommand::Doctor {
|
||||
provider,
|
||||
validate,
|
||||
json,
|
||||
})) => {
|
||||
assert_eq!(provider.as_deref(), Some("openai"));
|
||||
assert!(validate);
|
||||
assert!(json);
|
||||
}
|
||||
other => panic!("unexpected command: {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_list_subcommand_parses() {
|
||||
let args = Args::try_parse_from(["jcode", "provider", "list", "--json"]).unwrap();
|
||||
match args.command {
|
||||
Some(Command::Provider(ProviderCommand::List { json })) => assert!(json),
|
||||
other => panic!("unexpected command: {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_current_subcommand_parses() {
|
||||
let args = Args::try_parse_from(["jcode", "provider", "current", "--json"]).unwrap();
|
||||
match args.command {
|
||||
Some(Command::Provider(ProviderCommand::Current { json })) => assert!(json),
|
||||
other => panic!("unexpected command: {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_add_subcommand_parses_agent_friendly_flags() {
|
||||
let args = Args::try_parse_from([
|
||||
"jcode",
|
||||
"provider",
|
||||
"add",
|
||||
"my-api",
|
||||
"--base-url",
|
||||
"https://llm.example.com/v1",
|
||||
"--model",
|
||||
"model-a",
|
||||
"--context-window",
|
||||
"128000",
|
||||
"--api-key-stdin",
|
||||
"--auth",
|
||||
"bearer",
|
||||
"--set-default",
|
||||
"--json",
|
||||
])
|
||||
.unwrap();
|
||||
|
||||
match args.command {
|
||||
Some(Command::Provider(ProviderCommand::Add {
|
||||
name,
|
||||
base_url,
|
||||
model,
|
||||
context_window,
|
||||
api_key_stdin,
|
||||
auth,
|
||||
set_default,
|
||||
json,
|
||||
..
|
||||
})) => {
|
||||
assert_eq!(name, "my-api");
|
||||
assert_eq!(base_url, "https://llm.example.com/v1");
|
||||
assert_eq!(model, "model-a");
|
||||
assert_eq!(context_window, Some(128000));
|
||||
assert!(api_key_stdin);
|
||||
assert_eq!(auth, Some(ProviderAuthArg::Bearer));
|
||||
assert!(set_default);
|
||||
assert!(json);
|
||||
}
|
||||
other => panic!("unexpected command: {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restart_save_subcommand_parses() {
|
||||
let args = Args::try_parse_from(["jcode", "restart", "save"]).unwrap();
|
||||
match args.command {
|
||||
Some(Command::Restart {
|
||||
action: RestartCommand::Save {
|
||||
auto_restore: false,
|
||||
},
|
||||
}) => {}
|
||||
other => panic!("unexpected command: {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restart_save_auto_restore_flag_parses() {
|
||||
let args = Args::try_parse_from(["jcode", "restart", "save", "--auto-restore"]).unwrap();
|
||||
match args.command {
|
||||
Some(Command::Restart {
|
||||
action: RestartCommand::Save { auto_restore: true },
|
||||
}) => {}
|
||||
other => panic!("unexpected command: {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
/// Contract test for the onboarding agent-repair brief (see
|
||||
/// `jcode-tui::tui::app::onboarding_repair::build_repair_brief`). The brief
|
||||
/// tells a coding agent to run these exact commands to diagnose and fix a
|
||||
/// failed login. If any flag here stops parsing, the brief would hand the agent
|
||||
/// a broken command, so this guards the agent-facing CLI contract.
|
||||
#[test]
|
||||
fn onboarding_repair_brief_commands_are_valid_cli() {
|
||||
// Diagnose.
|
||||
Args::try_parse_from(["jcode", "auth-test", "--provider", "openai", "--json"])
|
||||
.expect("auth-test --provider --json must parse");
|
||||
Args::try_parse_from(["jcode", "auth-test", "--all-configured", "--json"])
|
||||
.expect("auth-test --all-configured --json must parse");
|
||||
Args::try_parse_from(["jcode", "auth", "doctor"]).expect("auth doctor must parse");
|
||||
|
||||
// Fix: OAuth and API-key logins.
|
||||
Args::try_parse_from(["jcode", "login", "--provider", "openai"])
|
||||
.expect("login --provider must parse");
|
||||
Args::try_parse_from(["jcode", "login", "--provider", "openai", "--api-key", "k"])
|
||||
.expect("login --provider --api-key must parse");
|
||||
|
||||
// Fix: custom OpenAI-compatible endpoint via provider add + key on stdin.
|
||||
Args::try_parse_from([
|
||||
"jcode",
|
||||
"provider",
|
||||
"add",
|
||||
"my-endpoint",
|
||||
"--base-url",
|
||||
"https://api.example.com/v1",
|
||||
"--model",
|
||||
"some-model",
|
||||
"--api-key-stdin",
|
||||
])
|
||||
.expect("provider add --base-url --model --api-key-stdin must parse");
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
use anyhow::{Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::time::Duration;
|
||||
|
||||
const DEFAULT_AUTH_TEST_PROVIDER_PROMPT: &str =
|
||||
"Reply with exactly AUTH_TEST_OK and nothing else. Do not call tools.";
|
||||
const AUTH_TEST_TOOL_NAME: &str = "bash";
|
||||
const AUTH_TEST_TOOL_COMMAND: &str = "echo JCODE_TOOL_OK";
|
||||
const AUTH_TEST_TOOL_OUTPUT_MARKER: &str = "JCODE_TOOL_OK";
|
||||
const DEFAULT_AUTH_TEST_TOOL_PROMPT: &str = "Use exactly one bash tool call with command exactly `echo JCODE_TOOL_OK`. After you see the tool result, reply with exactly AUTH_TEST_OK and nothing else.";
|
||||
|
||||
include!("auth_test/types.rs");
|
||||
include!("auth_test/run.rs");
|
||||
include!("auth_test/probes.rs");
|
||||
include!("auth_test/choice.rs");
|
||||
@@ -0,0 +1,591 @@
|
||||
#[derive(Debug)]
|
||||
pub(crate) enum AuthTestChoicePlan {
|
||||
Run { model: Option<String> },
|
||||
Skip(String),
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct OpenAiCompatibleModelsResponse {
|
||||
#[serde(default)]
|
||||
data: Vec<OpenAiCompatibleModelInfo>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct OpenAiCompatibleModelInfo {
|
||||
id: String,
|
||||
}
|
||||
|
||||
pub(crate) async fn auth_test_choice_plan(
|
||||
choice: &super::provider_init::ProviderChoice,
|
||||
model: Option<&str>,
|
||||
) -> Result<AuthTestChoicePlan> {
|
||||
if let Some(model) = model.map(str::trim).filter(|model| !model.is_empty()) {
|
||||
return Ok(AuthTestChoicePlan::Run {
|
||||
model: Some(model.to_string()),
|
||||
});
|
||||
}
|
||||
|
||||
let Some(profile) = super::provider_init::profile_for_choice(choice) else {
|
||||
return Ok(AuthTestChoicePlan::Run { model: None });
|
||||
};
|
||||
let resolved = crate::provider_catalog::resolve_openai_compatible_profile(profile);
|
||||
if resolved.default_model.is_some() {
|
||||
return Ok(AuthTestChoicePlan::Run { model: None });
|
||||
}
|
||||
|
||||
crate::provider_catalog::apply_openai_compatible_profile_env(Some(profile));
|
||||
let discovered_model = discover_openai_compatible_validation_model(&resolved).await?;
|
||||
if let Some(model) = discovered_model {
|
||||
return Ok(AuthTestChoicePlan::Run { model: Some(model) });
|
||||
}
|
||||
|
||||
Ok(AuthTestChoicePlan::Skip(format!(
|
||||
"Skipped: {} local endpoint reported no models. Re-run `jcode auth-test --provider {} --model <local-model>` or set a default model first.",
|
||||
resolved.display_name,
|
||||
choice.as_arg_value()
|
||||
)))
|
||||
}
|
||||
|
||||
pub(crate) fn tool_smoke_skip_detail_for_choice(
|
||||
choice: &super::provider_init::ProviderChoice,
|
||||
model: Option<&str>,
|
||||
) -> Option<String> {
|
||||
if matches!(choice, super::provider_init::ProviderChoice::Cursor) {
|
||||
return Some(
|
||||
"Skipped: the Cursor native agent transport is text-only in jcode (it does not expose \
|
||||
tool calls over agent.v1.AgentService/Run). Basic provider smoke still validates chat."
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
if matches!(choice, super::provider_init::ProviderChoice::Fpt) {
|
||||
let model = effective_openai_compatible_auth_test_model(
|
||||
crate::provider_catalog::FPT_PROFILE,
|
||||
model,
|
||||
)
|
||||
.unwrap_or_else(|| "the selected model".to_string());
|
||||
return Some(format!(
|
||||
"Skipped: FPT model '{}' is hosted on an OpenAI-compatible/vLLM-style endpoint that rejects OpenAI tool-choice requests unless server-side auto-tool parsing is enabled. Basic provider smoke still validates chat.",
|
||||
model
|
||||
));
|
||||
}
|
||||
|
||||
if !matches!(choice, super::provider_init::ProviderChoice::NvidiaNim) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let model = effective_openai_compatible_auth_test_model(
|
||||
crate::provider_catalog::NVIDIA_NIM_PROFILE,
|
||||
model,
|
||||
)?;
|
||||
if !nvidia_nim_model_supports_openai_tools(&model) {
|
||||
return Some(format!(
|
||||
"Skipped: NVIDIA NIM model '{}' is documented by NVIDIA as a request/status-polling model rather than a standard OpenAI tool-calling chat model. Basic provider smoke still validates chat; choose a NIM model with OpenAI tool support to run tool_smoke.",
|
||||
model
|
||||
));
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn effective_openai_compatible_auth_test_model(
|
||||
profile: crate::provider_catalog::OpenAiCompatibleProfile,
|
||||
model: Option<&str>,
|
||||
) -> Option<String> {
|
||||
model
|
||||
.map(str::trim)
|
||||
.filter(|model| !model.is_empty())
|
||||
.map(ToString::to_string)
|
||||
.or_else(|| {
|
||||
std::env::var("JCODE_OPENROUTER_MODEL")
|
||||
.ok()
|
||||
.map(|model| model.trim().to_string())
|
||||
.filter(|model| !model.is_empty())
|
||||
})
|
||||
.or_else(|| {
|
||||
let cfg = crate::config::config();
|
||||
let default_provider_is_profile = cfg
|
||||
.provider
|
||||
.default_provider
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.map(|provider| {
|
||||
provider == profile.id
|
||||
|| crate::provider_catalog::resolve_openai_compatible_profile_selection(
|
||||
provider,
|
||||
)
|
||||
.map(|resolved| resolved.id == profile.id)
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.unwrap_or(false);
|
||||
default_provider_is_profile
|
||||
.then(|| cfg.provider.default_model.clone())
|
||||
.flatten()
|
||||
})
|
||||
.or_else(|| {
|
||||
crate::provider_catalog::resolve_openai_compatible_profile(profile).default_model
|
||||
})
|
||||
}
|
||||
|
||||
fn nvidia_nim_model_supports_openai_tools(model: &str) -> bool {
|
||||
let normalized = model.trim().to_ascii_lowercase().replace('_', "-");
|
||||
// NVIDIA documents moonshotai/kimi-k2.6 under the request/status-polling
|
||||
// visual-model API family. The OpenAI-compatible chat endpoint accepts basic
|
||||
// prompts for this model, but tool-enabled smoke has been observed returning
|
||||
// a server-side `unhashable type: 'dict'` 500 when sent OpenAI tools.
|
||||
!(normalized.contains("moonshotai/kimi-k2.6") || normalized.contains("moonshotai/kimi-k2-6"))
|
||||
}
|
||||
|
||||
async fn discover_openai_compatible_validation_model(
|
||||
profile: &crate::provider_catalog::ResolvedOpenAiCompatibleProfile,
|
||||
) -> Result<Option<String>> {
|
||||
let url = format!("{}/models", profile.api_base.trim_end_matches('/'));
|
||||
let mut request = crate::provider::shared_http_client().get(&url);
|
||||
if matches!(profile.id.as_str(), "kimi" | "alibaba-coding-plan" | "zai") {
|
||||
request = request
|
||||
.header("User-Agent", "claude-cli/1.0.0")
|
||||
.header("x-app", "cli");
|
||||
}
|
||||
if let Some(api_key) = crate::provider_catalog::load_api_key_from_env_or_config(
|
||||
&profile.api_key_env,
|
||||
&profile.env_file,
|
||||
) {
|
||||
request = request.bearer_auth(api_key);
|
||||
}
|
||||
|
||||
let response = request.send().await.with_context(|| {
|
||||
format!(
|
||||
"Failed to query {} models from {} during auth-test validation",
|
||||
profile.display_name, url
|
||||
)
|
||||
})?;
|
||||
let status = response.status();
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
if !status.is_success() {
|
||||
anyhow::bail!(
|
||||
"{} model discovery failed (HTTP {}): {}",
|
||||
profile.display_name,
|
||||
status,
|
||||
body.trim()
|
||||
);
|
||||
}
|
||||
|
||||
let parsed: OpenAiCompatibleModelsResponse =
|
||||
serde_json::from_str(&body).with_context(|| {
|
||||
format!(
|
||||
"Failed to parse {} model discovery response from {}",
|
||||
profile.display_name, url
|
||||
)
|
||||
})?;
|
||||
Ok(parsed
|
||||
.data
|
||||
.into_iter()
|
||||
.map(|model| model.id.trim().to_string())
|
||||
.find(|model| !model.is_empty()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod nvidia_nim_tool_smoke_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn skips_kimi_k2_6_tool_smoke_for_nvidia_nim() {
|
||||
let detail = tool_smoke_skip_detail_for_choice(
|
||||
&super::super::provider_init::ProviderChoice::NvidiaNim,
|
||||
Some("moonshotai/kimi-k2.6"),
|
||||
)
|
||||
.expect("kimi-k2.6 should skip NIM tool smoke");
|
||||
|
||||
assert!(detail.contains("NVIDIA NIM model 'moonshotai/kimi-k2.6'"));
|
||||
assert!(detail.contains("tool_smoke"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn allows_other_nvidia_nim_models_to_attempt_tool_smoke() {
|
||||
assert!(
|
||||
tool_smoke_skip_detail_for_choice(
|
||||
&super::super::provider_init::ProviderChoice::NvidiaNim,
|
||||
Some("nvidia/llama-3.1-nemotron-ultra-253b-v1"),
|
||||
)
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn does_not_apply_nvidia_skip_to_other_providers() {
|
||||
assert!(
|
||||
tool_smoke_skip_detail_for_choice(
|
||||
&super::super::provider_init::ProviderChoice::Groq,
|
||||
Some("moonshotai/kimi-k2.6"),
|
||||
)
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skips_fpt_tool_smoke_for_vllm_auto_tool_choice_gap() {
|
||||
let detail = tool_smoke_skip_detail_for_choice(
|
||||
&super::super::provider_init::ProviderChoice::Fpt,
|
||||
Some("FPT.AI-KIE-v1.7"),
|
||||
)
|
||||
.expect("FPT should skip tool smoke when server-side auto tool parsing is unavailable");
|
||||
|
||||
assert!(detail.contains("FPT model 'FPT.AI-KIE-v1.7'"));
|
||||
assert!(detail.contains("vLLM-style endpoint"));
|
||||
assert!(detail.contains("Basic provider smoke still validates chat"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn allows_cerebras_models_to_attempt_tool_smoke() {
|
||||
assert!(
|
||||
tool_smoke_skip_detail_for_choice(
|
||||
&super::super::provider_init::ProviderChoice::Cerebras,
|
||||
Some("gpt-oss-120b"),
|
||||
)
|
||||
.is_none()
|
||||
);
|
||||
assert!(
|
||||
tool_smoke_skip_detail_for_choice(
|
||||
&super::super::provider_init::ProviderChoice::Cerebras,
|
||||
Some("zai-glm-4.7"),
|
||||
)
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async fn run_provider_smoke_for_choice(
|
||||
choice: &super::provider_init::ProviderChoice,
|
||||
model: Option<&str>,
|
||||
prompt: &str,
|
||||
) -> Result<String> {
|
||||
run_auth_test_with_retry(async || {
|
||||
let provider = super::provider_init::init_provider_for_validation(choice, model)
|
||||
.await
|
||||
.with_context(|| format!("Failed to initialize {} provider", choice.as_arg_value()))?;
|
||||
let output = provider
|
||||
.complete_simple(prompt, "")
|
||||
.await
|
||||
.with_context(|| format!("{} provider smoke prompt failed", choice.as_arg_value()))?;
|
||||
Ok(output.trim().to_string())
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
async fn run_provider_tool_smoke_for_choice(
|
||||
choice: &super::provider_init::ProviderChoice,
|
||||
model: Option<&str>,
|
||||
prompt: &str,
|
||||
) -> Result<String> {
|
||||
run_auth_test_with_retry(async || {
|
||||
let (provider, registry) =
|
||||
super::provider_init::init_provider_and_registry_for_validation(choice, model)
|
||||
.await
|
||||
.with_context(|| {
|
||||
format!("Failed to initialize {} provider", choice.as_arg_value())
|
||||
})?;
|
||||
registry
|
||||
.register_mcp_tools(None, None, Some("auth-test".to_string()))
|
||||
.await;
|
||||
|
||||
let allowed_tools = HashSet::from([AUTH_TEST_TOOL_NAME.to_string()]);
|
||||
let mut agent = crate::agent::Agent::new_with_session(
|
||||
provider,
|
||||
registry,
|
||||
crate::session::Session::create(None, None),
|
||||
Some(allowed_tools),
|
||||
);
|
||||
let transcript_start = agent.messages().len();
|
||||
let output = agent.run_once_capture(prompt).await.with_context(|| {
|
||||
format!(
|
||||
"{} tool-enabled smoke prompt failed during agent turn execution",
|
||||
choice.as_arg_value()
|
||||
)
|
||||
})?;
|
||||
validate_auth_test_tool_smoke_transcript(&agent.messages()[transcript_start..], &output)
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"{} tool-enabled smoke prompt did not complete a valid real Jcode tool loop",
|
||||
choice.as_arg_value()
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(output.trim().to_string())
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
fn validate_auth_test_tool_smoke_transcript(
|
||||
messages: &[crate::session::StoredMessage],
|
||||
output: &str,
|
||||
) -> Result<()> {
|
||||
if output.trim() != "AUTH_TEST_OK" {
|
||||
anyhow::bail!(
|
||||
"tool smoke final response was {:?}, expected exactly AUTH_TEST_OK",
|
||||
output.trim()
|
||||
);
|
||||
}
|
||||
|
||||
let mut tool_uses = Vec::new();
|
||||
let mut tool_results = Vec::new();
|
||||
for message in messages {
|
||||
for block in &message.content {
|
||||
match block {
|
||||
crate::message::ContentBlock::ToolUse { id, name, input, .. } => {
|
||||
tool_uses.push((id.as_str(), name.as_str(), input));
|
||||
}
|
||||
crate::message::ContentBlock::ToolResult {
|
||||
tool_use_id,
|
||||
content,
|
||||
is_error,
|
||||
} => {
|
||||
tool_results.push((tool_use_id.as_str(), content.as_str(), *is_error));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ensure_auth_test_tool_count(tool_uses.len())?;
|
||||
let (tool_id, tool_name, input) = tool_uses[0];
|
||||
if tool_id.trim().is_empty() {
|
||||
anyhow::bail!("tool smoke emitted tool call with empty id");
|
||||
}
|
||||
let tool_call = crate::message::ToolCall {
|
||||
id: tool_id.to_string(),
|
||||
name: tool_name.to_string(),
|
||||
input: input.clone(),
|
||||
intent: None, thought_signature: None, };
|
||||
if let Some(error) = tool_call.validation_error() {
|
||||
anyhow::bail!("tool smoke emitted invalid tool call: {error}");
|
||||
}
|
||||
if tool_name != AUTH_TEST_TOOL_NAME {
|
||||
anyhow::bail!(
|
||||
"tool smoke used unexpected tool {:?}; expected {:?}",
|
||||
tool_name,
|
||||
AUTH_TEST_TOOL_NAME
|
||||
);
|
||||
}
|
||||
let command = input
|
||||
.get("command")
|
||||
.and_then(|value| value.as_str())
|
||||
.unwrap_or_default()
|
||||
.trim();
|
||||
if command != AUTH_TEST_TOOL_COMMAND {
|
||||
anyhow::bail!(
|
||||
"tool smoke used unsafe or unexpected command {:?}; expected {:?}",
|
||||
command,
|
||||
AUTH_TEST_TOOL_COMMAND
|
||||
);
|
||||
}
|
||||
|
||||
let matching_results = tool_results
|
||||
.iter()
|
||||
.filter(|(tool_use_id, _, _)| *tool_use_id == tool_id)
|
||||
.collect::<Vec<_>>();
|
||||
if matching_results.len() != 1 {
|
||||
anyhow::bail!(
|
||||
"tool smoke expected exactly one matching tool result for {}, got {}",
|
||||
tool_id,
|
||||
matching_results.len()
|
||||
);
|
||||
}
|
||||
let (_, content, is_error) = matching_results[0];
|
||||
if is_error.unwrap_or(false) {
|
||||
anyhow::bail!("tool smoke tool result was marked as an error: {content}");
|
||||
}
|
||||
if !content.contains(AUTH_TEST_TOOL_OUTPUT_MARKER) {
|
||||
anyhow::bail!(
|
||||
"tool smoke result did not contain marker {:?}: {}",
|
||||
AUTH_TEST_TOOL_OUTPUT_MARKER,
|
||||
crate::util::truncate_str(content, 500)
|
||||
);
|
||||
}
|
||||
|
||||
let errored_results = tool_results
|
||||
.iter()
|
||||
.filter(|(_, content, is_error)| {
|
||||
is_error.unwrap_or(false) || content.contains("Invalid tool call")
|
||||
})
|
||||
.count();
|
||||
if errored_results > 0 {
|
||||
anyhow::bail!("tool smoke transcript contained {errored_results} errored tool result(s)");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn ensure_auth_test_tool_count(count: usize) -> Result<()> {
|
||||
match count {
|
||||
1 => Ok(()),
|
||||
0 => anyhow::bail!("tool smoke did not emit any tool call"),
|
||||
other => anyhow::bail!("tool smoke emitted {other} tool calls; expected exactly one"),
|
||||
}
|
||||
}
|
||||
|
||||
async fn run_auth_test_with_retry<F, Fut>(mut f: F) -> Result<String>
|
||||
where
|
||||
F: FnMut() -> Fut,
|
||||
Fut: std::future::Future<Output = Result<String>>,
|
||||
{
|
||||
const RETRY_DELAYS: &[Duration] = &[Duration::from_secs(3), Duration::from_secs(8)];
|
||||
|
||||
let mut last_err = None;
|
||||
for (attempt, delay) in RETRY_DELAYS.iter().enumerate() {
|
||||
match f().await {
|
||||
Ok(output) => return Ok(output),
|
||||
Err(err) if auth_test_error_is_retryable(&err) => {
|
||||
last_err = Some(err);
|
||||
crate::logging::warn(&format!(
|
||||
"auth-test transient failure on attempt {} - retrying in {}s",
|
||||
attempt + 1,
|
||||
delay.as_secs()
|
||||
));
|
||||
tokio::time::sleep(*delay).await;
|
||||
}
|
||||
Err(err) => return Err(err),
|
||||
}
|
||||
}
|
||||
|
||||
match f().await {
|
||||
Ok(output) => Ok(output),
|
||||
Err(err) if last_err.is_some() => Err(err),
|
||||
Err(err) => Err(err),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn auth_test_error_is_retryable(err: &anyhow::Error) -> bool {
|
||||
let text = format!("{err:#}").to_ascii_lowercase();
|
||||
[
|
||||
"http 429",
|
||||
"too many requests",
|
||||
"resource_exhausted",
|
||||
"rate_limit_exceeded",
|
||||
"rate limit",
|
||||
"temporarily unavailable",
|
||||
"timeout",
|
||||
"connection reset",
|
||||
"service unavailable",
|
||||
"http 500",
|
||||
"http 502",
|
||||
"http 503",
|
||||
"http 504",
|
||||
]
|
||||
.iter()
|
||||
.any(|needle| text.contains(needle))
|
||||
}
|
||||
|
||||
fn print_auth_test_reports(reports: &[AuthTestProviderReport]) {
|
||||
for report in reports {
|
||||
println!("=== auth-test: {} ===", report.provider);
|
||||
if !report.credential_paths.is_empty() {
|
||||
println!("credential paths:");
|
||||
for path in &report.credential_paths {
|
||||
println!(" - {}", path);
|
||||
}
|
||||
}
|
||||
for step in &report.steps {
|
||||
let marker = if step.ok { "✓" } else { "✗" };
|
||||
println!("{} {} — {}", marker, step.name, step.detail);
|
||||
}
|
||||
if let Some(output) = report.smoke_output.as_deref() {
|
||||
println!("smoke output: {}", output);
|
||||
}
|
||||
if let Some(output) = report.tool_smoke_output.as_deref() {
|
||||
println!("tool smoke output: {}", output);
|
||||
}
|
||||
println!("result: {}\n", if report.success { "PASS" } else { "FAIL" });
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod auth_tool_smoke_tests {
|
||||
use super::*;
|
||||
|
||||
fn stored_message(
|
||||
role: crate::message::Role,
|
||||
content: Vec<crate::message::ContentBlock>,
|
||||
) -> crate::session::StoredMessage {
|
||||
crate::session::StoredMessage {
|
||||
id: "msg_test".to_string(),
|
||||
role,
|
||||
content,
|
||||
display_role: None,
|
||||
timestamp: None,
|
||||
tool_duration_ms: None,
|
||||
token_usage: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn valid_tool_transcript() -> Vec<crate::session::StoredMessage> {
|
||||
vec![
|
||||
stored_message(
|
||||
crate::message::Role::Assistant,
|
||||
vec![crate::message::ContentBlock::ToolUse {
|
||||
id: "call_1".to_string(),
|
||||
name: AUTH_TEST_TOOL_NAME.to_string(),
|
||||
input: serde_json::json!({"command": AUTH_TEST_TOOL_COMMAND}), thought_signature: None, }],
|
||||
),
|
||||
stored_message(
|
||||
crate::message::Role::User,
|
||||
vec![crate::message::ContentBlock::ToolResult {
|
||||
tool_use_id: "call_1".to_string(),
|
||||
content: format!("{}\n", AUTH_TEST_TOOL_OUTPUT_MARKER),
|
||||
is_error: None,
|
||||
}],
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auth_test_tool_smoke_validation_accepts_real_successful_loop() {
|
||||
validate_auth_test_tool_smoke_transcript(&valid_tool_transcript(), "AUTH_TEST_OK")
|
||||
.expect("valid tool smoke transcript");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auth_test_tool_smoke_validation_rejects_no_tool_call() {
|
||||
let err = validate_auth_test_tool_smoke_transcript(&[], "AUTH_TEST_OK")
|
||||
.expect_err("missing tool call should fail")
|
||||
.to_string();
|
||||
assert!(err.contains("did not emit any tool call"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auth_test_tool_smoke_validation_rejects_empty_tool_name() {
|
||||
let mut messages = valid_tool_transcript();
|
||||
if let crate::message::ContentBlock::ToolUse { name, .. } = &mut messages[0].content[0] {
|
||||
name.clear();
|
||||
}
|
||||
let err = validate_auth_test_tool_smoke_transcript(&messages, "AUTH_TEST_OK")
|
||||
.expect_err("empty tool name should fail")
|
||||
.to_string();
|
||||
assert!(err.contains("tool name must not be empty"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auth_test_tool_smoke_validation_rejects_unexpected_command() {
|
||||
let mut messages = valid_tool_transcript();
|
||||
if let crate::message::ContentBlock::ToolUse { input, .. } = &mut messages[0].content[0] {
|
||||
*input = serde_json::json!({"command": "ls"});
|
||||
}
|
||||
let err = validate_auth_test_tool_smoke_transcript(&messages, "AUTH_TEST_OK")
|
||||
.expect_err("unexpected command should fail")
|
||||
.to_string();
|
||||
assert!(err.contains("unsafe or unexpected command"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auth_test_tool_smoke_validation_rejects_tool_result_error() {
|
||||
let mut messages = valid_tool_transcript();
|
||||
if let crate::message::ContentBlock::ToolResult { is_error, .. } =
|
||||
&mut messages[1].content[0]
|
||||
{
|
||||
*is_error = Some(true);
|
||||
}
|
||||
let err = validate_auth_test_tool_smoke_transcript(&messages, "AUTH_TEST_OK")
|
||||
.expect_err("errored tool result should fail")
|
||||
.to_string();
|
||||
assert!(err.contains("marked as an error"), "{err}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
fn generic_credential_paths_for_provider(
|
||||
provider: crate::provider_catalog::LoginProviderDescriptor,
|
||||
) -> Vec<String> {
|
||||
let Ok(config_dir) = crate::storage::app_config_dir() else {
|
||||
return Vec::new();
|
||||
};
|
||||
|
||||
match provider.target {
|
||||
crate::provider_catalog::LoginProviderTarget::Jcode => {
|
||||
vec![config_dir.join(crate::subscription_catalog::JCODE_ENV_FILE)]
|
||||
}
|
||||
crate::provider_catalog::LoginProviderTarget::OpenRouter => {
|
||||
vec![config_dir.join("openrouter.env")]
|
||||
}
|
||||
crate::provider_catalog::LoginProviderTarget::OpenAiApiKey => {
|
||||
vec![config_dir.join("openai.env")]
|
||||
}
|
||||
crate::provider_catalog::LoginProviderTarget::Azure => {
|
||||
vec![config_dir.join(crate::auth::azure::ENV_FILE)]
|
||||
}
|
||||
crate::provider_catalog::LoginProviderTarget::Bedrock => {
|
||||
vec![config_dir.join(crate::provider::bedrock::ENV_FILE)]
|
||||
}
|
||||
crate::provider_catalog::LoginProviderTarget::OpenAiCompatible(profile) => {
|
||||
// When a named config profile is active (selected via
|
||||
// `--provider-profile`), its credentials come from the profile's
|
||||
// configured `api_key_env`/`env_file`, not the built-in
|
||||
// `openai-compatible.env`. Report that path so the audit is accurate
|
||||
// (#402).
|
||||
if let Some((_key_env, env_file)) =
|
||||
crate::provider_catalog::active_named_provider_profile_credential_source()
|
||||
{
|
||||
vec![config_dir.join(env_file)]
|
||||
} else {
|
||||
let resolved =
|
||||
crate::provider_catalog::resolve_openai_compatible_profile(profile);
|
||||
vec![config_dir.join(resolved.env_file)]
|
||||
}
|
||||
}
|
||||
_ => Vec::new(),
|
||||
}
|
||||
.into_iter()
|
||||
.map(|path| path.display().to_string())
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn auth_state_label(state: crate::auth::AuthState) -> &'static str {
|
||||
match state {
|
||||
crate::auth::AuthState::Available => "available",
|
||||
crate::auth::AuthState::Expired => "expired",
|
||||
crate::auth::AuthState::NotConfigured => "not_configured",
|
||||
}
|
||||
}
|
||||
|
||||
fn probe_generic_provider_auth(
|
||||
provider: crate::provider_catalog::LoginProviderDescriptor,
|
||||
report: &mut AuthTestProviderReport,
|
||||
) {
|
||||
// Keep generic provider probes provider-local. A DeepSeek/Z.AI/OpenRouter
|
||||
// auth-test should never be delayed or wedged by an unrelated Cursor/Gemini
|
||||
// external auth probe.
|
||||
let status = crate::auth::AuthStatus::check_fast();
|
||||
let assessment = status.assessment_for_provider(provider);
|
||||
report.push_step(
|
||||
"credential_probe",
|
||||
assessment.is_available(),
|
||||
format!(
|
||||
"{} auth status is {} ({}).",
|
||||
provider.display_name,
|
||||
auth_state_label(assessment.state),
|
||||
assessment.method_detail,
|
||||
),
|
||||
);
|
||||
report.push_step(
|
||||
"refresh_probe",
|
||||
true,
|
||||
"Skipped: provider does not expose a dedicated refresh probe in jcode today.".to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
async fn probe_claude_auth(report: &mut AuthTestProviderReport) {
|
||||
if let Some(creds) = push_result_step(
|
||||
report,
|
||||
"credential_probe",
|
||||
crate::auth::claude::load_credentials(),
|
||||
|creds| {
|
||||
format!(
|
||||
"Loaded Claude credentials (expires_at={}).",
|
||||
creds.expires_at
|
||||
)
|
||||
},
|
||||
) {
|
||||
push_result_step(
|
||||
report,
|
||||
"refresh_probe",
|
||||
crate::auth::oauth::refresh_claude_tokens(&creds.refresh_token).await,
|
||||
|tokens| {
|
||||
format!(
|
||||
"Claude token refresh succeeded (new_expires_at={}).",
|
||||
tokens.expires_at
|
||||
)
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async fn probe_openai_auth(report: &mut AuthTestProviderReport) {
|
||||
if let Some(creds) = push_result_step(
|
||||
report,
|
||||
"credential_probe",
|
||||
crate::auth::codex::load_credentials(),
|
||||
|creds| {
|
||||
if creds.refresh_token.trim().is_empty() {
|
||||
"Loaded OpenAI API key credentials (no refresh token present).".to_string()
|
||||
} else {
|
||||
format!(
|
||||
"Loaded OpenAI OAuth credentials (expires_at={:?}).",
|
||||
creds.expires_at
|
||||
)
|
||||
}
|
||||
},
|
||||
) {
|
||||
if creds.refresh_token.trim().is_empty() {
|
||||
report.push_step(
|
||||
"refresh_probe",
|
||||
true,
|
||||
"Skipped: OpenAI is using API key auth, not OAuth.",
|
||||
);
|
||||
} else {
|
||||
push_result_step(
|
||||
report,
|
||||
"refresh_probe",
|
||||
crate::auth::oauth::refresh_openai_tokens(&creds.refresh_token).await,
|
||||
|tokens| {
|
||||
format!(
|
||||
"OpenAI token refresh succeeded (new_expires_at={}).",
|
||||
tokens.expires_at
|
||||
)
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn probe_gemini_auth(report: &mut AuthTestProviderReport) {
|
||||
// Prefer the official Gemini Developer API key when configured: it is a
|
||||
// static credential (no refresh handshake) pointed at
|
||||
// generativelanguage.googleapis.com, so we only assert that it loads.
|
||||
if crate::auth::gemini::has_api_key() {
|
||||
let detail = match crate::auth::gemini::api_key() {
|
||||
Some(_) => "Loaded Gemini Developer API key (generativelanguage.googleapis.com).",
|
||||
None => "Gemini Developer API key reported present but failed to load.",
|
||||
};
|
||||
report.push_step("credential_probe", true, detail);
|
||||
return;
|
||||
}
|
||||
|
||||
if push_result_step(
|
||||
report,
|
||||
"credential_probe",
|
||||
crate::auth::gemini::load_tokens(),
|
||||
|tokens| {
|
||||
format!(
|
||||
"Loaded Gemini tokens{} (expires_at={}).",
|
||||
auth_email_suffix(tokens.email.as_deref()),
|
||||
tokens.expires_at
|
||||
)
|
||||
},
|
||||
)
|
||||
.is_some()
|
||||
{
|
||||
push_result_step(
|
||||
report,
|
||||
"refresh_probe",
|
||||
crate::auth::gemini::load_or_refresh_tokens().await,
|
||||
|tokens| {
|
||||
format!(
|
||||
"Gemini token load/refresh succeeded (expires_at={}).",
|
||||
tokens.expires_at
|
||||
)
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async fn probe_antigravity_auth(report: &mut AuthTestProviderReport) {
|
||||
if push_result_step(
|
||||
report,
|
||||
"credential_probe",
|
||||
crate::auth::antigravity::load_tokens(),
|
||||
|tokens| {
|
||||
format!(
|
||||
"Loaded Antigravity OAuth tokens{} (expires_at={}).",
|
||||
auth_email_suffix(tokens.email.as_deref()),
|
||||
tokens.expires_at
|
||||
)
|
||||
},
|
||||
)
|
||||
.is_some()
|
||||
{
|
||||
push_result_step(
|
||||
report,
|
||||
"refresh_probe",
|
||||
crate::auth::antigravity::load_or_refresh_tokens().await,
|
||||
|tokens| {
|
||||
format!(
|
||||
"Antigravity token load/refresh succeeded (expires_at={}).",
|
||||
tokens.expires_at
|
||||
)
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async fn probe_google_auth(report: &mut AuthTestProviderReport) {
|
||||
let creds_result = crate::auth::google::load_credentials();
|
||||
let tokens_result = crate::auth::google::load_tokens();
|
||||
match (creds_result, tokens_result) {
|
||||
(Ok(creds), Ok(tokens)) => {
|
||||
report.push_step(
|
||||
"credential_probe",
|
||||
true,
|
||||
format!(
|
||||
"Loaded Google credentials (client_id={}...) and Gmail tokens{}.",
|
||||
&creds.client_id[..20.min(creds.client_id.len())],
|
||||
auth_email_suffix(tokens.email.as_deref())
|
||||
),
|
||||
);
|
||||
match crate::auth::google::get_valid_token().await {
|
||||
Ok(_) => report.push_step(
|
||||
"refresh_probe",
|
||||
true,
|
||||
"Google/Gmail token load/refresh succeeded.".to_string(),
|
||||
),
|
||||
Err(err) => report.push_step("refresh_probe", false, err.to_string()),
|
||||
}
|
||||
}
|
||||
(Err(err), _) => report.push_step("credential_probe", false, err.to_string()),
|
||||
(_, Err(err)) => report.push_step("credential_probe", false, err.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
async fn probe_copilot_auth(report: &mut AuthTestProviderReport) {
|
||||
if let Some(token) = push_result_step(
|
||||
report,
|
||||
"credential_probe",
|
||||
crate::auth::copilot::load_github_token(),
|
||||
|token| {
|
||||
format!(
|
||||
"Loaded GitHub OAuth token for Copilot ({} chars).",
|
||||
token.len()
|
||||
)
|
||||
},
|
||||
) {
|
||||
let client = crate::provider::shared_http_client();
|
||||
push_result_step(
|
||||
report,
|
||||
"refresh_probe",
|
||||
crate::auth::copilot::exchange_github_token(&client, &token).await,
|
||||
|api_token| {
|
||||
format!(
|
||||
"Exchanged GitHub token for Copilot API token (expires_at={}).",
|
||||
api_token.expires_at
|
||||
)
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async fn probe_cursor_auth(report: &mut AuthTestProviderReport) {
|
||||
let has_api_key = crate::auth::cursor::has_cursor_api_key();
|
||||
let has_auth_file = crate::auth::cursor::has_cursor_auth_file_token();
|
||||
let has_vscdb = crate::auth::cursor::has_cursor_vscdb_token();
|
||||
let ok = has_api_key || has_auth_file || has_vscdb;
|
||||
report.push_step(
|
||||
"credential_probe",
|
||||
ok,
|
||||
format!(
|
||||
"Cursor native auth sources: api_key={}, auth_json={}, vscdb_token={}",
|
||||
has_api_key, has_auth_file, has_vscdb
|
||||
),
|
||||
);
|
||||
report.push_step(
|
||||
"refresh_probe",
|
||||
true,
|
||||
"Skipped: Cursor provider does not expose a native refresh-token probe in jcode today."
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,853 @@
|
||||
async fn maybe_run_auth_test_smoke(
|
||||
report: &mut AuthTestProviderReport,
|
||||
kind: AuthTestSmokeKind,
|
||||
target: AuthTestTarget,
|
||||
model: Option<&str>,
|
||||
enabled: bool,
|
||||
prompt: &str,
|
||||
) {
|
||||
if enabled && report.success && target.supports_smoke() {
|
||||
// Some providers validate chat but cannot run the tool smoke. The
|
||||
// Cursor native agent transport is text-only (no tool calls over
|
||||
// agent.v1.AgentService/Run), so skip the tool smoke with an
|
||||
// explanation instead of hanging waiting for a tool call.
|
||||
if matches!(kind, AuthTestSmokeKind::Tool)
|
||||
&& matches!(target, AuthTestTarget::Cursor)
|
||||
{
|
||||
report.push_step(
|
||||
kind.step_name(),
|
||||
true,
|
||||
"Skipped: the Cursor native agent transport is text-only in jcode (it does not \
|
||||
expose tool calls over agent.v1.AgentService/Run). Basic provider smoke still \
|
||||
validates chat."
|
||||
.to_string(),
|
||||
);
|
||||
return;
|
||||
}
|
||||
match kind.run(target, model, prompt).await {
|
||||
Ok(output) => {
|
||||
let ok = output.contains("AUTH_TEST_OK");
|
||||
kind.set_output(report, output.clone());
|
||||
report.push_step(
|
||||
kind.step_name(),
|
||||
ok,
|
||||
if ok {
|
||||
kind.success_detail().to_string()
|
||||
} else {
|
||||
kind.failure_detail(&output)
|
||||
},
|
||||
);
|
||||
}
|
||||
Err(err) => report.push_step(kind.step_name(), false, format!("{err:#}")),
|
||||
}
|
||||
} else if !target.supports_smoke() {
|
||||
report.push_step(kind.step_name(), true, kind.unsupported_detail());
|
||||
} else if !enabled {
|
||||
report.push_step(kind.step_name(), true, kind.skipped_by_flag_detail());
|
||||
}
|
||||
}
|
||||
|
||||
async fn maybe_run_auth_test_smoke_for_choice(
|
||||
report: &mut AuthTestProviderReport,
|
||||
kind: AuthTestSmokeKind,
|
||||
choice: &super::provider_init::ProviderChoice,
|
||||
model: Option<&str>,
|
||||
enabled: bool,
|
||||
prompt: &str,
|
||||
) {
|
||||
if enabled && report.success {
|
||||
match auth_test_choice_plan(choice, model).await {
|
||||
Ok(AuthTestChoicePlan::Run { model }) => {
|
||||
if matches!(kind, AuthTestSmokeKind::Tool)
|
||||
&& let Some(detail) =
|
||||
tool_smoke_skip_detail_for_choice(choice, model.as_deref())
|
||||
{
|
||||
report.push_step(kind.step_name(), true, detail);
|
||||
return;
|
||||
}
|
||||
match kind.run_for_choice(choice, model.as_deref(), prompt).await {
|
||||
Ok(output) => {
|
||||
let ok = output.contains("AUTH_TEST_OK");
|
||||
kind.set_output(report, output.clone());
|
||||
report.push_step(
|
||||
kind.step_name(),
|
||||
ok,
|
||||
if ok {
|
||||
kind.success_detail().to_string()
|
||||
} else {
|
||||
kind.failure_detail(&output)
|
||||
},
|
||||
);
|
||||
}
|
||||
Err(err) => report.push_step(kind.step_name(), false, format!("{err:#}")),
|
||||
}
|
||||
}
|
||||
Ok(AuthTestChoicePlan::Skip(detail)) => {
|
||||
report.push_step(kind.step_name(), true, detail);
|
||||
}
|
||||
Err(err) => report.push_step(kind.step_name(), false, format!("{err:#}")),
|
||||
}
|
||||
} else if !enabled {
|
||||
report.push_step(kind.step_name(), true, kind.skipped_by_flag_detail());
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn run_post_login_validation(
|
||||
provider: crate::provider_catalog::LoginProviderDescriptor,
|
||||
) -> Result<()> {
|
||||
run_post_login_validation_inner(provider, true).await
|
||||
}
|
||||
|
||||
pub(crate) async fn run_post_login_validation_quiet(
|
||||
provider: crate::provider_catalog::LoginProviderDescriptor,
|
||||
) -> Result<()> {
|
||||
run_post_login_validation_inner(provider, false).await
|
||||
}
|
||||
|
||||
async fn run_post_login_validation_inner(
|
||||
provider: crate::provider_catalog::LoginProviderDescriptor,
|
||||
verbose: bool,
|
||||
) -> Result<()> {
|
||||
let Some(choice) = super::provider_init::choice_for_login_provider(provider) else {
|
||||
crate::logging::auth_event(
|
||||
"post_login_validation_skipped",
|
||||
provider.id,
|
||||
&[("reason", "no_runtime_provider_choice")],
|
||||
);
|
||||
if verbose {
|
||||
eprintln!(
|
||||
"\nSkipping automatic runtime validation for {}. Auto Import can add multiple providers; run `jcode auth-test --all-configured` to validate them.",
|
||||
provider.display_name
|
||||
);
|
||||
}
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
super::provider_init::apply_login_provider_profile_env(provider);
|
||||
crate::logging::auth_event(
|
||||
"post_login_validation_started",
|
||||
provider.id,
|
||||
&[("choice", choice.as_arg_value())],
|
||||
);
|
||||
|
||||
if verbose {
|
||||
eprintln!(
|
||||
"\nValidating {} login with live auth/runtime checks...",
|
||||
provider.display_name
|
||||
);
|
||||
}
|
||||
|
||||
let report = if let Some(target) = AuthTestTarget::from_provider_choice(&choice) {
|
||||
populate_auth_test_target_report(
|
||||
target,
|
||||
None,
|
||||
true,
|
||||
true,
|
||||
DEFAULT_AUTH_TEST_PROVIDER_PROMPT,
|
||||
DEFAULT_AUTH_TEST_TOOL_PROMPT,
|
||||
AuthTestProviderReport::new(target),
|
||||
)
|
||||
.await
|
||||
} else {
|
||||
populate_generic_auth_test_report(
|
||||
provider,
|
||||
choice,
|
||||
None,
|
||||
true,
|
||||
true,
|
||||
DEFAULT_AUTH_TEST_PROVIDER_PROMPT,
|
||||
DEFAULT_AUTH_TEST_TOOL_PROMPT,
|
||||
AuthTestProviderReport::new_generic(
|
||||
choice.as_arg_value().to_string(),
|
||||
generic_credential_paths_for_provider(provider),
|
||||
),
|
||||
)
|
||||
.await
|
||||
};
|
||||
|
||||
persist_auth_test_report(&report, None);
|
||||
let step_count = report.steps.len().to_string();
|
||||
crate::logging::auth_event(
|
||||
"post_login_validation_completed",
|
||||
provider.id,
|
||||
&[
|
||||
("choice", choice.as_arg_value()),
|
||||
("success", if report.success { "true" } else { "false" }),
|
||||
("steps", step_count.as_str()),
|
||||
],
|
||||
);
|
||||
if verbose {
|
||||
print_auth_test_reports(std::slice::from_ref(&report));
|
||||
}
|
||||
|
||||
if report.success {
|
||||
Ok(())
|
||||
} else if AuthTestTarget::from_provider_choice(&choice).is_some() {
|
||||
anyhow::bail!(
|
||||
"Post-login validation failed for {}. Credentials were saved, but jcode could not verify runtime readiness. Re-run `jcode auth-test --provider {}` for details.",
|
||||
provider.display_name,
|
||||
choice.as_arg_value()
|
||||
)
|
||||
} else {
|
||||
anyhow::bail!(
|
||||
"Post-login validation failed for {}. Credentials were saved, but jcode could not verify runtime readiness. Re-test with `jcode --provider {} run \"Reply with exactly AUTH_TEST_OK and nothing else.\"` after fixing the provider/runtime.",
|
||||
provider.display_name,
|
||||
choice.as_arg_value()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn run_auth_test_coverage_command(
|
||||
emit_json: bool,
|
||||
output_path: Option<&str>,
|
||||
coverage_path: Option<&str>,
|
||||
gap_limit: usize,
|
||||
) -> Result<()> {
|
||||
let coverage_path = coverage_path.map(std::path::Path::new);
|
||||
let (coverage, path) = crate::live_tests::load_coverage(coverage_path)?;
|
||||
let summary = crate::live_tests::strict_live_provider_model_coverage_summary(
|
||||
&coverage,
|
||||
path.display().to_string(),
|
||||
);
|
||||
|
||||
if emit_json || output_path.is_some() {
|
||||
let json = serde_json::to_string_pretty(&summary)?;
|
||||
if let Some(path) = output_path {
|
||||
std::fs::write(path, &json)
|
||||
.with_context(|| format!("failed to write auth-test coverage report to {path}"))?;
|
||||
}
|
||||
if emit_json {
|
||||
println!("{json}");
|
||||
}
|
||||
} else {
|
||||
print!(
|
||||
"{}",
|
||||
crate::live_tests::format_strict_live_provider_model_coverage_summary(
|
||||
&summary, gap_limit,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn run_auth_test_context_audit_command(
|
||||
choice: &super::provider_init::ProviderChoice,
|
||||
all_configured: bool,
|
||||
emit_json: bool,
|
||||
output_path: Option<&str>,
|
||||
) -> Result<()> {
|
||||
let targets = resolve_auth_test_targets(choice, all_configured)?;
|
||||
let mut reports = Vec::new();
|
||||
|
||||
for target in targets {
|
||||
reports.push(run_context_audit_for_target(target).await);
|
||||
}
|
||||
|
||||
let report_json = (emit_json || output_path.is_some())
|
||||
.then(|| serde_json::to_string_pretty(&reports))
|
||||
.transpose()?;
|
||||
|
||||
if let Some(path) = output_path {
|
||||
std::fs::write(path, report_json.as_deref().unwrap_or("[]"))
|
||||
.with_context(|| format!("failed to write auth-test context audit report to {path}"))?;
|
||||
}
|
||||
|
||||
if emit_json {
|
||||
println!("{}", report_json.as_deref().unwrap_or("[]"));
|
||||
} else {
|
||||
print_context_audit_reports(&reports);
|
||||
}
|
||||
|
||||
if reports.iter().all(|report| report.success) {
|
||||
Ok(())
|
||||
} else {
|
||||
anyhow::bail!("One or more live context audits failed")
|
||||
}
|
||||
}
|
||||
|
||||
async fn run_context_audit_for_target(
|
||||
target: ResolvedAuthTestTarget,
|
||||
) -> AuthTestContextAuditReport {
|
||||
let (provider_id, display_name, supports_openrouter_catalog) = match target {
|
||||
ResolvedAuthTestTarget::Generic { provider, choice } => {
|
||||
super::provider_init::apply_login_provider_profile_env(provider);
|
||||
let supports_openrouter_catalog = matches!(
|
||||
provider.target,
|
||||
crate::provider_catalog::LoginProviderTarget::OpenRouter
|
||||
| crate::provider_catalog::LoginProviderTarget::OpenAiCompatible(_)
|
||||
);
|
||||
(
|
||||
choice.as_arg_value().to_string(),
|
||||
provider.display_name.to_string(),
|
||||
supports_openrouter_catalog,
|
||||
)
|
||||
}
|
||||
ResolvedAuthTestTarget::Detailed(target) => (
|
||||
target.label().to_string(),
|
||||
target.label().to_string(),
|
||||
false,
|
||||
),
|
||||
};
|
||||
|
||||
if !supports_openrouter_catalog {
|
||||
return AuthTestContextAuditReport {
|
||||
provider: provider_id,
|
||||
display_name,
|
||||
checked_models: 0,
|
||||
skipped_models_without_context: 0,
|
||||
mismatches: Vec::new(),
|
||||
success: true,
|
||||
detail:
|
||||
"Skipped: provider does not use the OpenRouter/OpenAI-compatible live catalog path."
|
||||
.to_string(),
|
||||
};
|
||||
}
|
||||
|
||||
audit_openrouter_context_windows(provider_id, display_name).await
|
||||
}
|
||||
|
||||
async fn audit_openrouter_context_windows(
|
||||
provider_id: String,
|
||||
display_name: String,
|
||||
) -> AuthTestContextAuditReport {
|
||||
use crate::provider::Provider as _;
|
||||
|
||||
let provider = match jcode_provider_openrouter_runtime::OpenRouterProvider::new() {
|
||||
Ok(provider) => provider,
|
||||
Err(err) => {
|
||||
return AuthTestContextAuditReport {
|
||||
provider: provider_id,
|
||||
display_name,
|
||||
checked_models: 0,
|
||||
skipped_models_without_context: 0,
|
||||
mismatches: Vec::new(),
|
||||
success: false,
|
||||
detail: format!("Failed to initialize provider: {err:#}"),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
let models = match provider.refresh_models().await {
|
||||
Ok(models) => models,
|
||||
Err(err) => {
|
||||
return AuthTestContextAuditReport {
|
||||
provider: provider_id,
|
||||
display_name,
|
||||
checked_models: 0,
|
||||
skipped_models_without_context: 0,
|
||||
mismatches: Vec::new(),
|
||||
success: false,
|
||||
detail: format!("Failed to fetch live model catalog: {err:#}"),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
let mut checked_models = 0usize;
|
||||
let mut skipped_models_without_context = 0usize;
|
||||
let mut mismatches = Vec::new();
|
||||
|
||||
for model in models {
|
||||
let Some(catalog_context_window) = model.context_length.map(|value| value as usize) else {
|
||||
skipped_models_without_context += 1;
|
||||
continue;
|
||||
};
|
||||
checked_models += 1;
|
||||
|
||||
if let Err(err) = provider.set_model(&model.id) {
|
||||
mismatches.push(AuthTestContextModelReport {
|
||||
model: model.id,
|
||||
catalog_context_window,
|
||||
resolved_context_window: 0,
|
||||
ok: false,
|
||||
});
|
||||
crate::logging::info(&format!(
|
||||
"live context audit could not switch model for {}: {err:#}",
|
||||
provider_id
|
||||
));
|
||||
continue;
|
||||
}
|
||||
|
||||
let resolved_context_window = provider.context_window();
|
||||
if resolved_context_window != catalog_context_window {
|
||||
mismatches.push(AuthTestContextModelReport {
|
||||
model: model.id,
|
||||
catalog_context_window,
|
||||
resolved_context_window,
|
||||
ok: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let success = mismatches.is_empty();
|
||||
let detail = if success {
|
||||
format!(
|
||||
"Checked {checked_models} live catalog models with context metadata; skipped {skipped_models_without_context} without context metadata."
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"Found {} context-window mismatches across {checked_models} live catalog models with context metadata; skipped {skipped_models_without_context} without context metadata.",
|
||||
mismatches.len()
|
||||
)
|
||||
};
|
||||
|
||||
AuthTestContextAuditReport {
|
||||
provider: provider_id,
|
||||
display_name,
|
||||
checked_models,
|
||||
skipped_models_without_context,
|
||||
mismatches,
|
||||
success,
|
||||
detail,
|
||||
}
|
||||
}
|
||||
|
||||
fn print_context_audit_reports(reports: &[AuthTestContextAuditReport]) {
|
||||
for report in reports {
|
||||
println!("{} ({})", report.display_name, report.provider);
|
||||
println!(" success: {}", report.success);
|
||||
println!(" {}", report.detail);
|
||||
for mismatch in report.mismatches.iter().take(20) {
|
||||
println!(
|
||||
" mismatch: {} catalog={} resolved={}",
|
||||
mismatch.model, mismatch.catalog_context_window, mismatch.resolved_context_window
|
||||
);
|
||||
}
|
||||
if report.mismatches.len() > 20 {
|
||||
println!(" ... {} more mismatches", report.mismatches.len() - 20);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[expect(
|
||||
clippy::too_many_arguments,
|
||||
reason = "CLI auth-test entrypoint maps directly from command-line flags"
|
||||
)]
|
||||
pub async fn run_auth_test_command(
|
||||
choice: &super::provider_init::ProviderChoice,
|
||||
model: Option<&str>,
|
||||
login: bool,
|
||||
all_configured: bool,
|
||||
no_smoke: bool,
|
||||
no_tool_smoke: bool,
|
||||
prompt: Option<&str>,
|
||||
emit_json: bool,
|
||||
output_path: Option<&str>,
|
||||
) -> Result<()> {
|
||||
let targets = resolve_auth_test_targets(choice, all_configured)?;
|
||||
let provider_smoke_prompt = prompt.unwrap_or(DEFAULT_AUTH_TEST_PROVIDER_PROMPT);
|
||||
let tool_smoke_prompt = prompt.unwrap_or(DEFAULT_AUTH_TEST_TOOL_PROMPT);
|
||||
|
||||
let mut reports = Vec::new();
|
||||
for target in targets {
|
||||
let report = match target {
|
||||
ResolvedAuthTestTarget::Detailed(target) => {
|
||||
run_auth_test_target(
|
||||
target,
|
||||
model,
|
||||
login,
|
||||
!no_smoke,
|
||||
!no_tool_smoke,
|
||||
provider_smoke_prompt,
|
||||
tool_smoke_prompt,
|
||||
)
|
||||
.await
|
||||
}
|
||||
ResolvedAuthTestTarget::Generic { provider, choice } => {
|
||||
let mut report = AuthTestProviderReport::new_generic(
|
||||
choice.as_arg_value().to_string(),
|
||||
generic_credential_paths_for_provider(provider),
|
||||
);
|
||||
if login {
|
||||
match super::login::run_login(
|
||||
&choice,
|
||||
None,
|
||||
super::login::LoginOptions::default(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(()) => report.push_step("login", true, "Login flow completed."),
|
||||
Err(err) => report.push_step("login", false, err.to_string()),
|
||||
}
|
||||
}
|
||||
populate_generic_auth_test_report(
|
||||
provider,
|
||||
choice,
|
||||
model,
|
||||
!no_smoke,
|
||||
!no_tool_smoke,
|
||||
provider_smoke_prompt,
|
||||
tool_smoke_prompt,
|
||||
report,
|
||||
)
|
||||
.await
|
||||
}
|
||||
};
|
||||
persist_auth_test_report(&report, model);
|
||||
reports.push(report);
|
||||
}
|
||||
|
||||
let report_json = (emit_json || output_path.is_some())
|
||||
.then(|| serde_json::to_string_pretty(&reports))
|
||||
.transpose()?;
|
||||
|
||||
if let Some(path) = output_path {
|
||||
std::fs::write(path, report_json.as_deref().unwrap_or("[]"))
|
||||
.with_context(|| format!("failed to write auth-test report to {}", path))?;
|
||||
}
|
||||
|
||||
if emit_json {
|
||||
println!("{}", report_json.as_deref().unwrap_or("[]"));
|
||||
} else {
|
||||
print_auth_test_reports(&reports);
|
||||
}
|
||||
|
||||
if reports.iter().all(|report| report.success) {
|
||||
Ok(())
|
||||
} else {
|
||||
anyhow::bail!("One or more auth tests failed")
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn resolve_auth_test_targets(
|
||||
choice: &super::provider_init::ProviderChoice,
|
||||
all_configured: bool,
|
||||
) -> Result<Vec<ResolvedAuthTestTarget>> {
|
||||
if all_configured || matches!(choice, super::provider_init::ProviderChoice::Auto) {
|
||||
// Auth-test discovery must not run slow or blocking provider-global probes.
|
||||
// Generic OpenAI-compatible providers only need local env/config detection,
|
||||
// and detailed providers perform their own provider-specific checks later.
|
||||
let status = crate::auth::AuthStatus::check_fast();
|
||||
let targets = configured_auth_test_targets(&status);
|
||||
if targets.is_empty() {
|
||||
anyhow::bail!(
|
||||
"No configured supported auth providers found. Run `jcode login --provider <provider>` first, or choose an explicit --provider."
|
||||
);
|
||||
}
|
||||
return Ok(targets);
|
||||
}
|
||||
|
||||
ResolvedAuthTestTarget::from_choice(choice)
|
||||
.map(|target| vec![target])
|
||||
.ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"Provider '{}' is not yet supported by `jcode auth-test`.",
|
||||
choice.as_arg_value()
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn configured_auth_test_targets(
|
||||
status: &crate::auth::AuthStatus,
|
||||
) -> Vec<ResolvedAuthTestTarget> {
|
||||
crate::provider_catalog::auth_status_login_providers()
|
||||
.into_iter()
|
||||
.filter(|provider| status.assessment_for_provider(*provider).is_configured())
|
||||
.filter_map(ResolvedAuthTestTarget::from_provider)
|
||||
.collect()
|
||||
}
|
||||
|
||||
async fn run_auth_test_target(
|
||||
target: AuthTestTarget,
|
||||
model: Option<&str>,
|
||||
login: bool,
|
||||
run_smoke: bool,
|
||||
run_tool_smoke: bool,
|
||||
provider_smoke_prompt: &str,
|
||||
tool_smoke_prompt: &str,
|
||||
) -> AuthTestProviderReport {
|
||||
let mut report = AuthTestProviderReport::new(target);
|
||||
|
||||
if login {
|
||||
match super::login::run_login(
|
||||
&target.provider_choice(),
|
||||
None,
|
||||
super::login::LoginOptions::default(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(()) => report.push_step("login", true, "Login flow completed."),
|
||||
Err(err) => report.push_step("login", false, err.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
populate_auth_test_target_report(
|
||||
target,
|
||||
model,
|
||||
run_smoke,
|
||||
run_tool_smoke,
|
||||
provider_smoke_prompt,
|
||||
tool_smoke_prompt,
|
||||
report,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn populate_auth_test_target_report(
|
||||
target: AuthTestTarget,
|
||||
model: Option<&str>,
|
||||
run_smoke: bool,
|
||||
run_tool_smoke: bool,
|
||||
provider_smoke_prompt: &str,
|
||||
tool_smoke_prompt: &str,
|
||||
mut report: AuthTestProviderReport,
|
||||
) -> AuthTestProviderReport {
|
||||
match target {
|
||||
AuthTestTarget::Claude => probe_claude_auth(&mut report).await,
|
||||
AuthTestTarget::Openai => probe_openai_auth(&mut report).await,
|
||||
AuthTestTarget::Gemini => probe_gemini_auth(&mut report).await,
|
||||
AuthTestTarget::Antigravity => probe_antigravity_auth(&mut report).await,
|
||||
AuthTestTarget::Google => probe_google_auth(&mut report).await,
|
||||
AuthTestTarget::Copilot => probe_copilot_auth(&mut report).await,
|
||||
AuthTestTarget::Cursor => probe_cursor_auth(&mut report).await,
|
||||
}
|
||||
|
||||
maybe_run_auth_test_smoke(
|
||||
&mut report,
|
||||
AuthTestSmokeKind::Provider,
|
||||
target,
|
||||
model,
|
||||
run_smoke,
|
||||
provider_smoke_prompt,
|
||||
)
|
||||
.await;
|
||||
|
||||
maybe_run_auth_test_smoke(
|
||||
&mut report,
|
||||
AuthTestSmokeKind::Tool,
|
||||
target,
|
||||
model,
|
||||
run_tool_smoke,
|
||||
tool_smoke_prompt,
|
||||
)
|
||||
.await;
|
||||
|
||||
report
|
||||
}
|
||||
|
||||
#[expect(
|
||||
clippy::too_many_arguments,
|
||||
reason = "Auth-test helper carries explicit smoke and prompt controls until structured options land"
|
||||
)]
|
||||
async fn populate_generic_auth_test_report(
|
||||
provider: crate::provider_catalog::LoginProviderDescriptor,
|
||||
choice: super::provider_init::ProviderChoice,
|
||||
model: Option<&str>,
|
||||
run_smoke: bool,
|
||||
run_tool_smoke: bool,
|
||||
provider_smoke_prompt: &str,
|
||||
tool_smoke_prompt: &str,
|
||||
mut report: AuthTestProviderReport,
|
||||
) -> AuthTestProviderReport {
|
||||
super::provider_init::apply_login_provider_profile_env(provider);
|
||||
probe_generic_provider_auth(provider, &mut report);
|
||||
|
||||
maybe_run_auth_test_smoke_for_choice(
|
||||
&mut report,
|
||||
AuthTestSmokeKind::Provider,
|
||||
&choice,
|
||||
model,
|
||||
run_smoke,
|
||||
provider_smoke_prompt,
|
||||
)
|
||||
.await;
|
||||
|
||||
maybe_run_auth_test_smoke_for_choice(
|
||||
&mut report,
|
||||
AuthTestSmokeKind::Tool,
|
||||
&choice,
|
||||
model,
|
||||
run_tool_smoke,
|
||||
tool_smoke_prompt,
|
||||
)
|
||||
.await;
|
||||
|
||||
report
|
||||
}
|
||||
|
||||
fn persist_auth_test_report(report: &AuthTestProviderReport, model: Option<&str>) {
|
||||
let step_map = report
|
||||
.steps
|
||||
.iter()
|
||||
.map(|step| (step.name.as_str(), step.ok))
|
||||
.collect::<HashMap<_, _>>();
|
||||
let summary = report
|
||||
.steps
|
||||
.iter()
|
||||
.find(|step| !step.ok)
|
||||
.map(|step| format!("{}: {}", step.name, step.detail))
|
||||
.or_else(|| {
|
||||
report
|
||||
.steps
|
||||
.last()
|
||||
.map(|step| format!("{}: {}", step.name, step.detail))
|
||||
})
|
||||
.unwrap_or_else(|| "No validation steps recorded.".to_string());
|
||||
|
||||
let record = crate::auth::validation::ProviderValidationRecord {
|
||||
checked_at_ms: chrono::Utc::now().timestamp_millis(),
|
||||
success: report.success,
|
||||
provider_smoke_ok: step_map.get("provider_smoke").copied(),
|
||||
tool_smoke_ok: step_map.get("tool_smoke").copied(),
|
||||
summary,
|
||||
};
|
||||
|
||||
if let Err(err) = crate::auth::validation::save(&report.provider, record) {
|
||||
crate::logging::warn(&format!(
|
||||
"failed to persist auth validation result for {}: {}",
|
||||
report.provider, err
|
||||
));
|
||||
}
|
||||
|
||||
if let Err(err) = persist_auth_test_live_verification_event(report, model) {
|
||||
crate::logging::warn(&format!(
|
||||
"failed to persist auth-test live verification event for {}: {}",
|
||||
report.provider, err
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
fn persist_auth_test_live_verification_event(
|
||||
report: &AuthTestProviderReport,
|
||||
model: Option<&str>,
|
||||
) -> Result<()> {
|
||||
let mut stages = Vec::new();
|
||||
let mut expected = Vec::new();
|
||||
let mut capabilities = Vec::new();
|
||||
|
||||
for step in &report.steps {
|
||||
match step.name.as_str() {
|
||||
"credential_probe" => {
|
||||
expected.push(crate::live_tests::checkpoints::AUTH_CREDENTIAL_LOADED);
|
||||
stages.push(auth_test_step_stage(
|
||||
crate::live_tests::checkpoints::AUTH_CREDENTIAL_LOADED,
|
||||
step,
|
||||
));
|
||||
}
|
||||
"provider_smoke" => {
|
||||
capabilities.push("provider_smoke");
|
||||
capabilities.push("non_streaming_chat_completion");
|
||||
expected.push(crate::live_tests::checkpoints::NON_STREAMING_CHAT_COMPLETION);
|
||||
stages.push(auth_test_step_stage(
|
||||
crate::live_tests::checkpoints::NON_STREAMING_CHAT_COMPLETION,
|
||||
step,
|
||||
));
|
||||
}
|
||||
"tool_smoke" => {
|
||||
let tool_smoke_skipped = auth_test_step_is_skipped(step);
|
||||
if !tool_smoke_skipped {
|
||||
capabilities.push("real_jcode_tool_smoke");
|
||||
}
|
||||
expected.push(crate::live_tests::checkpoints::TOOL_CALL_PARSE);
|
||||
expected.push(crate::live_tests::checkpoints::TOOL_EXECUTION_LOOP);
|
||||
expected.push(crate::live_tests::checkpoints::TOOL_RESULT_FOLLOWUP);
|
||||
expected.push(crate::live_tests::checkpoints::REAL_JCODE_TOOL_SMOKE);
|
||||
let stage = auth_test_step_stage(
|
||||
crate::live_tests::checkpoints::REAL_JCODE_TOOL_SMOKE,
|
||||
step,
|
||||
)
|
||||
.with_evidence("tool_name", serde_json::json!(AUTH_TEST_TOOL_NAME))
|
||||
.with_evidence("tool_command", serde_json::json!(AUTH_TEST_TOOL_COMMAND));
|
||||
stages.push(stage.clone());
|
||||
stages.push(auth_test_tool_derived_stage(
|
||||
crate::live_tests::checkpoints::TOOL_CALL_PARSE,
|
||||
step,
|
||||
));
|
||||
stages.push(auth_test_tool_derived_stage(
|
||||
crate::live_tests::checkpoints::TOOL_EXECUTION_LOOP,
|
||||
step,
|
||||
));
|
||||
stages.push(auth_test_tool_derived_stage(
|
||||
crate::live_tests::checkpoints::TOOL_RESULT_FOLLOWUP,
|
||||
step,
|
||||
));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
if expected.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let result = if report.success {
|
||||
crate::live_tests::LiveVerificationResult::Passed
|
||||
} else {
|
||||
crate::live_tests::LiveVerificationResult::Failed
|
||||
};
|
||||
let (coverage_provider_id, coverage_provider_label) =
|
||||
auth_test_coverage_provider_identity(report);
|
||||
let mut event = crate::live_tests::LiveVerificationEvent::new(
|
||||
"auth_test_real_jcode_runtime",
|
||||
coverage_provider_id,
|
||||
coverage_provider_label,
|
||||
crate::live_tests::LiveVerificationAuth::non_secret("auth-test", None::<String>),
|
||||
result,
|
||||
)
|
||||
.with_expected_checkpoints(expected)
|
||||
.with_capabilities(capabilities)
|
||||
.with_stages(stages)
|
||||
.with_metadata(
|
||||
"checkpoint_taxonomy_version",
|
||||
serde_json::json!(crate::live_tests::CHECKPOINT_TAXONOMY_VERSION),
|
||||
)
|
||||
.with_metadata("auth_test_steps", serde_json::json!(report.steps));
|
||||
if let Some(model) = model.map(str::trim).filter(|model| !model.is_empty()) {
|
||||
event = event.with_model(model.to_string());
|
||||
}
|
||||
crate::live_tests::append_event(&event)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn auth_test_coverage_provider_identity(report: &AuthTestProviderReport) -> (String, String) {
|
||||
if report.provider == "openai-compatible"
|
||||
&& let Ok(profile_name) = std::env::var("JCODE_NAMED_PROVIDER_PROFILE")
|
||||
{
|
||||
let profile_name = profile_name.trim();
|
||||
if !profile_name.is_empty() {
|
||||
let label = crate::config::config()
|
||||
.providers
|
||||
.get(profile_name)
|
||||
.map(|profile| {
|
||||
format!(
|
||||
"{} (custom OpenAI-compatible: {})",
|
||||
profile_name, profile.base_url
|
||||
)
|
||||
})
|
||||
.unwrap_or_else(|| format!("{} (custom OpenAI-compatible)", profile_name));
|
||||
return (profile_name.to_string(), label);
|
||||
}
|
||||
}
|
||||
|
||||
(report.provider.clone(), report.provider.clone())
|
||||
}
|
||||
|
||||
fn auth_test_step_stage(
|
||||
checkpoint: &'static str,
|
||||
step: &AuthTestStepReport,
|
||||
) -> crate::live_tests::LiveVerificationStage {
|
||||
let status = if auth_test_step_is_skipped(step) {
|
||||
crate::live_tests::LiveVerificationStageStatus::Skipped
|
||||
} else if step.ok {
|
||||
crate::live_tests::LiveVerificationStageStatus::Passed
|
||||
} else {
|
||||
crate::live_tests::LiveVerificationStageStatus::Failed
|
||||
};
|
||||
crate::live_tests::LiveVerificationStage::new(checkpoint, status)
|
||||
.with_evidence("auth_test_step", serde_json::json!(step.name))
|
||||
.with_evidence("detail", serde_json::json!(step.detail))
|
||||
}
|
||||
|
||||
fn auth_test_step_is_skipped(step: &AuthTestStepReport) -> bool {
|
||||
step.detail.trim_start().starts_with("Skipped:")
|
||||
}
|
||||
|
||||
fn auth_test_tool_derived_stage(
|
||||
checkpoint: &'static str,
|
||||
step: &AuthTestStepReport,
|
||||
) -> crate::live_tests::LiveVerificationStage {
|
||||
auth_test_step_stage(checkpoint, step).with_evidence(
|
||||
"derived_from",
|
||||
serde_json::json!(crate::live_tests::checkpoints::REAL_JCODE_TOOL_SMOKE),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,356 @@
|
||||
#[expect(
|
||||
clippy::large_enum_variant,
|
||||
reason = "Generic auth-test targets carry provider descriptors until this CLI path is refactored"
|
||||
)]
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(crate) enum ResolvedAuthTestTarget {
|
||||
Detailed(AuthTestTarget),
|
||||
Generic {
|
||||
provider: crate::provider_catalog::LoginProviderDescriptor,
|
||||
choice: super::provider_init::ProviderChoice,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum AuthTestTarget {
|
||||
Claude,
|
||||
Openai,
|
||||
Gemini,
|
||||
Antigravity,
|
||||
Google,
|
||||
Copilot,
|
||||
Cursor,
|
||||
}
|
||||
|
||||
impl AuthTestTarget {
|
||||
fn provider_choice(self) -> super::provider_init::ProviderChoice {
|
||||
match self {
|
||||
Self::Claude => super::provider_init::ProviderChoice::Claude,
|
||||
Self::Openai => super::provider_init::ProviderChoice::Openai,
|
||||
Self::Gemini => super::provider_init::ProviderChoice::Gemini,
|
||||
Self::Antigravity => super::provider_init::ProviderChoice::Antigravity,
|
||||
Self::Google => super::provider_init::ProviderChoice::Google,
|
||||
Self::Copilot => super::provider_init::ProviderChoice::Copilot,
|
||||
Self::Cursor => super::provider_init::ProviderChoice::Cursor,
|
||||
}
|
||||
}
|
||||
|
||||
fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::Claude => "claude",
|
||||
Self::Openai => "openai",
|
||||
Self::Gemini => "gemini",
|
||||
Self::Antigravity => "antigravity",
|
||||
Self::Google => "google",
|
||||
Self::Copilot => "copilot",
|
||||
Self::Cursor => "cursor",
|
||||
}
|
||||
}
|
||||
|
||||
fn supports_smoke(self) -> bool {
|
||||
!matches!(self, Self::Google)
|
||||
}
|
||||
|
||||
#[allow(deprecated)]
|
||||
fn from_provider_choice(choice: &super::provider_init::ProviderChoice) -> Option<Self> {
|
||||
match choice {
|
||||
super::provider_init::ProviderChoice::Claude
|
||||
| super::provider_init::ProviderChoice::ClaudeSubprocess => Some(Self::Claude),
|
||||
super::provider_init::ProviderChoice::Openai => Some(Self::Openai),
|
||||
super::provider_init::ProviderChoice::Gemini => Some(Self::Gemini),
|
||||
super::provider_init::ProviderChoice::Antigravity => Some(Self::Antigravity),
|
||||
super::provider_init::ProviderChoice::Google => Some(Self::Google),
|
||||
super::provider_init::ProviderChoice::Copilot => Some(Self::Copilot),
|
||||
super::provider_init::ProviderChoice::Cursor => Some(Self::Cursor),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn credential_paths(self) -> Result<Vec<String>> {
|
||||
match self {
|
||||
Self::Claude => Ok(vec![
|
||||
crate::auth::claude::jcode_path()?.display().to_string(),
|
||||
crate::storage::user_home_path(".claude/.credentials.json")?
|
||||
.display()
|
||||
.to_string(),
|
||||
crate::storage::user_home_path(".local/share/opencode/auth.json")?
|
||||
.display()
|
||||
.to_string(),
|
||||
crate::storage::user_home_path(".pi/agent/auth.json")?
|
||||
.display()
|
||||
.to_string(),
|
||||
]),
|
||||
Self::Openai => Ok(vec![
|
||||
crate::storage::jcode_dir()?
|
||||
.join("openai-auth.json")
|
||||
.display()
|
||||
.to_string(),
|
||||
crate::storage::user_home_path(".codex/auth.json")?
|
||||
.display()
|
||||
.to_string(),
|
||||
crate::storage::user_home_path(".local/share/opencode/auth.json")?
|
||||
.display()
|
||||
.to_string(),
|
||||
crate::storage::user_home_path(".pi/agent/auth.json")?
|
||||
.display()
|
||||
.to_string(),
|
||||
]),
|
||||
Self::Gemini => Ok(vec![
|
||||
crate::auth::gemini::tokens_path()?.display().to_string(),
|
||||
crate::auth::gemini::gemini_cli_oauth_path()?
|
||||
.display()
|
||||
.to_string(),
|
||||
crate::storage::user_home_path(".local/share/opencode/auth.json")?
|
||||
.display()
|
||||
.to_string(),
|
||||
crate::storage::user_home_path(".pi/agent/auth.json")?
|
||||
.display()
|
||||
.to_string(),
|
||||
]),
|
||||
Self::Antigravity => Ok(vec![
|
||||
crate::auth::antigravity::tokens_path()?
|
||||
.display()
|
||||
.to_string(),
|
||||
crate::storage::user_home_path(".local/share/opencode/auth.json")?
|
||||
.display()
|
||||
.to_string(),
|
||||
crate::storage::user_home_path(".pi/agent/auth.json")?
|
||||
.display()
|
||||
.to_string(),
|
||||
]),
|
||||
Self::Google => Ok(vec![
|
||||
crate::auth::google::credentials_path()?
|
||||
.display()
|
||||
.to_string(),
|
||||
crate::auth::google::tokens_path()?.display().to_string(),
|
||||
]),
|
||||
Self::Copilot => Ok(vec![
|
||||
crate::storage::user_home_path(".copilot/config.json")?
|
||||
.display()
|
||||
.to_string(),
|
||||
crate::storage::user_home_path(".config/github-copilot/hosts.json")?
|
||||
.display()
|
||||
.to_string(),
|
||||
crate::storage::user_home_path(".config/github-copilot/apps.json")?
|
||||
.display()
|
||||
.to_string(),
|
||||
crate::storage::user_home_path(".local/share/opencode/auth.json")?
|
||||
.display()
|
||||
.to_string(),
|
||||
crate::storage::user_home_path(".pi/agent/auth.json")?
|
||||
.display()
|
||||
.to_string(),
|
||||
]),
|
||||
Self::Cursor => Ok(vec![
|
||||
dirs::config_dir()
|
||||
.ok_or_else(|| anyhow::anyhow!("No config directory found"))?
|
||||
.join("jcode")
|
||||
.join("cursor.env")
|
||||
.display()
|
||||
.to_string(),
|
||||
crate::auth::cursor::cursor_auth_file_path()?
|
||||
.display()
|
||||
.to_string(),
|
||||
crate::storage::user_home_path(".config/Cursor/User/globalStorage/state.vscdb")?
|
||||
.display()
|
||||
.to_string(),
|
||||
]),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct AuthTestStepReport {
|
||||
name: String,
|
||||
ok: bool,
|
||||
detail: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct AuthTestProviderReport {
|
||||
provider: String,
|
||||
credential_paths: Vec<String>,
|
||||
steps: Vec<AuthTestStepReport>,
|
||||
smoke_output: Option<String>,
|
||||
tool_smoke_output: Option<String>,
|
||||
success: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct AuthTestContextModelReport {
|
||||
model: String,
|
||||
catalog_context_window: usize,
|
||||
resolved_context_window: usize,
|
||||
ok: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct AuthTestContextAuditReport {
|
||||
provider: String,
|
||||
display_name: String,
|
||||
checked_models: usize,
|
||||
skipped_models_without_context: usize,
|
||||
mismatches: Vec<AuthTestContextModelReport>,
|
||||
success: bool,
|
||||
detail: String,
|
||||
}
|
||||
|
||||
impl AuthTestProviderReport {
|
||||
fn new(target: AuthTestTarget) -> Self {
|
||||
Self {
|
||||
provider: target.label().to_string(),
|
||||
credential_paths: target.credential_paths().unwrap_or_default(),
|
||||
steps: Vec::new(),
|
||||
smoke_output: None,
|
||||
tool_smoke_output: None,
|
||||
success: true,
|
||||
}
|
||||
}
|
||||
|
||||
fn new_generic(provider_id: String, credential_paths: Vec<String>) -> Self {
|
||||
Self {
|
||||
provider: provider_id,
|
||||
credential_paths,
|
||||
steps: Vec::new(),
|
||||
smoke_output: None,
|
||||
tool_smoke_output: None,
|
||||
success: true,
|
||||
}
|
||||
}
|
||||
|
||||
fn push_step(&mut self, name: impl Into<String>, ok: bool, detail: impl Into<String>) {
|
||||
if !ok {
|
||||
self.success = false;
|
||||
}
|
||||
self.steps.push(AuthTestStepReport {
|
||||
name: name.into(),
|
||||
ok,
|
||||
detail: detail.into(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
impl ResolvedAuthTestTarget {
|
||||
fn from_choice(choice: &super::provider_init::ProviderChoice) -> Option<Self> {
|
||||
let provider = super::provider_init::login_provider_for_choice(choice)?;
|
||||
Some(match AuthTestTarget::from_provider_choice(choice) {
|
||||
Some(target) => Self::Detailed(target),
|
||||
None => Self::Generic {
|
||||
provider,
|
||||
choice: *choice,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
fn from_provider(provider: crate::provider_catalog::LoginProviderDescriptor) -> Option<Self> {
|
||||
let choice = super::provider_init::choice_for_login_provider(provider)?;
|
||||
Some(match AuthTestTarget::from_provider_choice(&choice) {
|
||||
Some(target) => Self::Detailed(target),
|
||||
None => Self::Generic { provider, choice },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum AuthTestSmokeKind {
|
||||
Provider,
|
||||
Tool,
|
||||
}
|
||||
|
||||
impl AuthTestSmokeKind {
|
||||
fn step_name(self) -> &'static str {
|
||||
match self {
|
||||
Self::Provider => "provider_smoke",
|
||||
Self::Tool => "tool_smoke",
|
||||
}
|
||||
}
|
||||
|
||||
fn skipped_by_flag_detail(self) -> &'static str {
|
||||
match self {
|
||||
Self::Provider => "Skipped by --no-smoke.",
|
||||
Self::Tool => "Skipped by --no-tool-smoke.",
|
||||
}
|
||||
}
|
||||
|
||||
fn unsupported_detail(self) -> &'static str {
|
||||
"Skipped: provider is auth/tool-only and has no model runtime smoke step."
|
||||
}
|
||||
|
||||
fn success_detail(self) -> &'static str {
|
||||
match self {
|
||||
Self::Provider => "Provider returned AUTH_TEST_OK.",
|
||||
Self::Tool => {
|
||||
"Tool-enabled provider request returned AUTH_TEST_OK after one validated real Jcode bash tool call, successful registry execution, and tool-result followup."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn failure_detail(self, output: &str) -> String {
|
||||
match self {
|
||||
Self::Provider => {
|
||||
format!("Provider response did not contain AUTH_TEST_OK: {}", output)
|
||||
}
|
||||
Self::Tool => format!(
|
||||
"Tool-enabled provider response did not contain AUTH_TEST_OK: {}",
|
||||
output
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
async fn run(
|
||||
self,
|
||||
target: AuthTestTarget,
|
||||
model: Option<&str>,
|
||||
prompt: &str,
|
||||
) -> Result<String> {
|
||||
self.run_for_choice(&target.provider_choice(), model, prompt)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn run_for_choice(
|
||||
self,
|
||||
choice: &super::provider_init::ProviderChoice,
|
||||
model: Option<&str>,
|
||||
prompt: &str,
|
||||
) -> Result<String> {
|
||||
match self {
|
||||
Self::Provider => run_provider_smoke_for_choice(choice, model, prompt).await,
|
||||
Self::Tool => run_provider_tool_smoke_for_choice(choice, model, prompt).await,
|
||||
}
|
||||
}
|
||||
|
||||
fn set_output(self, report: &mut AuthTestProviderReport, output: String) {
|
||||
match self {
|
||||
Self::Provider => report.smoke_output = Some(output),
|
||||
Self::Tool => report.tool_smoke_output = Some(output),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn push_result_step<T, E, F>(
|
||||
report: &mut AuthTestProviderReport,
|
||||
name: &'static str,
|
||||
result: std::result::Result<T, E>,
|
||||
detail: F,
|
||||
) -> Option<T>
|
||||
where
|
||||
E: std::fmt::Display,
|
||||
F: FnOnce(&T) -> String,
|
||||
{
|
||||
match result {
|
||||
Ok(value) => {
|
||||
report.push_step(name, true, detail(&value));
|
||||
Some(value)
|
||||
}
|
||||
Err(err) => {
|
||||
report.push_step(name, false, err.to_string());
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn auth_email_suffix(email: Option<&str>) -> String {
|
||||
email
|
||||
.map(|email| format!(" for {}", email))
|
||||
.unwrap_or_default()
|
||||
}
|
||||
+3242
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,812 @@
|
||||
//! `jcode menubar` - a lightweight live indicator of how many jcode sessions
|
||||
//! are running and how many are actively streaming a model response.
|
||||
//!
|
||||
//! On macOS this renders a native menu bar (`NSStatusItem`) item that updates
|
||||
//! roughly once a second by reading the on-disk active-pid / streaming
|
||||
//! registries (see `crate::session::session_counts`). On other platforms (and
|
||||
//! with `--once` / `--json`) it just prints the current counts.
|
||||
|
||||
use anyhow::Result;
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::session::{self, SessionCounts};
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct CountsReport {
|
||||
total: usize,
|
||||
streaming: usize,
|
||||
}
|
||||
|
||||
impl From<SessionCounts> for CountsReport {
|
||||
fn from(counts: SessionCounts) -> Self {
|
||||
Self {
|
||||
total: counts.total,
|
||||
streaming: counts.streaming,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Format the compact title shown next to the menu bar icon.
|
||||
///
|
||||
/// Always shows both the streaming and total counts directly in the menu bar
|
||||
/// (e.g. "0/3" idle, "2/7" while streaming) so the live state is visible at a
|
||||
/// glance without opening the dropdown. Icon-only when no sessions are running.
|
||||
#[cfg(any(test, target_os = "macos"))]
|
||||
pub(crate) fn format_menubar_title(counts: SessionCounts) -> String {
|
||||
if counts.total == 0 {
|
||||
String::new()
|
||||
} else {
|
||||
format!("{}/{}", counts.streaming, counts.total)
|
||||
}
|
||||
}
|
||||
|
||||
/// Human-readable one-line summary used for `--once` and the menu header.
|
||||
pub(crate) fn format_menubar_summary(counts: SessionCounts) -> String {
|
||||
format!(
|
||||
"{} streaming · {} session{} running",
|
||||
counts.streaming,
|
||||
counts.total,
|
||||
if counts.total == 1 { "" } else { "s" }
|
||||
)
|
||||
}
|
||||
|
||||
/// Title for one session row in the dropdown menu: the session's animal emoji
|
||||
/// and short name, plus a streaming marker while a response is generating.
|
||||
#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
|
||||
pub(crate) fn format_session_menu_item_title(session_id: &str, streaming: bool) -> String {
|
||||
let display = crate::id::extract_session_name(session_id).unwrap_or(session_id);
|
||||
let icon = crate::id::session_icon(display);
|
||||
if streaming {
|
||||
format!("{icon} {display} · streaming")
|
||||
} else {
|
||||
format!("{icon} {display}")
|
||||
}
|
||||
}
|
||||
|
||||
pub fn run_menubar_command(once: bool, json: bool) -> Result<()> {
|
||||
if json {
|
||||
let report = CountsReport::from(session::session_counts());
|
||||
println!("{}", serde_json::to_string(&report)?);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if once {
|
||||
println!("{}", format_menubar_summary(session::session_counts()));
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
macos::run_status_item_app();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
{
|
||||
eprintln!(
|
||||
"The live menu bar indicator is only available on macOS. \
|
||||
Showing current counts instead (use --once or --json for scripting):"
|
||||
);
|
||||
println!("{}", format_menubar_summary(session::session_counts()));
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Ensure a single background `jcode menubar` helper is running on macOS so the
|
||||
/// session-count indicator shows up automatically for every macOS user without
|
||||
/// them needing to run `jcode menubar` by hand.
|
||||
///
|
||||
/// This is a best-effort, fire-and-forget singleton: it records the helper's
|
||||
/// PID in the *global* `~/.jcode/menubar.pid` (see [`global_menubar_dir`]) and
|
||||
/// only spawns a new detached process when no live helper is already running.
|
||||
/// Failures are silently ignored so they never disrupt normal session startup.
|
||||
///
|
||||
/// The macOS menu bar is a single per-login-session resource, so this guards
|
||||
/// hard against sandboxed jcode processes (tests, self-dev, onboarding) ever
|
||||
/// spawning a helper: each such process runs with a throwaway `$JCODE_HOME`,
|
||||
/// and without this guard every distinct sandbox home spawned its own helper
|
||||
/// and drew its own duplicate status item into the one real menu bar.
|
||||
#[cfg(target_os = "macos")]
|
||||
pub fn ensure_menubar_helper_running() {
|
||||
use std::os::unix::process::CommandExt;
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
// Allow users to opt out entirely.
|
||||
if std::env::var_os("JCODE_NO_MENUBAR").is_some() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Sandboxed jcode (tests / self-dev / onboarding, anything with a throwaway
|
||||
// `$JCODE_HOME`) must never manage the real user's global menu bar.
|
||||
if running_in_menubar_sandbox() {
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(dir) = global_menubar_dir() else {
|
||||
return;
|
||||
};
|
||||
let pid_path = dir.join("menubar.pid");
|
||||
|
||||
// If a recorded helper PID is still alive, do nothing.
|
||||
if let Ok(raw) = std::fs::read_to_string(&pid_path) {
|
||||
if let Ok(pid) = raw.trim().parse::<u32>() {
|
||||
if crate::platform::is_process_running(pid) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let Ok(exe) = std::env::current_exe() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let mut command = Command::new(exe);
|
||||
command
|
||||
.arg("menubar")
|
||||
.env("JCODE_NO_MENUBAR", "1")
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null());
|
||||
// Detach from the parent's process group so the helper outlives this session.
|
||||
unsafe {
|
||||
command.pre_exec(|| {
|
||||
libc::setsid();
|
||||
Ok(())
|
||||
});
|
||||
}
|
||||
|
||||
if let Ok(child) = command.spawn() {
|
||||
let _ = std::fs::write(&pid_path, child.id().to_string());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
pub fn ensure_menubar_helper_running() {}
|
||||
|
||||
/// Resolve the directory holding the *global* (per-OS-user) menu bar singleton
|
||||
/// state - the "only one helper" lock and the helper pid file.
|
||||
///
|
||||
/// The macOS menu bar is a single per-login-session resource shared by every
|
||||
/// jcode process for this user, so this state must live at a fixed location
|
||||
/// that does **not** depend on `$JCODE_HOME`. Sandboxes (tests, self-dev,
|
||||
/// onboarding) override `$JCODE_HOME` with throwaway temp dirs; anchoring to
|
||||
/// the real home (`$HOME/.jcode`) gives every process the same lock inode so
|
||||
/// the singleton actually holds across them. For a normal (non-sandboxed)
|
||||
/// launch this is exactly `crate::storage::jcode_dir()`, so behavior for the
|
||||
/// real user is unchanged.
|
||||
#[cfg(target_os = "macos")]
|
||||
fn global_menubar_dir() -> Option<std::path::PathBuf> {
|
||||
let home = dirs::home_dir()?;
|
||||
let dir = home.join(".jcode");
|
||||
let _ = std::fs::create_dir_all(&dir);
|
||||
Some(dir)
|
||||
}
|
||||
|
||||
/// True when this process is a sandboxed jcode that must not own the real
|
||||
/// user's global menu bar. A throwaway `$JCODE_HOME` (anything other than the
|
||||
/// real `~/.jcode`) or an explicit test/temp marker means "sandbox".
|
||||
#[cfg(target_os = "macos")]
|
||||
fn running_in_menubar_sandbox() -> bool {
|
||||
is_menubar_sandbox(
|
||||
env_truthy("JCODE_TEST_SESSION"),
|
||||
env_truthy("JCODE_TEMP_SERVER"),
|
||||
std::env::var_os("JCODE_HOME").as_deref(),
|
||||
dirs::home_dir().map(|home| home.join(".jcode")).as_deref(),
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn env_truthy(key: &str) -> bool {
|
||||
std::env::var(key)
|
||||
.ok()
|
||||
.map(|value| {
|
||||
let trimmed = value.trim();
|
||||
!trimmed.is_empty() && trimmed != "0" && !trimmed.eq_ignore_ascii_case("false")
|
||||
})
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Pure decision for [`running_in_menubar_sandbox`], split out so it can be
|
||||
/// unit-tested without mutating process-global environment state.
|
||||
///
|
||||
/// - An explicit test/temp marker forces "sandbox".
|
||||
/// - A `$JCODE_HOME` that differs from the real `~/.jcode` is a sandbox home.
|
||||
/// - No override (or an override equal to the real home) is the real user.
|
||||
#[cfg(target_os = "macos")]
|
||||
fn is_menubar_sandbox(
|
||||
test_session: bool,
|
||||
temp_server: bool,
|
||||
custom_home: Option<&std::ffi::OsStr>,
|
||||
real_jcode_home: Option<&std::path::Path>,
|
||||
) -> bool {
|
||||
if test_session || temp_server {
|
||||
return true;
|
||||
}
|
||||
|
||||
// No explicit override: the real user's default `~/.jcode`.
|
||||
let Some(custom_home) = custom_home else {
|
||||
return false;
|
||||
};
|
||||
let custom = std::path::Path::new(custom_home);
|
||||
let Some(real) = real_jcode_home else {
|
||||
// No real home to compare against: treat any explicit override as a sandbox.
|
||||
return true;
|
||||
};
|
||||
let normalize =
|
||||
|path: &std::path::Path| std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
|
||||
normalize(custom) != normalize(real)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
mod macos {
|
||||
use super::{format_menubar_summary, format_menubar_title, format_session_menu_item_title};
|
||||
use crate::session::{self, SessionCounts, SessionPresence};
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::cmp::Reverse;
|
||||
|
||||
use objc2::rc::Retained;
|
||||
use objc2::runtime::AnyObject;
|
||||
use objc2::{MainThreadMarker, MainThreadOnly, define_class, msg_send, sel};
|
||||
use objc2_app_kit::{
|
||||
NSAppearance, NSAppearanceNameAqua, NSAppearanceNameDarkAqua, NSApplication,
|
||||
NSApplicationActivationPolicy, NSCellImagePosition, NSColor, NSFont, NSFontAttributeName,
|
||||
NSFontWeightRegular, NSForegroundColorAttributeName, NSImage, NSMenu, NSMenuItem,
|
||||
NSStatusBar, NSStatusItem, NSVariableStatusItemLength,
|
||||
};
|
||||
use objc2_foundation::{
|
||||
NSAttributedString, NSDictionary, NSObject, NSString, NSUserDefaults, ns_string,
|
||||
};
|
||||
|
||||
/// Poll interval for refreshing the counts (milliseconds).
|
||||
const REFRESH_INTERVAL_MS: u64 = 1000;
|
||||
|
||||
/// A held singleton lock for the menu bar helper. Keeps the lock file open
|
||||
/// for the whole process lifetime; the kernel releases the advisory lock
|
||||
/// automatically when the process exits (including via `terminate:`).
|
||||
struct SingletonLock {
|
||||
#[allow(dead_code)]
|
||||
file: std::fs::File,
|
||||
}
|
||||
|
||||
/// Acquire the exclusive, system-wide "only one menu bar helper" lock.
|
||||
///
|
||||
/// Uses a non-blocking `flock(LOCK_EX | LOCK_NB)` on the *global*
|
||||
/// `~/.jcode/menubar.lock` (see [`super::global_menubar_dir`]) so the lock
|
||||
/// is shared across every jcode process for this OS user, including ones
|
||||
/// running with a sandboxed `$JCODE_HOME`. The menu bar itself is a single
|
||||
/// per-login-session resource, so the guard must be global too.
|
||||
///
|
||||
/// Returns `Some(guard)` if we are the sole helper, or `None` if another
|
||||
/// live helper already holds the lock (in which case the caller should exit
|
||||
/// without creating a second status item). On any unexpected error we fall
|
||||
/// back to `Some` so a transient filesystem issue never permanently hides
|
||||
/// the indicator.
|
||||
fn acquire_singleton_lock() -> Option<SingletonLock> {
|
||||
use std::os::unix::io::AsRawFd;
|
||||
|
||||
let dir = super::global_menubar_dir()?;
|
||||
let lock_path = dir.join("menubar.lock");
|
||||
let file = match std::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.write(true)
|
||||
.truncate(false)
|
||||
.open(&lock_path)
|
||||
{
|
||||
Ok(file) => file,
|
||||
// If we cannot open the lock file at all, don't block the indicator.
|
||||
Err(_) => {
|
||||
return Some(SingletonLock {
|
||||
file: dummy_file()?,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// SAFETY: `flock` on a valid fd. LOCK_NB makes this non-blocking.
|
||||
let rc = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) };
|
||||
if rc == 0 {
|
||||
Some(SingletonLock { file })
|
||||
} else {
|
||||
let err = std::io::Error::last_os_error();
|
||||
match err.raw_os_error() {
|
||||
// Another helper holds the lock: we are the duplicate, bail out.
|
||||
Some(libc::EWOULDBLOCK) => None,
|
||||
// Unexpected error: don't permanently suppress the indicator.
|
||||
_ => Some(SingletonLock { file }),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Open `/dev/null` as a stand-in lock handle for the rare case where the
|
||||
/// real lock file cannot be created. Returns `None` only if even that
|
||||
/// fails, in which case the caller proceeds without a guard.
|
||||
fn dummy_file() -> Option<std::fs::File> {
|
||||
std::fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.open("/dev/null")
|
||||
.ok()
|
||||
}
|
||||
|
||||
/// Autosave name under which macOS persists the status item's position.
|
||||
const STATUS_ITEM_AUTOSAVE: &str = "jcode-menubar";
|
||||
|
||||
/// Number of fixed items at the end of the menu (separator, New Window,
|
||||
/// separator, Quit). Session rows are inserted between the summary header
|
||||
/// and this tail.
|
||||
const MENU_TAIL_ITEMS: isize = 4;
|
||||
|
||||
define_class!(
|
||||
// SAFETY: NSObject has no subclassing requirements and MenuHandler
|
||||
// does not implement Drop.
|
||||
#[unsafe(super(NSObject))]
|
||||
#[thread_kind = MainThreadOnly]
|
||||
#[name = "JcodeMenubarHandler"]
|
||||
struct MenuHandler;
|
||||
|
||||
impl MenuHandler {
|
||||
/// Open the clicked session (stored in the item's representedObject)
|
||||
/// in a new terminal window via `jcode --resume <id>`.
|
||||
#[unsafe(method(openSession:))]
|
||||
fn open_session(&self, sender: &NSMenuItem)
|
||||
{
|
||||
let Some(object) = sender.representedObject() else {
|
||||
return;
|
||||
};
|
||||
let Ok(session_id) = object.downcast::<NSString>() else {
|
||||
return;
|
||||
};
|
||||
launch_jcode_window(vec!["--resume".to_string(), session_id.to_string()]);
|
||||
}
|
||||
|
||||
/// Launch a brand-new jcode session in a new terminal window.
|
||||
#[unsafe(method(newWindow:))]
|
||||
fn new_window(&self, _sender: &NSMenuItem) {
|
||||
launch_jcode_window(Vec::new());
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
impl MenuHandler {
|
||||
fn new(mtm: MainThreadMarker) -> Retained<Self> {
|
||||
let this = Self::alloc(mtm).set_ivars(());
|
||||
unsafe { msg_send![super(this), init] }
|
||||
}
|
||||
}
|
||||
|
||||
/// Launch a jcode window off the main thread so slow terminal startup
|
||||
/// (osascript / `open`) never blocks the menu bar UI.
|
||||
fn launch_jcode_window(args: Vec<String>) {
|
||||
std::thread::spawn(move || {
|
||||
if let Err(err) = crate::setup_hints::launch_jcode_in_macos_terminal(&args) {
|
||||
crate::logging::warn(&format!(
|
||||
"menubar: failed to launch jcode window ({args:?}): {err}"
|
||||
));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
pub(super) fn run_status_item_app() {
|
||||
let mtm = MainThreadMarker::new()
|
||||
.expect("jcode menubar must run on the main thread (the process entry point)");
|
||||
|
||||
// Defense in depth: a process running under an explicit test/temp marker
|
||||
// must never paint into the real user's menu bar. The real protection
|
||||
// against duplicates is `ensure_menubar_helper_running` (which refuses
|
||||
// to spawn from sandboxes) plus the global singleton lock below, but a
|
||||
// stray `jcode menubar` invoked directly inside a test harness should
|
||||
// still never realize a status item.
|
||||
if super::env_truthy("JCODE_TEST_SESSION") || super::env_truthy("JCODE_TEMP_SERVER") {
|
||||
return;
|
||||
}
|
||||
|
||||
// Enforce a single live menu bar helper. The pid-file fast path in
|
||||
// `ensure_menubar_helper_running` is best-effort and can race or be
|
||||
// bypassed entirely (e.g. a self-dev `target/.../jcode` and the
|
||||
// installed `~/.local/bin/jcode` both spawn helpers, or a reload
|
||||
// re-runs startup). Without a hard guard each extra helper creates its
|
||||
// own NSStatusItem, so the user ends up with a duplicate menu bar item
|
||||
// per spawn. Acquire an exclusive advisory lock here; if another helper
|
||||
// already holds it, exit immediately before creating any UI. The
|
||||
// returned guard must stay alive for the whole process lifetime (it is
|
||||
// held by `_singleton_lock` until `app.run()` is terminated).
|
||||
let Some(_singleton_lock) = acquire_singleton_lock() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let app = NSApplication::sharedApplication(mtm);
|
||||
// Accessory: no Dock icon, no main menu, just a menu bar item.
|
||||
app.setActivationPolicy(NSApplicationActivationPolicy::Accessory);
|
||||
|
||||
// Follow the system's Light/Dark setting explicitly. `jcode` runs as a
|
||||
// bare Mach-O with no Info.plist app bundle, so AppKit defaults the
|
||||
// process to the light Aqua appearance and never auto-adopts macOS Dark
|
||||
// Mode. That made the status item's template icon and `labelColor` text
|
||||
// resolve to *black*, which is invisible on a dark menu bar (the exact
|
||||
// symptom: a black icon on an already-black bar). Pin the app's
|
||||
// appearance to match `AppleInterfaceStyle` so the template image and
|
||||
// dynamic colors render light on a dark bar (and dark on a light bar).
|
||||
sync_app_appearance(&app);
|
||||
|
||||
let status_bar = NSStatusBar::systemStatusBar();
|
||||
let status_item: Retained<NSStatusItem> =
|
||||
status_bar.statusItemWithLength(NSVariableStatusItemLength);
|
||||
|
||||
// Give the item a persistent identity and seed a sane preferred
|
||||
// position the first time. Without this, macOS appends brand-new
|
||||
// status items at the far left of the status area; if another app owns
|
||||
// an oversized status item (or the menu bar is crowded), a freshly
|
||||
// created item can be pushed completely off-screen and the user never
|
||||
// sees it. Seeding "NSStatusItem Preferred Position <name>" (distance
|
||||
// in points from the right screen edge) before the item is realized
|
||||
// places it among the system icons; afterwards macOS keeps tracking
|
||||
// the user's chosen position under the same key.
|
||||
unsafe {
|
||||
let defaults = NSUserDefaults::standardUserDefaults();
|
||||
let pos_key = NSString::from_str(&format!(
|
||||
"NSStatusItem Preferred Position {STATUS_ITEM_AUTOSAVE}"
|
||||
));
|
||||
if defaults.objectForKey(&pos_key).is_none() {
|
||||
defaults.setInteger_forKey(550, &pos_key);
|
||||
}
|
||||
status_item.setAutosaveName(Some(&NSString::from_str(STATUS_ITEM_AUTOSAVE)));
|
||||
}
|
||||
|
||||
// Style the button like a native menu bar extra: a template SF Symbol
|
||||
// (auto-adapts to light/dark menu bars and tinting) plus a compact
|
||||
// monospaced-digit count. Keeping the item narrow matters: macOS hides
|
||||
// wide status items first whenever the frontmost app's menus need the
|
||||
// space, which is why a verbose title appears and disappears depending
|
||||
// on which app is focused.
|
||||
let menu_bar_font_size = NSFont::menuBarFontOfSize(0.0).pointSize();
|
||||
let title_font =
|
||||
NSFont::monospacedDigitSystemFontOfSize_weight(menu_bar_font_size, unsafe {
|
||||
NSFontWeightRegular
|
||||
});
|
||||
if let Some(button) = status_item.button(mtm) {
|
||||
let icon = NSImage::imageWithSystemSymbolName_accessibilityDescription(
|
||||
ns_string!("terminal.fill"),
|
||||
Some(ns_string!("jcode sessions")),
|
||||
);
|
||||
if let Some(icon) = icon.as_deref() {
|
||||
icon.setTemplate(true);
|
||||
button.setImage(Some(icon));
|
||||
// Title on the left, icon on the right.
|
||||
button.setImagePosition(NSCellImagePosition::ImageTrailing);
|
||||
}
|
||||
button.setFont(Some(&title_font));
|
||||
}
|
||||
|
||||
// The target of menu item actions. NSMenuItem holds its target weakly,
|
||||
// so keep a strong reference alive in the refresh closure below.
|
||||
let handler = MenuHandler::new(mtm);
|
||||
|
||||
// Build the dropdown menu: summary header, dynamic session rows, then
|
||||
// a fixed tail (New Window / Quit).
|
||||
let menu = NSMenu::new(mtm);
|
||||
let summary_item = NSMenuItem::new(mtm);
|
||||
summary_item.setEnabled(false);
|
||||
menu.addItem(&summary_item);
|
||||
|
||||
menu.addItem(&NSMenuItem::separatorItem(mtm));
|
||||
let new_window_item = unsafe {
|
||||
NSMenuItem::initWithTitle_action_keyEquivalent(
|
||||
NSMenuItem::alloc(mtm),
|
||||
ns_string!("New jcode Window"),
|
||||
Some(sel!(newWindow:)),
|
||||
ns_string!("n"),
|
||||
)
|
||||
};
|
||||
unsafe { new_window_item.setTarget(Some(&handler)) };
|
||||
menu.addItem(&new_window_item);
|
||||
menu.addItem(&NSMenuItem::separatorItem(mtm));
|
||||
|
||||
let quit_item = unsafe {
|
||||
NSMenuItem::initWithTitle_action_keyEquivalent(
|
||||
NSMenuItem::alloc(mtm),
|
||||
ns_string!("Quit jcode menu bar"),
|
||||
Some(objc2::sel!(terminate:)),
|
||||
ns_string!("q"),
|
||||
)
|
||||
};
|
||||
menu.addItem(&quit_item);
|
||||
status_item.setMenu(Some(&menu));
|
||||
|
||||
let last_sessions: RefCell<Vec<SessionPresence>> = RefCell::new(Vec::new());
|
||||
let app_for_refresh = app.clone();
|
||||
let refresh = move || {
|
||||
// Re-sync the Light/Dark appearance each tick so toggling the
|
||||
// system theme at runtime keeps the status item visible (the
|
||||
// process won't auto-adopt the change on its own).
|
||||
sync_app_appearance(&app_for_refresh);
|
||||
|
||||
let mut sessions = session::session_presence();
|
||||
sessions.sort_by_key(|s| (Reverse(s.streaming), s.session_id.clone()));
|
||||
|
||||
let counts = SessionCounts {
|
||||
total: sessions.len(),
|
||||
streaming: sessions.iter().filter(|s| s.streaming).count(),
|
||||
};
|
||||
if let Some(button) = status_item.button(mtm) {
|
||||
let title = format_menubar_title(counts);
|
||||
let attributed = attributed_title(&title, &title_font, counts.streaming > 0);
|
||||
button.setAttributedTitle(&attributed);
|
||||
// Tint the template icon to match: accent green while any
|
||||
// session is streaming, default (nil) otherwise so it follows
|
||||
// the menu bar's normal appearance.
|
||||
let tint: Option<Retained<NSColor>> = if counts.streaming > 0 {
|
||||
Some(streaming_color())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
button.setContentTintColor(tint.as_deref());
|
||||
}
|
||||
summary_item.setTitle(&NSString::from_str(&format_menubar_summary(counts)));
|
||||
|
||||
// Only touch the menu structure when the session set changed, so
|
||||
// an open menu is not visually disturbed every second.
|
||||
if *last_sessions.borrow() != sessions {
|
||||
rebuild_session_items(&menu, &handler, mtm, &sessions);
|
||||
*last_sessions.borrow_mut() = sessions;
|
||||
}
|
||||
};
|
||||
|
||||
// Initial render before the run loop starts spinning.
|
||||
refresh();
|
||||
|
||||
spawn_refresh_timer(refresh);
|
||||
|
||||
// Run the Cocoa event loop. `terminate:` (the Quit item) exits the process.
|
||||
app.run();
|
||||
}
|
||||
|
||||
/// Pin the application's appearance to the system Light/Dark setting.
|
||||
///
|
||||
/// `jcode` runs as a bare executable without an `Info.plist` app bundle, so
|
||||
/// AppKit defaults the process to the light `Aqua` appearance and does not
|
||||
/// follow the user's macOS Dark Mode preference. With a light appearance the
|
||||
/// status item's template SF Symbol and `labelColor` title both resolve to a
|
||||
/// dark/black color, which is invisible against a dark menu bar. Reading
|
||||
/// `AppleInterfaceStyle` (absent => Light, "Dark" => Dark) and applying the
|
||||
/// matching named appearance makes those dynamic colors and template images
|
||||
/// render with proper contrast on whatever menu bar the user has.
|
||||
///
|
||||
/// Safe to call repeatedly; setting the same appearance is a no-op.
|
||||
fn sync_app_appearance(app: &NSApplication) {
|
||||
let is_dark = {
|
||||
let defaults = NSUserDefaults::standardUserDefaults();
|
||||
defaults
|
||||
.stringForKey(ns_string!("AppleInterfaceStyle"))
|
||||
.map(|style| style.to_string().eq_ignore_ascii_case("dark"))
|
||||
.unwrap_or(false)
|
||||
};
|
||||
let name = if is_dark {
|
||||
unsafe { NSAppearanceNameDarkAqua }
|
||||
} else {
|
||||
unsafe { NSAppearanceNameAqua }
|
||||
};
|
||||
let appearance = NSAppearance::appearanceNamed(name);
|
||||
app.setAppearance(appearance.as_deref());
|
||||
}
|
||||
|
||||
/// Color used for the count (and icon tint) while any session is actively
|
||||
/// streaming a response. A slightly muted system green that reads well in
|
||||
/// both light and dark menu bars.
|
||||
fn streaming_color() -> Retained<NSColor> {
|
||||
NSColor::systemGreenColor()
|
||||
}
|
||||
|
||||
/// Build the colored menu bar title. While streaming, the count is drawn in
|
||||
/// the streaming color; when idle it uses the primary dynamic label color so
|
||||
/// it keeps full contrast against whatever the menu bar background is. (The
|
||||
/// previous secondary/"quiet" gray was nearly invisible on a black/dark menu
|
||||
/// bar.) `labelColor` is a dynamic system color, so AppKit resolves it at
|
||||
/// draw time using the status item button's effective appearance - white-ish
|
||||
/// on a dark menu bar, dark on a light one. The monospaced-digit font is
|
||||
/// applied so the width stays stable.
|
||||
fn attributed_title(
|
||||
title: &str,
|
||||
font: &NSFont,
|
||||
streaming: bool,
|
||||
) -> Retained<NSAttributedString> {
|
||||
let string = NSString::from_str(title);
|
||||
let color = if streaming {
|
||||
streaming_color()
|
||||
} else {
|
||||
NSColor::labelColor()
|
||||
};
|
||||
let keys: [&NSString; 2] = [unsafe { NSForegroundColorAttributeName }, unsafe {
|
||||
NSFontAttributeName
|
||||
}];
|
||||
let color_obj: &AnyObject = &color;
|
||||
let font_obj: &AnyObject = font;
|
||||
let values: [&AnyObject; 2] = [color_obj, font_obj];
|
||||
let attrs = NSDictionary::from_slices(&keys, &values);
|
||||
unsafe { NSAttributedString::new_with_attributes(&string, &attrs) }
|
||||
}
|
||||
|
||||
/// Replace the dynamic session rows between the summary header (index 0)
|
||||
/// and the fixed tail with one clickable row per running session.
|
||||
fn rebuild_session_items(
|
||||
menu: &NSMenu,
|
||||
handler: &MenuHandler,
|
||||
mtm: MainThreadMarker,
|
||||
sessions: &[SessionPresence],
|
||||
) {
|
||||
while menu.numberOfItems() > 1 + MENU_TAIL_ITEMS {
|
||||
menu.removeItemAtIndex(1);
|
||||
}
|
||||
|
||||
let mut index = 1;
|
||||
if !sessions.is_empty() {
|
||||
menu.insertItem_atIndex(&NSMenuItem::separatorItem(mtm), index);
|
||||
index += 1;
|
||||
}
|
||||
for presence in sessions {
|
||||
let title = format_session_menu_item_title(&presence.session_id, presence.streaming);
|
||||
let item = unsafe {
|
||||
NSMenuItem::initWithTitle_action_keyEquivalent(
|
||||
NSMenuItem::alloc(mtm),
|
||||
&NSString::from_str(&title),
|
||||
Some(sel!(openSession:)),
|
||||
ns_string!(""),
|
||||
)
|
||||
};
|
||||
let target: &AnyObject = handler;
|
||||
unsafe {
|
||||
item.setTarget(Some(target));
|
||||
item.setRepresentedObject(Some(&NSString::from_str(&presence.session_id)));
|
||||
}
|
||||
item.setToolTip(Some(&NSString::from_str(&format!(
|
||||
"Open {} in a new terminal window",
|
||||
presence.session_id
|
||||
))));
|
||||
menu.insertItem_atIndex(&item, index);
|
||||
index += 1;
|
||||
}
|
||||
}
|
||||
|
||||
/// Schedule a repeating timer on the main run loop that re-renders the item.
|
||||
fn spawn_refresh_timer<F>(refresh: F)
|
||||
where
|
||||
F: Fn() + 'static,
|
||||
{
|
||||
use std::ptr::NonNull;
|
||||
|
||||
use objc2_foundation::{NSRunLoop, NSRunLoopCommonModes, NSTimer};
|
||||
|
||||
let interval = REFRESH_INTERVAL_MS as f64 / 1000.0;
|
||||
let block = block2::RcBlock::new(move |_timer: NonNull<NSTimer>| {
|
||||
refresh();
|
||||
});
|
||||
|
||||
unsafe {
|
||||
let timer = NSTimer::timerWithTimeInterval_repeats_block(interval, true, &block);
|
||||
let run_loop = NSRunLoop::currentRunLoop();
|
||||
// Common modes (not just the default mode) so the counts and the
|
||||
// session list keep updating while the dropdown menu is open
|
||||
// (menu tracking runs the loop in NSEventTrackingRunLoopMode).
|
||||
run_loop.addTimer_forMode(&timer, NSRunLoopCommonModes);
|
||||
// The run loop retains the timer; keep our reference alive too so the
|
||||
// owned closure (and its captured `status_item`) lives for the whole
|
||||
// process lifetime.
|
||||
std::mem::forget(timer);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::session::SessionCounts;
|
||||
|
||||
#[test]
|
||||
fn title_no_sessions_is_icon_only() {
|
||||
let title = format_menubar_title(SessionCounts {
|
||||
total: 0,
|
||||
streaming: 0,
|
||||
});
|
||||
assert_eq!(title, "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn title_idle_shows_zero_streaming_and_total() {
|
||||
let title = format_menubar_title(SessionCounts {
|
||||
total: 5,
|
||||
streaming: 0,
|
||||
});
|
||||
assert_eq!(title, "0/5");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn title_streaming_shows_compact_ratio() {
|
||||
let title = format_menubar_title(SessionCounts {
|
||||
total: 7,
|
||||
streaming: 2,
|
||||
});
|
||||
assert_eq!(title, "2/7");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn summary_pluralizes_sessions() {
|
||||
assert_eq!(
|
||||
format_menubar_summary(SessionCounts {
|
||||
total: 1,
|
||||
streaming: 0,
|
||||
}),
|
||||
"0 streaming · 1 session running"
|
||||
);
|
||||
assert_eq!(
|
||||
format_menubar_summary(SessionCounts {
|
||||
total: 3,
|
||||
streaming: 1,
|
||||
}),
|
||||
"1 streaming · 3 sessions running"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_menu_item_title_shows_icon_name_and_streaming() {
|
||||
assert_eq!(
|
||||
format_session_menu_item_title("session_buffalo_1781229104969_6d487ff77287de4f", false),
|
||||
"🐃 buffalo"
|
||||
);
|
||||
assert_eq!(
|
||||
format_session_menu_item_title("session_buffalo_1781229104969_6d487ff77287de4f", true),
|
||||
"🐃 buffalo · streaming"
|
||||
);
|
||||
// Unparseable IDs fall back to the raw ID with the generic icon.
|
||||
assert_eq!(
|
||||
format_session_menu_item_title("weird-id", false),
|
||||
"💫 weird-id"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn counts_report_serializes_to_json() {
|
||||
let report = CountsReport::from(SessionCounts {
|
||||
total: 4,
|
||||
streaming: 2,
|
||||
});
|
||||
let json = serde_json::to_string(&report).unwrap();
|
||||
assert_eq!(json, r#"{"total":4,"streaming":2}"#);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[test]
|
||||
fn menubar_sandbox_detection() {
|
||||
use std::ffi::OsStr;
|
||||
use std::path::Path;
|
||||
|
||||
let real = Path::new("/Users/me/.jcode");
|
||||
|
||||
// Real user, no override: not a sandbox -> owns the menu bar.
|
||||
assert!(!is_menubar_sandbox(false, false, None, Some(real)));
|
||||
// Override equal to the real home is still the real user.
|
||||
assert!(!is_menubar_sandbox(
|
||||
false,
|
||||
false,
|
||||
Some(OsStr::new("/Users/me/.jcode")),
|
||||
Some(real),
|
||||
));
|
||||
|
||||
// Explicit test/temp markers force sandbox regardless of home.
|
||||
assert!(is_menubar_sandbox(true, false, None, Some(real)));
|
||||
assert!(is_menubar_sandbox(false, true, None, Some(real)));
|
||||
|
||||
// A throwaway sandbox home (e2e / self-dev / onboarding) is a sandbox.
|
||||
assert!(is_menubar_sandbox(
|
||||
false,
|
||||
false,
|
||||
Some(OsStr::new("/private/tmp/jcode-e2e-home-xyz")),
|
||||
Some(real),
|
||||
));
|
||||
|
||||
// No discoverable real home: any explicit override is treated as sandbox.
|
||||
assert!(is_menubar_sandbox(
|
||||
false,
|
||||
false,
|
||||
Some(OsStr::new("/private/tmp/jcode-e2e-home-xyz")),
|
||||
None,
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,735 @@
|
||||
use anyhow::{Context, Result};
|
||||
use serde::Serialize;
|
||||
use std::io::Read;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::cli::args::ProviderAuthArg;
|
||||
use crate::config::{
|
||||
Config, NamedProviderAuth, NamedProviderConfig, NamedProviderModelConfig, NamedProviderType,
|
||||
};
|
||||
use crate::provider_catalog::{
|
||||
api_base_uses_localhost, is_safe_env_file_name, is_safe_env_key_name, normalize_api_base,
|
||||
resolve_login_provider, save_env_value_to_env_file,
|
||||
};
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct ProviderAddOptions {
|
||||
pub name: String,
|
||||
pub base_url: String,
|
||||
pub model: String,
|
||||
pub context_window: Option<usize>,
|
||||
pub api_key_env: Option<String>,
|
||||
pub api_key: Option<String>,
|
||||
pub api_key_stdin: bool,
|
||||
pub no_api_key: bool,
|
||||
pub auth: Option<ProviderAuthArg>,
|
||||
pub auth_header: Option<String>,
|
||||
pub env_file: Option<String>,
|
||||
pub set_default: bool,
|
||||
pub overwrite: bool,
|
||||
pub provider_routing: bool,
|
||||
pub model_catalog: bool,
|
||||
pub json: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub(crate) struct ProviderSetupReport {
|
||||
status: &'static str,
|
||||
profile: String,
|
||||
config_path: String,
|
||||
api_base: String,
|
||||
model: String,
|
||||
api_key_env: Option<String>,
|
||||
env_file: Option<String>,
|
||||
env_file_path: Option<String>,
|
||||
api_key_stored: bool,
|
||||
auth: String,
|
||||
default_set: bool,
|
||||
run_command: String,
|
||||
auth_test_command: String,
|
||||
}
|
||||
|
||||
pub(crate) fn run_provider_add_command(options: ProviderAddOptions) -> Result<()> {
|
||||
let emit_json = options.json;
|
||||
let report = configure_provider_profile(options)?;
|
||||
|
||||
if emit_json {
|
||||
println!("{}", serde_json::to_string_pretty(&report)?);
|
||||
} else {
|
||||
println!("Added provider profile '{}'", report.profile);
|
||||
println!(" config: {}", report.config_path);
|
||||
println!(" base: {}", report.api_base);
|
||||
println!(" model: {}", report.model);
|
||||
println!(" auth: {}", report.auth);
|
||||
if let Some(env_file_path) = &report.env_file_path {
|
||||
if report.api_key_stored {
|
||||
println!(
|
||||
" key: {} in {}",
|
||||
report.api_key_env.as_deref().unwrap_or("API key"),
|
||||
env_file_path
|
||||
);
|
||||
} else {
|
||||
println!(
|
||||
" key: {} (also reads {})",
|
||||
report.api_key_env.as_deref().unwrap_or("API key"),
|
||||
env_file_path
|
||||
);
|
||||
}
|
||||
} else if let Some(api_key_env) = &report.api_key_env {
|
||||
println!(" key: environment variable {}", api_key_env);
|
||||
}
|
||||
if report.default_set {
|
||||
println!(" default: yes");
|
||||
}
|
||||
println!();
|
||||
println!("Run: {}", report.run_command);
|
||||
println!("Validate: {}", report.auth_test_command);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn configure_provider_profile(
|
||||
options: ProviderAddOptions,
|
||||
) -> Result<ProviderSetupReport> {
|
||||
let name = validate_profile_name(&options.name)?;
|
||||
ensure_profile_name_not_reserved(&name)?;
|
||||
|
||||
let api_base = normalize_api_base(&options.base_url).ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"Invalid --base-url '{}'. Use https://... or http://localhost/127.0.0.1/private-LAN for local servers.",
|
||||
options.base_url
|
||||
)
|
||||
})?;
|
||||
let model = options.model.trim().to_string();
|
||||
if model.is_empty() {
|
||||
anyhow::bail!("--model cannot be empty");
|
||||
}
|
||||
if matches!(options.context_window, Some(0)) {
|
||||
anyhow::bail!("--context-window must be greater than 0");
|
||||
}
|
||||
|
||||
let api_key = read_api_key(&options)?;
|
||||
let auth = resolve_auth_mode(&options, api_key.as_deref(), &api_base)?;
|
||||
let uses_auth = !matches!(auth, NamedProviderAuth::None);
|
||||
|
||||
if !uses_auth && options.auth_header.is_some() {
|
||||
anyhow::bail!("--auth-header can only be used with --auth api-key");
|
||||
}
|
||||
if !matches!(auth, NamedProviderAuth::Header) && options.auth_header.is_some() {
|
||||
anyhow::bail!("--auth-header requires --auth api-key");
|
||||
}
|
||||
|
||||
let api_key_env = if uses_auth {
|
||||
Some(resolve_api_key_env(&name, options.api_key_env.as_deref())?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let env_file = if uses_auth && (api_key.is_some() || options.env_file.is_some()) {
|
||||
Some(resolve_env_file(&name, options.env_file.as_deref())?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
if uses_auth
|
||||
&& api_key.is_none()
|
||||
&& options.api_key_env.is_none()
|
||||
&& options.env_file.is_none()
|
||||
&& !api_base_uses_localhost(&api_base)
|
||||
{
|
||||
anyhow::bail!(
|
||||
"Remote provider '{}' needs an API key source. Use --api-key-env NAME, --api-key-stdin, --api-key VALUE, or --auth none if this endpoint truly needs no auth.",
|
||||
name
|
||||
);
|
||||
}
|
||||
|
||||
if let (Some(key), Some(env_key), Some(file_name)) = (
|
||||
api_key.as_deref(),
|
||||
api_key_env.as_deref(),
|
||||
env_file.as_deref(),
|
||||
) {
|
||||
save_env_value_to_env_file(env_key, file_name, Some(key))?;
|
||||
}
|
||||
let api_key_stored = api_key.is_some() && env_file.is_some();
|
||||
|
||||
let profile = NamedProviderConfig {
|
||||
provider_type: NamedProviderType::OpenAiCompatible,
|
||||
base_url: api_base.clone(),
|
||||
api: None,
|
||||
auth: auth.clone(),
|
||||
auth_header: match auth {
|
||||
NamedProviderAuth::Header => options
|
||||
.auth_header
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToString::to_string),
|
||||
_ => None,
|
||||
},
|
||||
api_key_env: api_key_env.clone(),
|
||||
api_key: None,
|
||||
env_file: env_file.clone(),
|
||||
default_model: Some(model.clone()),
|
||||
requires_api_key: Some(uses_auth),
|
||||
provider_routing: options.provider_routing,
|
||||
model_catalog: options.model_catalog,
|
||||
allow_provider_pinning: options.provider_routing,
|
||||
models: vec![NamedProviderModelConfig {
|
||||
id: model.clone(),
|
||||
context_window: options.context_window,
|
||||
input: Vec::new(),
|
||||
}],
|
||||
extra_body: None,
|
||||
supports_reasoning_effort: None,
|
||||
};
|
||||
|
||||
let config_path = Config::path().ok_or_else(|| anyhow::anyhow!("No config path"))?;
|
||||
let content = std::fs::read_to_string(&config_path).unwrap_or_default();
|
||||
let existing = parse_config_or_default(&content).with_context(|| {
|
||||
format!(
|
||||
"failed to parse existing config at {}",
|
||||
config_path.display()
|
||||
)
|
||||
})?;
|
||||
if existing.providers.contains_key(&name) && !options.overwrite {
|
||||
anyhow::bail!(
|
||||
"Provider profile '{}' already exists. Re-run with --overwrite to replace it.",
|
||||
name
|
||||
);
|
||||
}
|
||||
|
||||
let mut updated = if options.overwrite {
|
||||
remove_named_provider_sections(&content, &name)
|
||||
} else {
|
||||
content
|
||||
};
|
||||
if options.set_default {
|
||||
updated = upsert_provider_defaults(updated, &name, &model);
|
||||
}
|
||||
updated = append_profile_section(updated, &name, &profile);
|
||||
|
||||
toml::from_str::<Config>(&updated).with_context(|| {
|
||||
format!(
|
||||
"generated provider config for '{}' was not valid TOML",
|
||||
name
|
||||
)
|
||||
})?;
|
||||
|
||||
if let Some(parent) = config_path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
std::fs::write(&config_path, updated)?;
|
||||
|
||||
let env_file_path = env_file
|
||||
.as_deref()
|
||||
.map(|file| crate::storage::app_config_dir().map(|dir| dir.join(file)))
|
||||
.transpose()?
|
||||
.map(path_to_string);
|
||||
|
||||
Ok(ProviderSetupReport {
|
||||
status: "ok",
|
||||
profile: name.clone(),
|
||||
config_path: path_to_string(config_path),
|
||||
api_base,
|
||||
model: model.clone(),
|
||||
api_key_env,
|
||||
env_file,
|
||||
env_file_path,
|
||||
api_key_stored,
|
||||
auth: auth_label(&auth).to_string(),
|
||||
default_set: options.set_default,
|
||||
run_command: format!(
|
||||
"jcode --provider-profile {} --model {} run 'hello'",
|
||||
shell_quote(&name),
|
||||
shell_quote(&model)
|
||||
),
|
||||
auth_test_command: format!(
|
||||
"jcode --provider-profile {} auth-test --prompt {}",
|
||||
shell_quote(&name),
|
||||
shell_quote("Reply exactly JCODE_PROVIDER_SETUP_OK")
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_config_or_default(content: &str) -> Result<Config> {
|
||||
if content.trim().is_empty() {
|
||||
Ok(Config::default())
|
||||
} else {
|
||||
Ok(toml::from_str::<Config>(content)?)
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_profile_name(raw: &str) -> Result<String> {
|
||||
let name = raw.trim();
|
||||
if name.is_empty() {
|
||||
anyhow::bail!("provider profile name cannot be empty");
|
||||
}
|
||||
if name.len() > 64 {
|
||||
anyhow::bail!("provider profile name must be at most 64 characters");
|
||||
}
|
||||
let mut chars = name.chars();
|
||||
let Some(first) = chars.next() else {
|
||||
anyhow::bail!("provider profile name cannot be empty");
|
||||
};
|
||||
if !first.is_ascii_alphanumeric() {
|
||||
anyhow::bail!("provider profile name must start with a letter or number");
|
||||
}
|
||||
if !name
|
||||
.chars()
|
||||
.all(|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '_')
|
||||
{
|
||||
anyhow::bail!("provider profile name may only contain ASCII letters, numbers, '-' and '_'");
|
||||
}
|
||||
Ok(name.to_string())
|
||||
}
|
||||
|
||||
fn ensure_profile_name_not_reserved(name: &str) -> Result<()> {
|
||||
const RESERVED_PROVIDER_NAMES: &[&str] = &[
|
||||
"auto",
|
||||
"claude-subprocess",
|
||||
"compat",
|
||||
"custom",
|
||||
"azure-openai",
|
||||
"aoai",
|
||||
];
|
||||
if resolve_login_provider(name).is_some()
|
||||
|| RESERVED_PROVIDER_NAMES
|
||||
.iter()
|
||||
.any(|reserved| name.eq_ignore_ascii_case(reserved))
|
||||
{
|
||||
anyhow::bail!(
|
||||
"'{}' is a built-in provider id or alias. Choose a non-reserved profile name such as '{}-api'.",
|
||||
name,
|
||||
name
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn read_api_key(options: &ProviderAddOptions) -> Result<Option<String>> {
|
||||
if options.api_key_stdin {
|
||||
let mut input = String::new();
|
||||
std::io::stdin().read_to_string(&mut input)?;
|
||||
let key = input.trim().to_string();
|
||||
if key.is_empty() {
|
||||
anyhow::bail!("--api-key-stdin was set, but stdin was empty");
|
||||
}
|
||||
Ok(Some(key))
|
||||
} else {
|
||||
Ok(options
|
||||
.api_key
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|key| !key.is_empty())
|
||||
.map(ToString::to_string))
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_auth_mode(
|
||||
options: &ProviderAddOptions,
|
||||
api_key: Option<&str>,
|
||||
api_base: &str,
|
||||
) -> Result<NamedProviderAuth> {
|
||||
if options.no_api_key {
|
||||
return Ok(NamedProviderAuth::None);
|
||||
}
|
||||
if matches!(options.auth, Some(ProviderAuthArg::None)) {
|
||||
if api_key.is_some() || options.api_key_env.is_some() || options.env_file.is_some() {
|
||||
anyhow::bail!("--auth none cannot be combined with API key options");
|
||||
}
|
||||
return Ok(NamedProviderAuth::None);
|
||||
}
|
||||
if options.auth.is_none()
|
||||
&& api_key.is_none()
|
||||
&& options.api_key_env.is_none()
|
||||
&& options.env_file.is_none()
|
||||
&& api_base_uses_localhost(api_base)
|
||||
{
|
||||
return Ok(NamedProviderAuth::None);
|
||||
}
|
||||
|
||||
Ok(match options.auth.unwrap_or(ProviderAuthArg::Bearer) {
|
||||
ProviderAuthArg::Bearer => NamedProviderAuth::Bearer,
|
||||
ProviderAuthArg::ApiKey => NamedProviderAuth::Header,
|
||||
ProviderAuthArg::None => NamedProviderAuth::None,
|
||||
})
|
||||
}
|
||||
|
||||
fn resolve_api_key_env(name: &str, configured: Option<&str>) -> Result<String> {
|
||||
let env_name = configured
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToString::to_string)
|
||||
.unwrap_or_else(|| derived_api_key_env(name));
|
||||
if !is_safe_env_key_name(&env_name) {
|
||||
anyhow::bail!(
|
||||
"Invalid --api-key-env '{}'. Use uppercase letters, numbers, and underscores only.",
|
||||
env_name
|
||||
);
|
||||
}
|
||||
Ok(env_name)
|
||||
}
|
||||
|
||||
fn resolve_env_file(name: &str, configured: Option<&str>) -> Result<String> {
|
||||
let file = configured
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToString::to_string)
|
||||
.unwrap_or_else(|| format!("provider-{}.env", name));
|
||||
if !is_safe_env_file_name(&file) {
|
||||
anyhow::bail!(
|
||||
"Invalid --env-file '{}'. Use a file name only, with letters, numbers, '.', '_' or '-'.",
|
||||
file
|
||||
);
|
||||
}
|
||||
Ok(file)
|
||||
}
|
||||
|
||||
fn derived_api_key_env(name: &str) -> String {
|
||||
let suffix = name
|
||||
.chars()
|
||||
.map(|ch| {
|
||||
if ch.is_ascii_alphanumeric() {
|
||||
ch.to_ascii_uppercase()
|
||||
} else {
|
||||
'_'
|
||||
}
|
||||
})
|
||||
.collect::<String>();
|
||||
format!("JCODE_PROVIDER_{}_API_KEY", suffix)
|
||||
}
|
||||
|
||||
fn append_profile_section(
|
||||
mut content: String,
|
||||
name: &str,
|
||||
profile: &NamedProviderConfig,
|
||||
) -> String {
|
||||
if !content.trim().is_empty() && !content.ends_with('\n') {
|
||||
content.push('\n');
|
||||
}
|
||||
if !content.is_empty() && !content.ends_with("\n\n") {
|
||||
content.push('\n');
|
||||
}
|
||||
|
||||
content.push_str(&format!("[providers.{name}]\n"));
|
||||
content.push_str("type = \"openai-compatible\"\n");
|
||||
content.push_str(&format!("base_url = {}\n", toml_quote(&profile.base_url)));
|
||||
content.push_str(&format!(
|
||||
"auth = {}\n",
|
||||
toml_quote(auth_label(&profile.auth))
|
||||
));
|
||||
if let Some(header) = profile.auth_header.as_deref() {
|
||||
content.push_str(&format!("auth_header = {}\n", toml_quote(header)));
|
||||
}
|
||||
if let Some(api_key_env) = profile.api_key_env.as_deref() {
|
||||
content.push_str(&format!("api_key_env = {}\n", toml_quote(api_key_env)));
|
||||
}
|
||||
if let Some(env_file) = profile.env_file.as_deref() {
|
||||
content.push_str(&format!("env_file = {}\n", toml_quote(env_file)));
|
||||
}
|
||||
if let Some(default_model) = profile.default_model.as_deref() {
|
||||
content.push_str(&format!("default_model = {}\n", toml_quote(default_model)));
|
||||
}
|
||||
if let Some(requires_api_key) = profile.requires_api_key {
|
||||
content.push_str(&format!("requires_api_key = {requires_api_key}\n"));
|
||||
}
|
||||
if profile.provider_routing {
|
||||
content.push_str("provider_routing = true\nallow_provider_pinning = true\n");
|
||||
}
|
||||
if profile.model_catalog {
|
||||
content.push_str("model_catalog = true\n");
|
||||
}
|
||||
|
||||
for model in &profile.models {
|
||||
content.push_str(&format!("\n[[providers.{name}.models]]\n"));
|
||||
content.push_str(&format!("id = {}\n", toml_quote(&model.id)));
|
||||
if let Some(limit) = model.context_window {
|
||||
content.push_str(&format!("context_window = {limit}\n"));
|
||||
}
|
||||
}
|
||||
|
||||
content
|
||||
}
|
||||
|
||||
fn upsert_provider_defaults(content: String, profile: &str, model: &str) -> String {
|
||||
let mut lines = split_lines_lossy(&content);
|
||||
let provider_idx = lines.iter().position(|line| line.trim() == "[provider]");
|
||||
|
||||
let Some(idx) = provider_idx else {
|
||||
let mut prefix = String::from("[provider]\n");
|
||||
prefix.push_str(&format!("default_provider = {}\n", toml_quote(profile)));
|
||||
prefix.push_str(&format!("default_model = {}\n\n", toml_quote(model)));
|
||||
if content.trim().is_empty() {
|
||||
return prefix;
|
||||
}
|
||||
return format!("{prefix}{}", content.trim_start_matches('\n'));
|
||||
};
|
||||
|
||||
let end = lines
|
||||
.iter()
|
||||
.enumerate()
|
||||
.skip(idx + 1)
|
||||
.find(|(_, line)| is_toml_header(line))
|
||||
.map(|(line_idx, _)| line_idx)
|
||||
.unwrap_or(lines.len());
|
||||
|
||||
upsert_key_in_range(
|
||||
&mut lines,
|
||||
idx + 1,
|
||||
end,
|
||||
"default_provider",
|
||||
&toml_quote(profile),
|
||||
);
|
||||
let end = lines
|
||||
.iter()
|
||||
.enumerate()
|
||||
.skip(idx + 1)
|
||||
.find(|(_, line)| is_toml_header(line))
|
||||
.map(|(line_idx, _)| line_idx)
|
||||
.unwrap_or(lines.len());
|
||||
upsert_key_in_range(
|
||||
&mut lines,
|
||||
idx + 1,
|
||||
end,
|
||||
"default_model",
|
||||
&toml_quote(model),
|
||||
);
|
||||
|
||||
join_lines(lines)
|
||||
}
|
||||
|
||||
fn upsert_key_in_range(lines: &mut Vec<String>, start: usize, end: usize, key: &str, value: &str) {
|
||||
for line in lines.iter_mut().take(end).skip(start) {
|
||||
if line_has_toml_key(line, key) {
|
||||
*line = format!("{key} = {value}");
|
||||
return;
|
||||
}
|
||||
}
|
||||
lines.insert(end, format!("{key} = {value}"));
|
||||
}
|
||||
|
||||
fn remove_named_provider_sections(content: &str, name: &str) -> String {
|
||||
let lines = split_lines_lossy(content);
|
||||
let mut kept = Vec::with_capacity(lines.len());
|
||||
let mut skip = false;
|
||||
|
||||
for line in lines {
|
||||
if is_toml_header(&line) {
|
||||
skip = is_named_provider_header(&line, name);
|
||||
}
|
||||
if !skip {
|
||||
kept.push(line);
|
||||
}
|
||||
}
|
||||
|
||||
join_lines(kept)
|
||||
}
|
||||
|
||||
fn is_named_provider_header(line: &str, name: &str) -> bool {
|
||||
let trimmed = line.trim();
|
||||
let inner = if trimmed.starts_with("[[") && trimmed.ends_with("]]") {
|
||||
&trimmed[2..trimmed.len() - 2]
|
||||
} else if trimmed.starts_with('[') && trimmed.ends_with(']') {
|
||||
&trimmed[1..trimmed.len() - 1]
|
||||
} else {
|
||||
return false;
|
||||
};
|
||||
let inner = inner.trim();
|
||||
let plain = format!("providers.{name}");
|
||||
let double_quoted = format!("providers.{}", toml_quote(name));
|
||||
let single_quoted = format!("providers.'{name}'");
|
||||
inner == plain
|
||||
|| inner == format!("{plain}.models")
|
||||
|| inner == double_quoted
|
||||
|| inner == format!("{double_quoted}.models")
|
||||
|| inner == single_quoted
|
||||
|| inner == format!("{single_quoted}.models")
|
||||
}
|
||||
|
||||
fn is_toml_header(line: &str) -> bool {
|
||||
let trimmed = line.trim();
|
||||
trimmed.starts_with('[') && trimmed.ends_with(']')
|
||||
}
|
||||
|
||||
fn line_has_toml_key(line: &str, key: &str) -> bool {
|
||||
let trimmed = line.trim_start();
|
||||
let Some(rest) = trimmed.strip_prefix(key) else {
|
||||
return false;
|
||||
};
|
||||
rest.trim_start().starts_with('=')
|
||||
}
|
||||
|
||||
fn split_lines_lossy(content: &str) -> Vec<String> {
|
||||
content.lines().map(ToString::to_string).collect()
|
||||
}
|
||||
|
||||
fn join_lines(lines: Vec<String>) -> String {
|
||||
let mut joined = lines.join("\n");
|
||||
if !joined.is_empty() {
|
||||
joined.push('\n');
|
||||
}
|
||||
joined
|
||||
}
|
||||
|
||||
fn auth_label(auth: &NamedProviderAuth) -> &'static str {
|
||||
match auth {
|
||||
NamedProviderAuth::Bearer => "bearer",
|
||||
NamedProviderAuth::Header => "header",
|
||||
NamedProviderAuth::None => "none",
|
||||
}
|
||||
}
|
||||
|
||||
fn toml_quote(value: &str) -> String {
|
||||
serde_json::to_string(value).unwrap_or_else(|error| {
|
||||
crate::logging::warn(&format!(
|
||||
"failed to quote provider config string with serde_json: {error}"
|
||||
));
|
||||
format!("\"{}\"", value.escape_default())
|
||||
})
|
||||
}
|
||||
|
||||
fn shell_quote(value: &str) -> String {
|
||||
if value
|
||||
.chars()
|
||||
.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.' | '/' | ':'))
|
||||
{
|
||||
value.to_string()
|
||||
} else {
|
||||
format!("'{}'", value.replace('\'', "'\\''"))
|
||||
}
|
||||
}
|
||||
|
||||
fn path_to_string(path: PathBuf) -> String {
|
||||
path.display().to_string()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::ffi::OsString;
|
||||
|
||||
struct EnvVarGuard {
|
||||
key: &'static str,
|
||||
previous: Option<OsString>,
|
||||
}
|
||||
|
||||
impl EnvVarGuard {
|
||||
fn set(key: &'static str, value: impl AsRef<std::ffi::OsStr>) -> Self {
|
||||
let previous = std::env::var_os(key);
|
||||
crate::env::set_var(key, value);
|
||||
Self { key, previous }
|
||||
}
|
||||
|
||||
fn remove(key: &'static str) -> Self {
|
||||
let previous = std::env::var_os(key);
|
||||
crate::env::remove_var(key);
|
||||
Self { key, previous }
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for EnvVarGuard {
|
||||
fn drop(&mut self) {
|
||||
if let Some(previous) = &self.previous {
|
||||
crate::env::set_var(self.key, previous);
|
||||
} else {
|
||||
crate::env::remove_var(self.key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn base_options() -> ProviderAddOptions {
|
||||
ProviderAddOptions {
|
||||
name: "my-api".to_string(),
|
||||
base_url: "https://llm.example.com/v1".to_string(),
|
||||
model: "model-a".to_string(),
|
||||
context_window: Some(128_000),
|
||||
api_key_env: None,
|
||||
api_key: Some("secret-test-key".to_string()),
|
||||
api_key_stdin: false,
|
||||
no_api_key: false,
|
||||
auth: None,
|
||||
auth_header: None,
|
||||
env_file: None,
|
||||
set_default: true,
|
||||
overwrite: false,
|
||||
provider_routing: false,
|
||||
model_catalog: false,
|
||||
json: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_add_writes_named_profile_env_file_and_default() {
|
||||
let _lock = crate::storage::lock_test_env();
|
||||
let temp = tempfile::TempDir::new().expect("temp dir");
|
||||
let _home = EnvVarGuard::set("JCODE_HOME", temp.path());
|
||||
let _key = EnvVarGuard::remove("JCODE_PROVIDER_MY_API_API_KEY");
|
||||
let config_path = temp.path().join("config.toml");
|
||||
std::fs::write(
|
||||
&config_path,
|
||||
"# keep this comment\n[provider]\nopenai_reasoning_effort = \"low\"\n",
|
||||
)
|
||||
.expect("write config");
|
||||
|
||||
let report = configure_provider_profile(base_options()).expect("configure provider");
|
||||
|
||||
assert_eq!(report.profile, "my-api");
|
||||
let config = std::fs::read_to_string(&config_path).expect("read config");
|
||||
assert!(config.contains("# keep this comment"));
|
||||
assert!(config.contains("default_provider = \"my-api\""));
|
||||
assert!(config.contains("default_model = \"model-a\""));
|
||||
assert!(config.contains("[providers.my-api]"));
|
||||
assert!(!config.contains("secret-test-key"));
|
||||
|
||||
let parsed: Config = toml::from_str(&config).expect("valid config");
|
||||
assert_eq!(parsed.provider.default_provider.as_deref(), Some("my-api"));
|
||||
assert_eq!(parsed.provider.default_model.as_deref(), Some("model-a"));
|
||||
let profile = parsed.providers.get("my-api").expect("profile");
|
||||
assert_eq!(profile.base_url, "https://llm.example.com/v1");
|
||||
assert_eq!(profile.default_model.as_deref(), Some("model-a"));
|
||||
assert_eq!(
|
||||
profile.api_key_env.as_deref(),
|
||||
Some("JCODE_PROVIDER_MY_API_API_KEY")
|
||||
);
|
||||
assert_eq!(profile.env_file.as_deref(), Some("provider-my-api.env"));
|
||||
assert_eq!(profile.models[0].context_window, Some(128_000));
|
||||
|
||||
let env_file = temp
|
||||
.path()
|
||||
.join("config")
|
||||
.join("jcode")
|
||||
.join("provider-my-api.env");
|
||||
let env_content = std::fs::read_to_string(env_file).expect("env file");
|
||||
assert!(env_content.contains("JCODE_PROVIDER_MY_API_API_KEY=secret-test-key"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_add_rejects_remote_without_api_key_source() {
|
||||
let _lock = crate::storage::lock_test_env();
|
||||
let temp = tempfile::TempDir::new().expect("temp dir");
|
||||
let _home = EnvVarGuard::set("JCODE_HOME", temp.path());
|
||||
let mut options = base_options();
|
||||
options.api_key = None;
|
||||
options.set_default = false;
|
||||
|
||||
let err = configure_provider_profile(options).expect_err("should require key source");
|
||||
assert!(err.to_string().contains("needs an API key source"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_add_allows_localhost_without_api_key() {
|
||||
let _lock = crate::storage::lock_test_env();
|
||||
let temp = tempfile::TempDir::new().expect("temp dir");
|
||||
let _home = EnvVarGuard::set("JCODE_HOME", temp.path());
|
||||
let mut options = base_options();
|
||||
options.base_url = "http://localhost:8000/v1".to_string();
|
||||
options.api_key = None;
|
||||
options.set_default = false;
|
||||
|
||||
configure_provider_profile(options).expect("localhost no-auth should work");
|
||||
let config = std::fs::read_to_string(temp.path().join("config.toml")).expect("config");
|
||||
assert!(config.contains("auth = \"none\""));
|
||||
assert!(config.contains("requires_api_key = false"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,768 @@
|
||||
use anyhow::Result;
|
||||
use serde::Serialize;
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::cli::provider_init::{self, ProviderChoice};
|
||||
|
||||
const AUTH_DOCTOR_VALIDATION_TIMEOUT_SECS: u64 = 120;
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct AuthStatusProviderReport {
|
||||
id: String,
|
||||
display_name: String,
|
||||
status: String,
|
||||
method: String,
|
||||
health: String,
|
||||
credential_source: String,
|
||||
expiry_confidence: String,
|
||||
refresh_support: String,
|
||||
validation_method: String,
|
||||
last_refresh: Option<String>,
|
||||
validation: Option<String>,
|
||||
auth_kind: String,
|
||||
recommended: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct AuthStatusReport {
|
||||
any_available: bool,
|
||||
providers: Vec<AuthStatusProviderReport>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct AuthDoctorProviderReport {
|
||||
id: String,
|
||||
display_name: String,
|
||||
auth_kind: String,
|
||||
recommended: bool,
|
||||
status: String,
|
||||
method: String,
|
||||
health: String,
|
||||
credential_source: String,
|
||||
credential_source_detail: String,
|
||||
expiry_confidence: String,
|
||||
refresh_support: String,
|
||||
validation_method: String,
|
||||
last_refresh: Option<String>,
|
||||
last_refresh_detail: Option<AuthDoctorRefreshDetail>,
|
||||
validation: Option<String>,
|
||||
validation_detail: Option<AuthDoctorValidationDetail>,
|
||||
validation_result: Option<String>,
|
||||
diagnostics: Vec<String>,
|
||||
needs_attention: bool,
|
||||
recommended_actions: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct AuthDoctorRefreshDetail {
|
||||
last_attempt_ms: i64,
|
||||
last_success_ms: Option<i64>,
|
||||
last_error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct AuthDoctorValidationDetail {
|
||||
checked_at_ms: i64,
|
||||
success: bool,
|
||||
provider_smoke_ok: Option<bool>,
|
||||
tool_smoke_ok: Option<bool>,
|
||||
stale: bool,
|
||||
summary: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct AuthDoctorReport {
|
||||
checked_provider: Option<String>,
|
||||
validate: bool,
|
||||
any_issue: bool,
|
||||
providers: Vec<AuthDoctorProviderReport>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub(super) struct ProviderListEntry {
|
||||
pub(super) id: String,
|
||||
pub(super) display_name: String,
|
||||
pub(super) auth_kind: Option<String>,
|
||||
pub(super) recommended: bool,
|
||||
pub(super) aliases: Vec<String>,
|
||||
pub(super) detail: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct ProviderListReport {
|
||||
providers: Vec<ProviderListEntry>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct ProviderCurrentReport {
|
||||
requested_provider: String,
|
||||
requested_model: Option<String>,
|
||||
resolved_provider: String,
|
||||
selected_model: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub(super) struct VersionReport {
|
||||
pub(super) version: String,
|
||||
pub(super) semver: String,
|
||||
pub(super) base_semver: String,
|
||||
pub(super) update_semver: String,
|
||||
pub(super) git_hash: String,
|
||||
pub(super) git_tag: String,
|
||||
pub(super) build_time: String,
|
||||
pub(super) git_date: String,
|
||||
pub(super) release_build: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct UsageLimitReport {
|
||||
name: String,
|
||||
usage_percent: f32,
|
||||
resets_at: Option<String>,
|
||||
reset_in: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct UsageProviderReport {
|
||||
provider_name: String,
|
||||
limits: Vec<UsageLimitReport>,
|
||||
extra_info: Vec<(String, String)>,
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct UsageReport {
|
||||
providers: Vec<UsageProviderReport>,
|
||||
}
|
||||
|
||||
pub(super) fn run_auth_status_command(emit_json: bool) -> Result<()> {
|
||||
let report = build_auth_status_report();
|
||||
if emit_json {
|
||||
println!("{}", serde_json::to_string_pretty(&report)?);
|
||||
} else {
|
||||
for provider in report.providers {
|
||||
println!(
|
||||
"{}\t{}\t{}\t{}\t{}\t{}",
|
||||
provider.id,
|
||||
provider.status,
|
||||
provider.auth_kind,
|
||||
provider.method,
|
||||
provider.health,
|
||||
provider.validation.as_deref().unwrap_or("not validated")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn build_auth_status_report() -> AuthStatusReport {
|
||||
let status = crate::auth::AuthStatus::check();
|
||||
let validation = crate::auth::validation::load_all();
|
||||
let providers = crate::provider_catalog::auth_status_login_providers();
|
||||
let reports = providers
|
||||
.into_iter()
|
||||
.map(|provider| {
|
||||
let assessment = status.assessment_for_provider(provider);
|
||||
AuthStatusProviderReport {
|
||||
id: provider.id.to_string(),
|
||||
display_name: provider.display_name.to_string(),
|
||||
status: auth_state_label(assessment.state).to_string(),
|
||||
method: assessment.method_detail.clone(),
|
||||
health: assessment.health_summary(),
|
||||
credential_source: assessment.credential_source.label().to_string(),
|
||||
expiry_confidence: assessment.expiry_confidence.label().to_string(),
|
||||
refresh_support: assessment.refresh_support.label().to_string(),
|
||||
validation_method: assessment.validation_method.label().to_string(),
|
||||
last_refresh: assessment
|
||||
.last_refresh
|
||||
.as_ref()
|
||||
.map(crate::auth::refresh_state::format_record_label),
|
||||
validation: validation
|
||||
.get(provider.id)
|
||||
.map(crate::auth::validation::format_record_label),
|
||||
auth_kind: provider.auth_kind.label().to_string(),
|
||||
recommended: provider.recommended,
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
AuthStatusReport {
|
||||
any_available: status.has_any_available(),
|
||||
providers: reports,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn run_auth_doctor_command(
|
||||
provider_arg: Option<&str>,
|
||||
validate: bool,
|
||||
emit_json: bool,
|
||||
) -> Result<()> {
|
||||
let report = build_auth_doctor_report(provider_arg, validate).await?;
|
||||
|
||||
if emit_json {
|
||||
println!("{}", serde_json::to_string_pretty(&report)?);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
for (index, provider) in report.providers.iter().enumerate() {
|
||||
if index > 0 {
|
||||
println!();
|
||||
}
|
||||
println!("{} ({})", provider.display_name, provider.id);
|
||||
println!("auth_kind: {}", provider.auth_kind);
|
||||
println!("status: {}", provider.status);
|
||||
println!("method: {}", provider.method);
|
||||
println!("health: {}", provider.health);
|
||||
println!(
|
||||
"credential_source: {} ({})",
|
||||
provider.credential_source, provider.credential_source_detail
|
||||
);
|
||||
println!("expiry: {}", provider.expiry_confidence);
|
||||
println!("refresh: {}", provider.refresh_support);
|
||||
println!("validation_method: {}", provider.validation_method);
|
||||
println!(
|
||||
"last_refresh: {}",
|
||||
provider.last_refresh.as_deref().unwrap_or("not recorded")
|
||||
);
|
||||
println!(
|
||||
"validation: {}",
|
||||
provider.validation.as_deref().unwrap_or("not validated")
|
||||
);
|
||||
if let Some(validation_result) = provider.validation_result.as_deref() {
|
||||
println!("validation_run: {}", validation_result);
|
||||
}
|
||||
println!("needs_attention: {}", provider.needs_attention);
|
||||
if !provider.diagnostics.is_empty() {
|
||||
println!("diagnostics:");
|
||||
for diagnostic in &provider.diagnostics {
|
||||
println!("- {}", diagnostic);
|
||||
}
|
||||
}
|
||||
if !provider.recommended_actions.is_empty() {
|
||||
println!("next_steps:");
|
||||
for action in &provider.recommended_actions {
|
||||
println!("- {}", action);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn build_auth_doctor_report(
|
||||
provider_arg: Option<&str>,
|
||||
validate: bool,
|
||||
) -> Result<AuthDoctorReport> {
|
||||
let mut status = crate::auth::AuthStatus::check();
|
||||
let providers = select_auth_doctor_providers(provider_arg, &status)?;
|
||||
let mut reports = Vec::new();
|
||||
|
||||
for provider in providers {
|
||||
let pre_validation_assessment = status.assessment_for_provider(provider);
|
||||
let validation_result = if validate && pre_validation_assessment.is_configured() {
|
||||
Some(run_auth_doctor_validation(provider).await)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if validation_result.is_some() {
|
||||
crate::auth::AuthStatus::invalidate_cache();
|
||||
status = crate::auth::AuthStatus::check();
|
||||
}
|
||||
let assessment = status.assessment_for_provider(provider);
|
||||
let validation = assessment
|
||||
.last_validation
|
||||
.as_ref()
|
||||
.map(crate::auth::validation::format_record_label);
|
||||
let validation_detail = assessment
|
||||
.last_validation
|
||||
.as_ref()
|
||||
.map(auth_doctor_validation_detail);
|
||||
let last_refresh = assessment
|
||||
.last_refresh
|
||||
.as_ref()
|
||||
.map(crate::auth::refresh_state::format_record_label);
|
||||
let last_refresh_detail =
|
||||
assessment
|
||||
.last_refresh
|
||||
.as_ref()
|
||||
.map(|record| AuthDoctorRefreshDetail {
|
||||
last_attempt_ms: record.last_attempt_ms,
|
||||
last_success_ms: record.last_success_ms,
|
||||
last_error: record.last_error.clone(),
|
||||
});
|
||||
let recommended_actions = crate::auth::doctor::recommended_actions(
|
||||
provider,
|
||||
&assessment,
|
||||
validation_result.as_deref(),
|
||||
);
|
||||
let diagnostics =
|
||||
crate::auth::doctor::diagnostics(provider, &assessment, validation_result.as_deref());
|
||||
let method = assessment.method_detail.clone();
|
||||
let health = assessment.health_summary();
|
||||
let needs_attention =
|
||||
crate::auth::doctor::needs_attention(&assessment, validation_result.as_deref());
|
||||
|
||||
reports.push(AuthDoctorProviderReport {
|
||||
id: provider.id.to_string(),
|
||||
display_name: provider.display_name.to_string(),
|
||||
auth_kind: provider.auth_kind.label().to_string(),
|
||||
recommended: provider.recommended,
|
||||
status: auth_state_label(assessment.state).to_string(),
|
||||
method,
|
||||
health,
|
||||
credential_source: assessment.credential_source.label().to_string(),
|
||||
credential_source_detail: assessment.credential_source_detail.clone(),
|
||||
expiry_confidence: assessment.expiry_confidence.label().to_string(),
|
||||
refresh_support: assessment.refresh_support.label().to_string(),
|
||||
validation_method: assessment.validation_method.label().to_string(),
|
||||
last_refresh,
|
||||
last_refresh_detail,
|
||||
validation,
|
||||
validation_detail,
|
||||
validation_result,
|
||||
diagnostics,
|
||||
needs_attention,
|
||||
recommended_actions,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(AuthDoctorReport {
|
||||
checked_provider: provider_arg.map(str::to_string),
|
||||
validate,
|
||||
any_issue: reports.iter().any(|provider| provider.needs_attention),
|
||||
providers: reports,
|
||||
})
|
||||
}
|
||||
|
||||
async fn run_auth_doctor_validation(
|
||||
provider: crate::provider_catalog::LoginProviderDescriptor,
|
||||
) -> String {
|
||||
match tokio::time::timeout(
|
||||
Duration::from_secs(AUTH_DOCTOR_VALIDATION_TIMEOUT_SECS),
|
||||
super::super::auth_test::run_post_login_validation_quiet(provider),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(Ok(())) => "validation passed".to_string(),
|
||||
Ok(Err(err)) => err.to_string(),
|
||||
Err(_) => format!(
|
||||
"validation timed out after {}s; run `jcode auth-test --provider {}` for detailed output",
|
||||
AUTH_DOCTOR_VALIDATION_TIMEOUT_SECS, provider.id
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn auth_doctor_validation_detail(
|
||||
record: &crate::auth::validation::ProviderValidationRecord,
|
||||
) -> AuthDoctorValidationDetail {
|
||||
AuthDoctorValidationDetail {
|
||||
checked_at_ms: record.checked_at_ms,
|
||||
success: record.success,
|
||||
provider_smoke_ok: record.provider_smoke_ok,
|
||||
tool_smoke_ok: record.tool_smoke_ok,
|
||||
stale: crate::auth::doctor::validation_is_stale(record.checked_at_ms),
|
||||
summary: record.summary.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn run_provider_list_command(emit_json: bool) -> Result<()> {
|
||||
let providers = list_cli_providers();
|
||||
|
||||
if emit_json {
|
||||
let report = ProviderListReport { providers };
|
||||
println!("{}", serde_json::to_string_pretty(&report)?);
|
||||
} else {
|
||||
for provider in providers {
|
||||
if let Some(detail) = provider.detail.as_deref() {
|
||||
println!("{}\t{}\t{}", provider.id, provider.display_name, detail);
|
||||
} else {
|
||||
println!("{}\t{}", provider.id, provider.display_name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) async fn run_provider_current_command(
|
||||
choice: &ProviderChoice,
|
||||
model: Option<&str>,
|
||||
emit_json: bool,
|
||||
) -> Result<()> {
|
||||
let provider = provider_init::init_provider_quiet(choice, model).await?;
|
||||
let report = ProviderCurrentReport {
|
||||
requested_provider: choice.as_arg_value().to_string(),
|
||||
requested_model: model.map(str::to_string),
|
||||
resolved_provider: crate::provider_catalog::runtime_provider_display_name(provider.name()),
|
||||
selected_model: provider.model(),
|
||||
};
|
||||
|
||||
if emit_json {
|
||||
println!("{}", serde_json::to_string_pretty(&report)?);
|
||||
} else {
|
||||
println!("requested_provider\t{}", report.requested_provider);
|
||||
if let Some(requested_model) = report.requested_model.as_deref() {
|
||||
println!("requested_model\t{}", requested_model);
|
||||
}
|
||||
println!("resolved_provider\t{}", report.resolved_provider);
|
||||
println!("selected_model\t{}", report.selected_model);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn run_version_command(emit_json: bool) -> Result<()> {
|
||||
let report = VersionReport {
|
||||
version: jcode_build_meta::VERSION.to_string(),
|
||||
semver: jcode_build_meta::SEMVER.to_string(),
|
||||
base_semver: jcode_build_meta::BASE_SEMVER.to_string(),
|
||||
update_semver: jcode_build_meta::UPDATE_SEMVER.to_string(),
|
||||
git_hash: jcode_build_meta::GIT_HASH.to_string(),
|
||||
git_tag: jcode_build_meta::GIT_TAG.to_string(),
|
||||
build_time: crate::build::current_binary_build_time_string()
|
||||
.unwrap_or_else(|| "unknown".to_string()),
|
||||
git_date: jcode_build_meta::GIT_DATE.to_string(),
|
||||
release_build: jcode_build_meta::is_release_build(),
|
||||
};
|
||||
|
||||
if emit_json {
|
||||
println!("{}", serde_json::to_string_pretty(&report)?);
|
||||
} else {
|
||||
println!("version\t{}", report.version);
|
||||
println!("semver\t{}", report.semver);
|
||||
println!("base_semver\t{}", report.base_semver);
|
||||
println!("update_semver\t{}", report.update_semver);
|
||||
println!("git_hash\t{}", report.git_hash);
|
||||
println!("git_tag\t{}", report.git_tag);
|
||||
println!("build_time\t{}", report.build_time);
|
||||
println!("git_date\t{}", report.git_date);
|
||||
println!("release_build\t{}", report.release_build);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) async fn run_usage_command(emit_json: bool) -> Result<()> {
|
||||
let providers = crate::usage::fetch_all_provider_usage().await;
|
||||
|
||||
let report = UsageReport {
|
||||
providers: providers.iter().map(usage_provider_report).collect(),
|
||||
};
|
||||
|
||||
if emit_json {
|
||||
println!("{}", serde_json::to_string_pretty(&report)?);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if report.providers.is_empty() {
|
||||
println!("No connected providers");
|
||||
println!();
|
||||
println!("Next steps:");
|
||||
println!("- Use `jcode login --provider claude` to connect Claude OAuth.");
|
||||
println!("- Use `jcode login --provider openai` to connect ChatGPT / Codex OAuth.");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
for (idx, provider) in report.providers.iter().enumerate() {
|
||||
if idx > 0 {
|
||||
println!();
|
||||
}
|
||||
|
||||
println!("{}", provider.provider_name);
|
||||
println!("{}", "-".repeat(provider.provider_name.chars().count()));
|
||||
|
||||
if let Some(error) = &provider.error {
|
||||
println!("error: {}", error);
|
||||
continue;
|
||||
}
|
||||
|
||||
if provider.limits.is_empty() && provider.extra_info.is_empty() {
|
||||
println!("No usage data available.");
|
||||
continue;
|
||||
}
|
||||
|
||||
for limit in &provider.limits {
|
||||
match limit.reset_in.as_deref() {
|
||||
Some(reset_in) => println!(
|
||||
"{}: {} (resets in {})",
|
||||
limit.name,
|
||||
crate::usage::format_usage_bar(limit.usage_percent, 15),
|
||||
reset_in
|
||||
),
|
||||
None => println!(
|
||||
"{}: {}",
|
||||
limit.name,
|
||||
crate::usage::format_usage_bar(limit.usage_percent, 15)
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
if !provider.extra_info.is_empty() {
|
||||
if !provider.limits.is_empty() {
|
||||
println!();
|
||||
}
|
||||
for (key, value) in &provider.extra_info {
|
||||
println!("{}: {}", key, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn select_auth_doctor_providers(
|
||||
provider_arg: Option<&str>,
|
||||
status: &crate::auth::AuthStatus,
|
||||
) -> Result<Vec<crate::provider_catalog::LoginProviderDescriptor>> {
|
||||
if let Some(provider_arg) = provider_arg {
|
||||
let provider =
|
||||
crate::provider_catalog::resolve_login_provider(provider_arg).ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"Unknown provider '{}'. Use `jcode provider list` to see valid provider ids.",
|
||||
provider_arg
|
||||
)
|
||||
})?;
|
||||
return Ok(vec![provider]);
|
||||
}
|
||||
|
||||
let configured = crate::provider_catalog::auth_status_login_providers()
|
||||
.into_iter()
|
||||
.filter(|provider| status.assessment_for_provider(*provider).is_configured())
|
||||
.collect::<Vec<_>>();
|
||||
if configured.is_empty() {
|
||||
Ok(crate::provider_catalog::auth_status_login_providers().to_vec())
|
||||
} else {
|
||||
Ok(configured)
|
||||
}
|
||||
}
|
||||
|
||||
fn usage_provider_report(provider: &crate::usage::ProviderUsage) -> UsageProviderReport {
|
||||
UsageProviderReport {
|
||||
provider_name: provider.provider_name.clone(),
|
||||
limits: provider
|
||||
.limits
|
||||
.iter()
|
||||
.map(|limit| UsageLimitReport {
|
||||
name: limit.name.clone(),
|
||||
usage_percent: limit.usage_percent,
|
||||
resets_at: limit.resets_at.clone(),
|
||||
reset_in: limit
|
||||
.resets_at
|
||||
.as_deref()
|
||||
.map(crate::usage::format_reset_time),
|
||||
})
|
||||
.collect(),
|
||||
extra_info: provider.extra_info.clone(),
|
||||
error: provider.error.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn list_cli_providers() -> Vec<ProviderListEntry> {
|
||||
let choices = [
|
||||
ProviderChoice::Jcode,
|
||||
ProviderChoice::Claude,
|
||||
ProviderChoice::Openai,
|
||||
ProviderChoice::Openrouter,
|
||||
ProviderChoice::Azure,
|
||||
ProviderChoice::Opencode,
|
||||
ProviderChoice::OpencodeGo,
|
||||
ProviderChoice::Zai,
|
||||
ProviderChoice::Kimi,
|
||||
ProviderChoice::Groq,
|
||||
ProviderChoice::Mistral,
|
||||
ProviderChoice::Perplexity,
|
||||
ProviderChoice::TogetherAi,
|
||||
ProviderChoice::Deepinfra,
|
||||
ProviderChoice::Xai,
|
||||
ProviderChoice::Chutes,
|
||||
ProviderChoice::Cerebras,
|
||||
ProviderChoice::AlibabaCodingPlan,
|
||||
ProviderChoice::OpenaiCompatible,
|
||||
ProviderChoice::Cursor,
|
||||
ProviderChoice::Copilot,
|
||||
ProviderChoice::Gemini,
|
||||
ProviderChoice::Antigravity,
|
||||
ProviderChoice::Google,
|
||||
ProviderChoice::Auto,
|
||||
];
|
||||
|
||||
choices
|
||||
.into_iter()
|
||||
.map(|choice| {
|
||||
if let Some(provider) = provider_init::login_provider_for_choice(&choice) {
|
||||
ProviderListEntry {
|
||||
id: choice.as_arg_value().to_string(),
|
||||
display_name: provider.display_name.to_string(),
|
||||
auth_kind: Some(provider.auth_kind.label().to_string()),
|
||||
recommended: provider.recommended,
|
||||
aliases: provider
|
||||
.aliases
|
||||
.iter()
|
||||
.map(|alias| (*alias).to_string())
|
||||
.collect(),
|
||||
detail: Some(provider.menu_detail.to_string()),
|
||||
}
|
||||
} else {
|
||||
ProviderListEntry {
|
||||
id: choice.as_arg_value().to_string(),
|
||||
display_name: "Auto-detect".to_string(),
|
||||
auth_kind: None,
|
||||
recommended: false,
|
||||
aliases: Vec::new(),
|
||||
detail: Some("Use the best configured provider automatically".to_string()),
|
||||
}
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn auth_state_label(state: crate::auth::AuthState) -> &'static str {
|
||||
match state {
|
||||
crate::auth::AuthState::Available => "available",
|
||||
crate::auth::AuthState::Expired => "expired",
|
||||
crate::auth::AuthState::NotConfigured => "not_configured",
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn provider_status<'a>(
|
||||
report: &'a AuthStatusReport,
|
||||
provider_id: &str,
|
||||
) -> &'a AuthStatusProviderReport {
|
||||
report
|
||||
.providers
|
||||
.iter()
|
||||
.find(|provider| provider.id == provider_id)
|
||||
.unwrap_or_else(|| panic!("missing auth status provider `{}`", provider_id))
|
||||
}
|
||||
|
||||
fn provider_doctor<'a>(
|
||||
report: &'a AuthDoctorReport,
|
||||
provider_id: &str,
|
||||
) -> &'a AuthDoctorProviderReport {
|
||||
report
|
||||
.providers
|
||||
.iter()
|
||||
.find(|provider| provider.id == provider_id)
|
||||
.unwrap_or_else(|| panic!("missing auth doctor provider `{}`", provider_id))
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cli_auth_status_doctor_and_login_lifecycle_uses_fresh_sandbox() {
|
||||
let sandbox = crate::auth::test_sandbox::AuthTestSandbox::new().expect("sandbox");
|
||||
let provider = crate::provider_catalog::CEREBRAS_LOGIN_PROVIDER;
|
||||
let profile = crate::provider_catalog::CEREBRAS_PROFILE;
|
||||
let resolved = crate::provider_catalog::resolve_openai_compatible_profile(profile);
|
||||
let env_file = sandbox.env_file_path(&resolved.env_file);
|
||||
|
||||
assert!(
|
||||
!env_file.exists(),
|
||||
"fresh CLI sandbox should start without {}",
|
||||
env_file.display()
|
||||
);
|
||||
crate::auth::AuthStatus::invalidate_cache();
|
||||
|
||||
let before_status = build_auth_status_report();
|
||||
let before_cerebras = provider_status(&before_status, provider.id);
|
||||
assert_eq!(before_cerebras.status, "not_configured");
|
||||
assert_eq!(before_cerebras.auth_kind, "API key");
|
||||
assert_eq!(before_cerebras.credential_source, "none");
|
||||
assert_eq!(before_cerebras.method, "not configured");
|
||||
|
||||
let before_doctor = build_auth_doctor_report(Some(provider.id), false)
|
||||
.await
|
||||
.expect("doctor before login");
|
||||
assert_eq!(before_doctor.checked_provider.as_deref(), Some(provider.id));
|
||||
assert!(before_doctor.any_issue);
|
||||
let before_doctor_provider = provider_doctor(&before_doctor, provider.id);
|
||||
assert_eq!(before_doctor_provider.status, "not_configured");
|
||||
assert!(before_doctor_provider.needs_attention);
|
||||
assert!(before_doctor_provider.diagnostics.iter().any(|line| {
|
||||
line == &format!("{} is not configured for jcode yet.", provider.display_name)
|
||||
}));
|
||||
assert!(
|
||||
before_doctor_provider
|
||||
.recommended_actions
|
||||
.iter()
|
||||
.any(|line| {
|
||||
line == &format!("Connect it: jcode login --provider {}", provider.id)
|
||||
})
|
||||
);
|
||||
|
||||
crate::cli::login::run_login(
|
||||
&crate::cli::provider_init::ProviderChoice::Cerebras,
|
||||
None,
|
||||
crate::cli::login::LoginOptions {
|
||||
no_validate: true,
|
||||
openai_compatible_api_key: Some("test-cerebras-cli-key".to_string()),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("CLI login should save Cerebras key in sandbox");
|
||||
|
||||
assert!(
|
||||
env_file.exists(),
|
||||
"CLI login should create provider env file"
|
||||
);
|
||||
assert_eq!(
|
||||
crate::provider_catalog::load_api_key_from_env_or_config(
|
||||
&resolved.api_key_env,
|
||||
&resolved.env_file,
|
||||
)
|
||||
.as_deref(),
|
||||
Some("test-cerebras-cli-key")
|
||||
);
|
||||
crate::env::remove_var(&resolved.api_key_env);
|
||||
crate::auth::AuthStatus::invalidate_cache();
|
||||
|
||||
let after_status = build_auth_status_report();
|
||||
let after_cerebras = provider_status(&after_status, provider.id);
|
||||
assert!(after_status.any_available);
|
||||
assert_eq!(after_cerebras.status, "available");
|
||||
assert_eq!(after_cerebras.auth_kind, "API key");
|
||||
assert_eq!(after_cerebras.credential_source, "app config file");
|
||||
assert!(after_cerebras.method.contains(&resolved.api_key_env));
|
||||
assert!(
|
||||
after_cerebras.health.contains(&resolved.env_file),
|
||||
"status should show the sandbox env-file-backed source detail: {:?}",
|
||||
after_cerebras
|
||||
);
|
||||
|
||||
let after_doctor = build_auth_doctor_report(Some(provider.id), false)
|
||||
.await
|
||||
.expect("doctor after login");
|
||||
assert_eq!(after_doctor.checked_provider.as_deref(), Some(provider.id));
|
||||
let after_doctor_provider = provider_doctor(&after_doctor, provider.id);
|
||||
assert_eq!(after_doctor_provider.status, "available");
|
||||
assert_eq!(after_doctor_provider.credential_source, "app config file");
|
||||
assert!(after_doctor_provider.needs_attention);
|
||||
assert!(
|
||||
after_doctor_provider
|
||||
.diagnostics
|
||||
.iter()
|
||||
.any(|line| { line == "No runtime validation has been recorded." })
|
||||
);
|
||||
assert!(
|
||||
after_doctor_provider
|
||||
.recommended_actions
|
||||
.iter()
|
||||
.any(|line| {
|
||||
line == &format!(
|
||||
"Run runtime verification: jcode auth-test --provider {}",
|
||||
provider.id
|
||||
)
|
||||
})
|
||||
);
|
||||
assert!(
|
||||
after_doctor_provider
|
||||
.recommended_actions
|
||||
.iter()
|
||||
.any(|line| { line == "Review current state: jcode auth status --json" })
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
use anyhow::Result;
|
||||
use chrono::Utc;
|
||||
use serde::Deserialize;
|
||||
use std::io::IsTerminal;
|
||||
use std::path::PathBuf;
|
||||
|
||||
pub async fn run_restart_save_command(auto_restore: bool) -> Result<()> {
|
||||
let mut snapshot = if let Some(snapshot) = capture_connected_restart_snapshot().await? {
|
||||
snapshot
|
||||
} else {
|
||||
crate::restart_snapshot::save_current_snapshot()?
|
||||
};
|
||||
snapshot.auto_restore_on_next_start = auto_restore;
|
||||
crate::restart_snapshot::write_snapshot(&snapshot)?;
|
||||
let path = crate::restart_snapshot::snapshot_path()?;
|
||||
|
||||
if snapshot.sessions.is_empty() {
|
||||
println!("Saved empty reboot snapshot to {}", path.display());
|
||||
if auto_restore {
|
||||
println!("Automatic restore is armed for the next plain `jcode` launch.");
|
||||
}
|
||||
println!("\nNo active jcode windows were detected.");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
println!(
|
||||
"Saved reboot snapshot with {} session(s) to {}\n",
|
||||
snapshot.sessions.len(),
|
||||
path.display()
|
||||
);
|
||||
for session in &snapshot.sessions {
|
||||
let suffix = if session.is_selfdev {
|
||||
" [self-dev]"
|
||||
} else {
|
||||
""
|
||||
};
|
||||
println!(
|
||||
"- {} ({}){}",
|
||||
session.display_name, session.session_id, suffix
|
||||
);
|
||||
}
|
||||
if auto_restore {
|
||||
println!("\nAutomatic restore is armed for the next plain `jcode` launch.");
|
||||
}
|
||||
println!("\nAfter reboot, restore them with:\n jcode restart restore");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn run_restart_status_command() -> Result<()> {
|
||||
let path = crate::restart_snapshot::snapshot_path()?;
|
||||
let snapshot = match crate::restart_snapshot::load_snapshot() {
|
||||
Ok(snapshot) => snapshot,
|
||||
Err(_) => {
|
||||
println!("No reboot snapshot saved.\n\nCreate one with:\n jcode restart save");
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
|
||||
println!(
|
||||
"Reboot snapshot: {}\nCreated: {}\nSessions: {}\nAuto-restore on next plain startup: {}\n",
|
||||
path.display(),
|
||||
snapshot.created_at,
|
||||
snapshot.sessions.len(),
|
||||
if snapshot.auto_restore_on_next_start {
|
||||
"armed"
|
||||
} else {
|
||||
"off"
|
||||
}
|
||||
);
|
||||
for session in &snapshot.sessions {
|
||||
let suffix = if session.is_selfdev {
|
||||
" [self-dev]"
|
||||
} else {
|
||||
""
|
||||
};
|
||||
println!(
|
||||
"- {} ({}){}",
|
||||
session.display_name, session.session_id, suffix
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn maybe_run_pending_restart_restore_on_startup() -> Result<bool> {
|
||||
let snapshot = match crate::restart_snapshot::load_snapshot() {
|
||||
Ok(snapshot) => snapshot,
|
||||
// Do not synthesize an auto-restore snapshot from crashed sessions here.
|
||||
// A crashed session should remain crashed until the user explicitly resumes
|
||||
// or restores it, rather than being respawned by the next default startup.
|
||||
Err(_) => return Ok(false),
|
||||
};
|
||||
|
||||
if snapshot.auto_restore_on_next_start {
|
||||
let _ = crate::restart_snapshot::set_auto_restore_on_next_start(false);
|
||||
println!(
|
||||
"Found a reboot snapshot with auto-restore enabled. Restoring {} jcode window(s)...\n",
|
||||
snapshot.sessions.len()
|
||||
);
|
||||
run_restart_restore_command()?;
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
if std::io::stdin().is_terminal() || std::io::stderr().is_terminal() {
|
||||
println!("Saved reboot snapshot detected. Restore it with:\n jcode restart restore\n");
|
||||
}
|
||||
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
pub fn run_restart_clear_command() -> Result<()> {
|
||||
if crate::restart_snapshot::clear_snapshot()? {
|
||||
println!("Cleared reboot snapshot.");
|
||||
} else {
|
||||
println!("No reboot snapshot was saved.");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn run_restart_restore_command() -> Result<()> {
|
||||
let exe = current_restart_restore_exe()?;
|
||||
let result = match crate::restart_snapshot::restore_snapshot(&exe) {
|
||||
Ok(result) => result,
|
||||
Err(error) => {
|
||||
let path = crate::restart_snapshot::snapshot_path()?;
|
||||
return Err(anyhow::anyhow!(
|
||||
"Failed to restore reboot snapshot at {}: {}",
|
||||
path.display(),
|
||||
error
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
if result.snapshot.sessions.is_empty() {
|
||||
println!("Saved reboot snapshot is empty. Nothing to restore.");
|
||||
let _ = crate::restart_snapshot::clear_snapshot();
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let launched = result
|
||||
.outcomes
|
||||
.iter()
|
||||
.filter(|outcome| outcome.launched)
|
||||
.count();
|
||||
let fallback = result.outcomes.len().saturating_sub(launched);
|
||||
|
||||
if launched > 0 {
|
||||
println!("Restored {} jcode window(s).", launched);
|
||||
}
|
||||
|
||||
if fallback > 0 {
|
||||
println!(
|
||||
"\n{} session(s) could not be opened automatically. Run these commands manually:\n",
|
||||
fallback
|
||||
);
|
||||
for outcome in result.outcomes.iter().filter(|outcome| !outcome.launched) {
|
||||
println!("# {}", outcome.session.display_name);
|
||||
println!("{}", outcome.command);
|
||||
}
|
||||
println!(
|
||||
"\nThe reboot snapshot was kept so you can try `jcode restart restore` again later."
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let _ = crate::restart_snapshot::clear_snapshot();
|
||||
println!("Cleared reboot snapshot after successful restore.");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn current_restart_restore_exe() -> Result<PathBuf> {
|
||||
crate::build::client_update_candidate(false)
|
||||
.map(|(path, _)| path)
|
||||
.or_else(|| std::env::current_exe().ok())
|
||||
.ok_or_else(|| anyhow::anyhow!("Could not determine jcode executable for restore"))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ConnectedRestartSessionRow {
|
||||
session_id: String,
|
||||
#[serde(default)]
|
||||
working_dir: Option<String>,
|
||||
}
|
||||
|
||||
async fn capture_connected_restart_snapshot()
|
||||
-> Result<Option<crate::restart_snapshot::RestartSnapshot>> {
|
||||
let mut client = match crate::server::Client::connect_debug().await {
|
||||
Ok(client) => client,
|
||||
Err(_) => return Ok(None),
|
||||
};
|
||||
|
||||
let request_id = client.debug_command("sessions", None).await?;
|
||||
let response = loop {
|
||||
match client.read_event().await? {
|
||||
crate::protocol::ServerEvent::DebugResponse { id, ok, output } if id == request_id => {
|
||||
if !ok {
|
||||
anyhow::bail!(output);
|
||||
}
|
||||
break output;
|
||||
}
|
||||
crate::protocol::ServerEvent::Ack { id } if id == request_id => {}
|
||||
crate::protocol::ServerEvent::Done { id } if id == request_id => {}
|
||||
crate::protocol::ServerEvent::Error { id, message, .. } if id == request_id => {
|
||||
anyhow::bail!(message);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
};
|
||||
|
||||
let rows: Vec<ConnectedRestartSessionRow> = serde_json::from_str(&response)?;
|
||||
if rows.is_empty() {
|
||||
return Ok(Some(crate::restart_snapshot::RestartSnapshot {
|
||||
version: 1,
|
||||
created_at: Utc::now(),
|
||||
auto_restore_on_next_start: false,
|
||||
sessions: Vec::new(),
|
||||
}));
|
||||
}
|
||||
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
let mut sessions = Vec::new();
|
||||
for row in rows {
|
||||
if !seen.insert(row.session_id.clone()) {
|
||||
continue;
|
||||
}
|
||||
let Ok(mut session) = crate::session::Session::load(&row.session_id) else {
|
||||
continue;
|
||||
};
|
||||
if session.detect_crash() {
|
||||
let _ = session.save();
|
||||
continue;
|
||||
}
|
||||
sessions.push(crate::restart_snapshot::RestartSnapshotSession {
|
||||
session_id: session.id.clone(),
|
||||
display_name: session.display_name().to_string(),
|
||||
working_dir: session.working_dir.clone().or(row.working_dir),
|
||||
is_selfdev: session.is_canary,
|
||||
});
|
||||
}
|
||||
|
||||
sessions.sort_by(|a, b| {
|
||||
a.display_name
|
||||
.cmp(&b.display_name)
|
||||
.then_with(|| a.session_id.cmp(&b.session_id))
|
||||
});
|
||||
|
||||
Ok(Some(crate::restart_snapshot::RestartSnapshot {
|
||||
version: 1,
|
||||
created_at: Utc::now(),
|
||||
auto_restore_on_next_start: false,
|
||||
sessions,
|
||||
}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "restart_tests.rs"]
|
||||
mod restart_tests;
|
||||
@@ -0,0 +1,107 @@
|
||||
use super::{
|
||||
maybe_run_pending_restart_restore_on_startup, run_restart_clear_command,
|
||||
run_restart_save_command,
|
||||
};
|
||||
use crate::session::Session;
|
||||
use std::ffi::OsString;
|
||||
|
||||
struct TestEnvGuard {
|
||||
prev_home: Option<OsString>,
|
||||
_temp_home: tempfile::TempDir,
|
||||
_lock: std::sync::MutexGuard<'static, ()>,
|
||||
}
|
||||
|
||||
impl TestEnvGuard {
|
||||
fn new() -> anyhow::Result<Self> {
|
||||
let lock = crate::storage::lock_test_env();
|
||||
let temp_home = tempfile::Builder::new()
|
||||
.prefix("jcode-cli-restart-test-home-")
|
||||
.tempdir()?;
|
||||
let prev_home = std::env::var_os("JCODE_HOME");
|
||||
crate::env::set_var("JCODE_HOME", temp_home.path());
|
||||
Ok(Self {
|
||||
prev_home,
|
||||
_temp_home: temp_home,
|
||||
_lock: lock,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TestEnvGuard {
|
||||
fn drop(&mut self) {
|
||||
if let Some(prev_home) = &self.prev_home {
|
||||
crate::env::set_var("JCODE_HOME", prev_home);
|
||||
} else {
|
||||
crate::env::remove_var("JCODE_HOME");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn restart_save_writes_empty_snapshot_with_auto_restore_flag() {
|
||||
let _guard = TestEnvGuard::new().expect("setup test env");
|
||||
|
||||
run_restart_save_command(true)
|
||||
.await
|
||||
.expect("save restart snapshot");
|
||||
|
||||
let snapshot = crate::restart_snapshot::load_snapshot().expect("load snapshot");
|
||||
assert!(snapshot.auto_restore_on_next_start);
|
||||
assert!(snapshot.sessions.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pending_restore_returns_false_for_unarmed_snapshot() {
|
||||
let _guard = TestEnvGuard::new().expect("setup test env");
|
||||
|
||||
run_restart_save_command(false)
|
||||
.await
|
||||
.expect("save restart snapshot");
|
||||
|
||||
assert!(
|
||||
!maybe_run_pending_restart_restore_on_startup()
|
||||
.await
|
||||
.expect("check pending restore")
|
||||
);
|
||||
assert!(crate::restart_snapshot::load_snapshot().is_ok());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pending_restore_does_not_auto_restore_recent_crash_without_snapshot() {
|
||||
let _guard = TestEnvGuard::new().expect("setup test env");
|
||||
|
||||
let mut child = std::process::Command::new("sh")
|
||||
.arg("-c")
|
||||
.arg("exit 0")
|
||||
.spawn()
|
||||
.expect("spawn child");
|
||||
let dead_pid = child.id();
|
||||
let _ = child.wait().expect("wait for child");
|
||||
|
||||
let mut crashed = Session::create_with_id(
|
||||
"session_no_startup_auto_restore_crash".to_string(),
|
||||
None,
|
||||
Some("Do Not Respawn".to_string()),
|
||||
);
|
||||
crashed.mark_active_with_pid(dead_pid);
|
||||
crashed.save().expect("save active session with dead pid");
|
||||
|
||||
assert!(
|
||||
!maybe_run_pending_restart_restore_on_startup()
|
||||
.await
|
||||
.expect("check pending restore")
|
||||
);
|
||||
assert!(crate::restart_snapshot::load_snapshot().is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn restart_clear_removes_saved_snapshot() {
|
||||
let _guard = TestEnvGuard::new().expect("setup test env");
|
||||
|
||||
run_restart_save_command(false)
|
||||
.await
|
||||
.expect("save restart snapshot");
|
||||
run_restart_clear_command().expect("clear restart snapshot");
|
||||
|
||||
assert!(crate::restart_snapshot::load_snapshot().is_err());
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,294 @@
|
||||
use anyhow::Result;
|
||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||
|
||||
use crate::server;
|
||||
|
||||
pub async fn run_debug_command(
|
||||
command: &str,
|
||||
arg: &str,
|
||||
session_id: Option<String>,
|
||||
socket_path: Option<String>,
|
||||
_wait: bool,
|
||||
) -> Result<()> {
|
||||
match command {
|
||||
"list" => return debug_list_servers().await,
|
||||
"start" => return debug_start_server(arg, socket_path).await,
|
||||
_ => {}
|
||||
}
|
||||
|
||||
let debug_socket = if let Some(ref path) = socket_path {
|
||||
let main_path = std::path::PathBuf::from(path);
|
||||
let filename = main_path
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or("jcode.sock");
|
||||
let debug_filename = filename.replace(".sock", "-debug.sock");
|
||||
main_path.with_file_name(debug_filename)
|
||||
} else {
|
||||
server::debug_socket_path()
|
||||
};
|
||||
|
||||
if !crate::transport::is_socket_path(&debug_socket) {
|
||||
eprintln!("Debug socket not found at {:?}", debug_socket);
|
||||
eprintln!("\nMake sure:");
|
||||
eprintln!(" 1. A jcode server is running (jcode or jcode serve)");
|
||||
eprintln!(" 2. debug_socket is enabled in ~/.jcode/config.toml");
|
||||
eprintln!(" [display]");
|
||||
eprintln!(" debug_socket = true");
|
||||
eprintln!("\nOr use 'jcode debug start' to start a server.");
|
||||
eprintln!("Use 'jcode debug list' to see running servers.");
|
||||
anyhow::bail!("Debug socket not available");
|
||||
}
|
||||
|
||||
let stream = server::connect_socket(&debug_socket).await?;
|
||||
let (reader, mut writer) = stream.into_split();
|
||||
let mut reader = BufReader::new(reader);
|
||||
|
||||
let debug_cmd = if arg.is_empty() {
|
||||
command.to_string()
|
||||
} else {
|
||||
format!("{}:{}", command, arg)
|
||||
};
|
||||
|
||||
let request = serde_json::json!({
|
||||
"type": "debug_command",
|
||||
"id": 1,
|
||||
"command": debug_cmd,
|
||||
"session_id": session_id,
|
||||
});
|
||||
|
||||
let mut json = serde_json::to_string(&request)?;
|
||||
json.push('\n');
|
||||
writer.write_all(json.as_bytes()).await?;
|
||||
|
||||
let mut line = String::new();
|
||||
let n = reader.read_line(&mut line).await?;
|
||||
if n == 0 {
|
||||
anyhow::bail!("Server disconnected before sending response");
|
||||
}
|
||||
|
||||
let response: serde_json::Value = serde_json::from_str(&line)?;
|
||||
|
||||
match response.get("type").and_then(|v| v.as_str()) {
|
||||
Some("debug_response") => {
|
||||
let ok = response
|
||||
.get("ok")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
let output = response
|
||||
.get("output")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
|
||||
if ok {
|
||||
println!("{}", output);
|
||||
} else {
|
||||
eprintln!("Error: {}", output);
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
Some("error") => {
|
||||
let message = response
|
||||
.get("message")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("Unknown error");
|
||||
eprintln!("Error: {}", message);
|
||||
std::process::exit(1);
|
||||
}
|
||||
_ => {
|
||||
println!("{}", serde_json::to_string_pretty(&response)?);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn debug_list_servers() -> Result<()> {
|
||||
let mut servers = Vec::new();
|
||||
|
||||
let runtime_dir = crate::storage::runtime_dir();
|
||||
let mut scan_dirs = vec![runtime_dir.clone()];
|
||||
let temp_dir = std::env::temp_dir();
|
||||
if temp_dir != runtime_dir {
|
||||
scan_dirs.push(temp_dir);
|
||||
}
|
||||
|
||||
for dir in scan_dirs {
|
||||
if let Ok(entries) = std::fs::read_dir(&dir) {
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if let Some(name) = path.file_name().and_then(|n| n.to_str())
|
||||
&& name.starts_with("jcode")
|
||||
&& name.ends_with(".sock")
|
||||
&& !name.contains("-debug")
|
||||
{
|
||||
servers.push(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if servers.is_empty() {
|
||||
println!("No running jcode servers found.");
|
||||
println!("\nStart one with: jcode debug start");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
println!("Running jcode servers:\n");
|
||||
|
||||
for socket_path in servers {
|
||||
let debug_socket = {
|
||||
let filename = socket_path
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or("jcode.sock");
|
||||
let debug_filename = filename.replace(".sock", "-debug.sock");
|
||||
socket_path.with_file_name(debug_filename)
|
||||
};
|
||||
|
||||
let alive = crate::transport::Stream::connect(&socket_path)
|
||||
.await
|
||||
.is_ok();
|
||||
|
||||
let debug_enabled = if crate::transport::is_socket_path(&debug_socket) {
|
||||
crate::transport::Stream::connect(&debug_socket)
|
||||
.await
|
||||
.is_ok()
|
||||
} else {
|
||||
false
|
||||
};
|
||||
|
||||
let session_info = if debug_enabled {
|
||||
get_server_info(&debug_socket).await.unwrap_or_default()
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
let status = if alive {
|
||||
if debug_enabled {
|
||||
format!("✓ running, debug: enabled{}", session_info)
|
||||
} else {
|
||||
"✓ running, debug: disabled".to_string()
|
||||
}
|
||||
} else {
|
||||
"✗ not responding (stale socket?)".to_string()
|
||||
};
|
||||
|
||||
println!(" {} ({})", socket_path.display(), status);
|
||||
}
|
||||
|
||||
println!("\nUse -s/--socket to target a specific server:");
|
||||
println!(" jcode debug -s /path/to/socket.sock sessions");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get_server_info(debug_socket: &std::path::Path) -> Result<String> {
|
||||
use crate::transport::Stream;
|
||||
|
||||
let stream = Stream::connect(debug_socket).await?;
|
||||
let (reader, mut writer) = stream.into_split();
|
||||
let mut reader = BufReader::new(reader);
|
||||
|
||||
let request = serde_json::json!({
|
||||
"type": "debug_command",
|
||||
"id": 1,
|
||||
"command": "sessions",
|
||||
});
|
||||
let mut json = serde_json::to_string(&request)?;
|
||||
json.push('\n');
|
||||
writer.write_all(json.as_bytes()).await?;
|
||||
|
||||
let mut line = String::new();
|
||||
let n = tokio::time::timeout(
|
||||
std::time::Duration::from_secs(2),
|
||||
reader.read_line(&mut line),
|
||||
)
|
||||
.await??;
|
||||
if n == 0 {
|
||||
return Ok(String::new());
|
||||
}
|
||||
|
||||
let response: serde_json::Value = serde_json::from_str(&line)?;
|
||||
if let Some(output) = response.get("output").and_then(|v| v.as_str())
|
||||
&& let Ok(sessions) = serde_json::from_str::<Vec<String>>(output)
|
||||
{
|
||||
return Ok(format!(", sessions: {}", sessions.len()));
|
||||
}
|
||||
|
||||
Ok(String::new())
|
||||
}
|
||||
|
||||
async fn debug_start_server(arg: &str, socket_path: Option<String>) -> Result<()> {
|
||||
let socket = socket_path.unwrap_or_else(|| {
|
||||
if !arg.is_empty() {
|
||||
arg.to_string()
|
||||
} else {
|
||||
server::socket_path().to_string_lossy().to_string()
|
||||
}
|
||||
});
|
||||
|
||||
let socket_pathbuf = std::path::PathBuf::from(&socket);
|
||||
|
||||
if crate::transport::is_socket_path(&socket_pathbuf)
|
||||
&& crate::transport::Stream::connect(&socket_pathbuf)
|
||||
.await
|
||||
.is_ok()
|
||||
{
|
||||
eprintln!("Server already running at {}", socket);
|
||||
eprintln!("Use 'jcode debug list' to see all servers.");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let debug_socket = {
|
||||
let filename = socket_pathbuf
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or("jcode.sock");
|
||||
let debug_filename = filename.replace(".sock", "-debug.sock");
|
||||
socket_pathbuf.with_file_name(debug_filename)
|
||||
};
|
||||
let _ = std::fs::remove_file(&debug_socket);
|
||||
|
||||
eprintln!("Starting jcode server...");
|
||||
|
||||
let exe = std::env::current_exe()?;
|
||||
let mut cmd = std::process::Command::new(&exe);
|
||||
cmd.arg("serve");
|
||||
|
||||
if socket != server::socket_path().to_string_lossy() {
|
||||
cmd.arg("--socket").arg(&socket);
|
||||
}
|
||||
|
||||
cmd.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.spawn()?;
|
||||
|
||||
let start = std::time::Instant::now();
|
||||
loop {
|
||||
if start.elapsed() > std::time::Duration::from_secs(10) {
|
||||
anyhow::bail!("Server failed to start within 10 seconds");
|
||||
}
|
||||
if crate::transport::is_socket_path(&socket_pathbuf)
|
||||
&& crate::transport::Stream::connect(&socket_pathbuf)
|
||||
.await
|
||||
.is_ok()
|
||||
{
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
||||
}
|
||||
|
||||
eprintln!("✓ Server started at {}", socket);
|
||||
|
||||
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
|
||||
if crate::transport::is_socket_path(&debug_socket) {
|
||||
eprintln!("✓ Debug socket at {}", debug_socket.display());
|
||||
} else {
|
||||
eprintln!("⚠ Debug socket not enabled. Add to ~/.jcode/config.toml:");
|
||||
eprintln!(" [display]");
|
||||
eprintln!(" debug_socket = true");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
+1212
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,274 @@
|
||||
use super::*;
|
||||
use crate::transport::Listener;
|
||||
|
||||
#[test]
|
||||
fn auth_doctor_provider_focus_uses_global_provider_when_positional_is_absent() {
|
||||
assert_eq!(
|
||||
auth_doctor_provider_arg(None, &ProviderChoice::Cerebras),
|
||||
Some("cerebras")
|
||||
);
|
||||
assert_eq!(
|
||||
auth_doctor_provider_arg(None, &ProviderChoice::Auto),
|
||||
None,
|
||||
"auto should keep the default doctor behavior of checking configured providers"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auth_doctor_positional_provider_wins_over_global_provider() {
|
||||
assert_eq!(
|
||||
auth_doctor_provider_arg(Some("openai"), &ProviderChoice::Cerebras),
|
||||
Some("openai"),
|
||||
"`jcode --provider cerebras auth doctor openai` should diagnose the explicit positional provider"
|
||||
);
|
||||
}
|
||||
|
||||
struct ReloadTestEnv {
|
||||
prev_socket: Option<std::ffi::OsString>,
|
||||
prev_runtime: Option<std::ffi::OsString>,
|
||||
socket_path: std::path::PathBuf,
|
||||
}
|
||||
|
||||
impl ReloadTestEnv {
|
||||
fn new() -> Self {
|
||||
let temp = tempfile::tempdir().expect("tempdir");
|
||||
let socket_path = temp.path().join("jcode.sock");
|
||||
let prev_socket = std::env::var_os("JCODE_SOCKET");
|
||||
let prev_runtime = std::env::var_os("JCODE_RUNTIME_DIR");
|
||||
crate::server::set_socket_path(socket_path.to_str().expect("utf8 socket path"));
|
||||
crate::env::set_var("JCODE_RUNTIME_DIR", temp.path());
|
||||
// Keep tempdir alive for the duration of the test helper.
|
||||
let _ = temp.keep();
|
||||
Self {
|
||||
prev_socket,
|
||||
prev_runtime,
|
||||
socket_path,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for ReloadTestEnv {
|
||||
fn drop(&mut self) {
|
||||
crate::server::clear_reload_marker();
|
||||
let _ = std::fs::remove_file(&self.socket_path);
|
||||
if let Some(prev_socket) = &self.prev_socket {
|
||||
crate::env::set_var("JCODE_SOCKET", prev_socket);
|
||||
} else {
|
||||
crate::env::remove_var("JCODE_SOCKET");
|
||||
}
|
||||
if let Some(prev_runtime) = &self.prev_runtime {
|
||||
crate::env::set_var("JCODE_RUNTIME_DIR", prev_runtime);
|
||||
} else {
|
||||
crate::env::remove_var("JCODE_RUNTIME_DIR");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn spawn_lock_serializes_shared_server_bootstrap() {
|
||||
let temp = tempfile::tempdir().expect("tempdir");
|
||||
let socket_path = temp.path().join("jcode.sock");
|
||||
let lock_path = spawn_lock_path(&socket_path);
|
||||
|
||||
let first = try_acquire_spawn_lock(&lock_path)
|
||||
.expect("acquire first lock")
|
||||
.expect("first lock should succeed");
|
||||
let second = try_acquire_spawn_lock(&lock_path).expect("acquire second lock");
|
||||
assert!(
|
||||
second.is_none(),
|
||||
"second lock should be held by first guard"
|
||||
);
|
||||
|
||||
drop(first);
|
||||
|
||||
let third = try_acquire_spawn_lock(&lock_path)
|
||||
.expect("acquire third lock")
|
||||
.expect("third lock should succeed after release");
|
||||
drop(third);
|
||||
|
||||
assert!(
|
||||
!lock_path.exists(),
|
||||
"lock file should be cleaned up when the guard drops"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_resume_id_imports_raw_codex_session_ids() {
|
||||
let _guard = crate::storage::lock_test_env();
|
||||
let temp = tempfile::tempdir().expect("tempdir");
|
||||
crate::env::set_var("JCODE_HOME", temp.path());
|
||||
|
||||
let codex_dir = temp.path().join("external/.codex/sessions/2026/04/16");
|
||||
std::fs::create_dir_all(&codex_dir).expect("create codex dir");
|
||||
std::fs::write(
|
||||
codex_dir.join("rollout.jsonl"),
|
||||
concat!(
|
||||
"{\"timestamp\":\"2026-04-16T10:00:00Z\",\"type\":\"session_meta\",\"payload\":{\"id\":\"codex-cli-resume-test\",\"timestamp\":\"2026-04-16T09:59:00Z\",\"cwd\":\"/tmp/codex-cli-resume\"}}\n",
|
||||
"{\"timestamp\":\"2026-04-16T10:00:01Z\",\"type\":\"response_item\",\"payload\":{\"type\":\"message\",\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Resume this Codex session\"}]}}\n",
|
||||
"{\"timestamp\":\"2026-04-16T10:00:02Z\",\"type\":\"response_item\",\"payload\":{\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"Imported\"}]}}\n"
|
||||
),
|
||||
)
|
||||
.expect("write codex transcript");
|
||||
|
||||
let resolved = resolve_resume_id("codex-cli-resume-test").expect("resolve codex id");
|
||||
let imported_id = crate::import::imported_codex_session_id("codex-cli-resume-test");
|
||||
assert_eq!(resolved, imported_id);
|
||||
|
||||
let session = crate::session::Session::load(&resolved).expect("load imported session");
|
||||
assert_eq!(session.messages.len(), 2);
|
||||
|
||||
crate::env::remove_var("JCODE_HOME");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resume_failure_defers_to_server_during_reload_handoff() {
|
||||
// Issue #328: when `--resume <id>` cannot be resolved locally but a reload/
|
||||
// update/restart handoff is in progress (JCODE_RESUMING set), we must defer
|
||||
// to the server instead of exiting with "No session found matching ...".
|
||||
let with_resuming = |key: &str| {
|
||||
if key == "JCODE_RESUMING" {
|
||||
Some(std::ffi::OsString::from("1"))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
};
|
||||
assert_eq!(
|
||||
resume_resolution_failure_action("ses_1780655839703_174710856", with_resuming),
|
||||
ResumeResolutionFailureAction::DeferToServer,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resume_failure_exits_when_no_handoff_in_progress() {
|
||||
// Without a reload handoff, an unresolved id is a genuine user error and
|
||||
// should still surface the actionable "use --resume to list" message + exit.
|
||||
let no_env = |_key: &str| Option::<std::ffi::OsString>::None;
|
||||
assert_eq!(
|
||||
resume_resolution_failure_action("totally-bogus-id", no_env),
|
||||
ResumeResolutionFailureAction::Exit,
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn wait_for_existing_reload_server_uses_reloading_server_instead_of_spawning() {
|
||||
let _guard = crate::storage::lock_test_env();
|
||||
let env = ReloadTestEnv::new();
|
||||
crate::server::write_reload_state(
|
||||
"reload-test",
|
||||
"hash",
|
||||
crate::server::ReloadPhase::Starting,
|
||||
None,
|
||||
);
|
||||
|
||||
let bind_path = env.socket_path.clone();
|
||||
let (release_tx, release_rx) = tokio::sync::oneshot::channel();
|
||||
let bind_task = tokio::spawn(async move {
|
||||
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
||||
let listener = Listener::bind(&bind_path).expect("bind replacement listener");
|
||||
crate::server::write_reload_state(
|
||||
"reload-test",
|
||||
"hash",
|
||||
crate::server::ReloadPhase::SocketReady,
|
||||
None,
|
||||
);
|
||||
let _listener = listener;
|
||||
let _ = release_rx.await;
|
||||
});
|
||||
|
||||
let result = tokio::time::timeout(
|
||||
std::time::Duration::from_secs(2),
|
||||
wait_for_existing_reload_server("test"),
|
||||
)
|
||||
.await
|
||||
.expect("reload wait should not hang");
|
||||
let _ = release_tx.send(());
|
||||
bind_task.await.expect("bind task");
|
||||
assert!(result);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn wait_for_existing_reload_server_returns_false_for_failed_reload() {
|
||||
let _guard = crate::storage::lock_test_env();
|
||||
let _env = ReloadTestEnv::new();
|
||||
crate::server::write_reload_state(
|
||||
"reload-test",
|
||||
"hash",
|
||||
crate::server::ReloadPhase::Failed,
|
||||
Some("boom".to_string()),
|
||||
);
|
||||
|
||||
assert!(!wait_for_existing_reload_server("test").await);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn wait_for_resuming_server_detects_delayed_listener_without_marker() {
|
||||
let _guard = crate::storage::lock_test_env();
|
||||
let env = ReloadTestEnv::new();
|
||||
|
||||
let bind_path = env.socket_path.clone();
|
||||
let (release_tx, release_rx) = tokio::sync::oneshot::channel();
|
||||
let bind_task = tokio::spawn(async move {
|
||||
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
||||
let listener = Listener::bind(&bind_path).expect("bind delayed listener");
|
||||
let _listener = listener;
|
||||
let _ = release_rx.await;
|
||||
});
|
||||
|
||||
let result = tokio::time::timeout(
|
||||
std::time::Duration::from_secs(2),
|
||||
wait_for_resuming_server("test", std::time::Duration::from_secs(1)),
|
||||
)
|
||||
.await
|
||||
.expect("resume wait should not hang");
|
||||
let _ = release_tx.send(());
|
||||
bind_task.await.expect("bind task");
|
||||
assert!(
|
||||
result,
|
||||
"resume wait should detect a delayed server without requiring a reload marker"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn wait_for_reloading_server_returns_false_when_idle() {
|
||||
let _guard = crate::storage::lock_test_env();
|
||||
let _env = ReloadTestEnv::new();
|
||||
|
||||
assert!(!wait_for_reloading_server().await);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn wait_for_reloading_server_returns_false_when_reload_failed() {
|
||||
let _guard = crate::storage::lock_test_env();
|
||||
let _env = ReloadTestEnv::new();
|
||||
crate::server::write_reload_state(
|
||||
"reload-test",
|
||||
"hash",
|
||||
crate::server::ReloadPhase::Failed,
|
||||
Some("boom".to_string()),
|
||||
);
|
||||
|
||||
assert!(!wait_for_reloading_server().await);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn wait_for_reloading_server_returns_true_for_live_listener() {
|
||||
let _guard = crate::storage::lock_test_env();
|
||||
let env = ReloadTestEnv::new();
|
||||
let _listener = Listener::bind(&env.socket_path).expect("bind listener");
|
||||
|
||||
assert!(wait_for_reloading_server().await);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn server_is_running_at_treats_live_listener_as_running_without_pong() {
|
||||
let temp = tempfile::tempdir().expect("tempdir");
|
||||
let socket_path = temp.path().join("jcode.sock");
|
||||
|
||||
let _listener = Listener::bind(&socket_path).expect("bind listener");
|
||||
|
||||
assert!(
|
||||
server_is_running_at(&socket_path).await,
|
||||
"a live listener should prevent duplicate server spawns even if ping is slow or absent"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,465 @@
|
||||
use anyhow::Result;
|
||||
use std::process::Command as ProcessCommand;
|
||||
|
||||
pub use crate::session_rebuild::{hot_rebuild, spawn_background_session_rebuild};
|
||||
|
||||
use crate::{build, tui::RunResult, update};
|
||||
|
||||
pub fn has_requested_action(run_result: &RunResult) -> bool {
|
||||
run_result.reload_session.is_some()
|
||||
|| run_result.rebuild_session.is_some()
|
||||
|| run_result.update_session.is_some()
|
||||
|| run_result.restart_session.is_some()
|
||||
}
|
||||
|
||||
pub fn execute_requested_action(run_result: &RunResult) -> Result<()> {
|
||||
if let Some(ref reload_session_id) = run_result.reload_session {
|
||||
hot_reload(reload_session_id)?;
|
||||
}
|
||||
|
||||
if let Some(ref rebuild_session_id) = run_result.rebuild_session {
|
||||
hot_rebuild(rebuild_session_id)?;
|
||||
}
|
||||
|
||||
if let Some(ref update_session_id) = run_result.update_session {
|
||||
hot_update(update_session_id)?;
|
||||
}
|
||||
|
||||
if let Some(ref restart_session_id) = run_result.restart_session {
|
||||
hot_restart(restart_session_id)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn hot_restart(session_id: &str) -> Result<()> {
|
||||
let cwd = std::env::current_dir()?;
|
||||
let exe = std::env::current_exe()?;
|
||||
let is_selfdev = crate::cli::selfdev::client_selfdev_requested();
|
||||
|
||||
crate::logging::info(&format!("Restarting with current binary: {:?}", exe));
|
||||
|
||||
crate::env::set_var("JCODE_RESUMING", "1");
|
||||
|
||||
let mut cmd = ProcessCommand::new(&exe);
|
||||
if is_selfdev {
|
||||
cmd.arg("self-dev");
|
||||
}
|
||||
cmd.arg("--resume").arg(session_id).current_dir(&cwd);
|
||||
let err = crate::platform::replace_process(&mut cmd);
|
||||
|
||||
Err(anyhow::anyhow!("Failed to exec {:?}: {}", exe, err))
|
||||
}
|
||||
|
||||
pub fn hot_reload(session_id: &str) -> Result<()> {
|
||||
let cwd = std::env::current_dir()?;
|
||||
|
||||
crate::env::set_var("JCODE_RESUMING", "1");
|
||||
|
||||
if let Ok(migrate_binary) = std::env::var("JCODE_MIGRATE_BINARY") {
|
||||
let binary_path = std::path::PathBuf::from(&migrate_binary);
|
||||
if binary_path.exists() {
|
||||
crate::logging::info("Migrating to stable binary...");
|
||||
let mut cmd = ProcessCommand::new(&binary_path);
|
||||
cmd.arg("--resume")
|
||||
.arg(session_id)
|
||||
.arg("--no-update")
|
||||
.env_remove("JCODE_MIGRATE_BINARY")
|
||||
.current_dir(cwd);
|
||||
let err = crate::platform::replace_process(&mut cmd);
|
||||
return Err(anyhow::anyhow!("Failed to exec {:?}: {}", binary_path, err));
|
||||
} else {
|
||||
crate::logging::warn(&format!(
|
||||
"Migration binary not found at {:?}, falling back to local binary",
|
||||
binary_path
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
let is_selfdev = crate::cli::selfdev::client_selfdev_requested();
|
||||
let (exe, _label) = build::preferred_reload_candidate(is_selfdev)
|
||||
.ok_or_else(|| anyhow::anyhow!("No reloadable binary found"))?;
|
||||
|
||||
if let Ok(metadata) = std::fs::metadata(&exe) {
|
||||
let age = metadata
|
||||
.modified()
|
||||
.ok()
|
||||
.and_then(|m| m.elapsed().ok())
|
||||
.map(|d| {
|
||||
let secs = d.as_secs();
|
||||
if secs < 60 {
|
||||
format!("{} seconds ago", secs)
|
||||
} else if secs < 3600 {
|
||||
format!("{} minutes ago", secs / 60)
|
||||
} else {
|
||||
format!("{} hours ago", secs / 3600)
|
||||
}
|
||||
})
|
||||
.unwrap_or_else(|| "unknown".to_string());
|
||||
crate::logging::info(&format!("Reloading with binary built {}...", age));
|
||||
}
|
||||
|
||||
for attempt in 0..3 {
|
||||
if attempt > 0 {
|
||||
std::thread::sleep(std::time::Duration::from_millis(200));
|
||||
if !exe.exists() {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
let mut cmd = ProcessCommand::new(&exe);
|
||||
if is_selfdev {
|
||||
cmd.arg("self-dev");
|
||||
}
|
||||
cmd.arg("--resume").arg(session_id).current_dir(&cwd);
|
||||
let err = crate::platform::replace_process(&mut cmd);
|
||||
|
||||
if err.kind() == std::io::ErrorKind::NotFound && attempt < 2 {
|
||||
crate::logging::warn(&format!(
|
||||
"exec attempt {} failed (ENOENT) for {:?}, retrying...",
|
||||
attempt + 1,
|
||||
exe
|
||||
));
|
||||
continue;
|
||||
}
|
||||
return Err(anyhow::anyhow!("Failed to exec {:?}: {}", exe, err));
|
||||
}
|
||||
Err(anyhow::anyhow!(
|
||||
"Failed to exec {:?}: binary not found after retries",
|
||||
exe
|
||||
))
|
||||
}
|
||||
|
||||
pub fn hot_update(session_id: &str) -> Result<()> {
|
||||
let cwd = std::env::current_dir()?;
|
||||
|
||||
update::print_centered("Checking for updates...");
|
||||
|
||||
match update::check_for_update_blocking() {
|
||||
Ok(Some(release)) => {
|
||||
let current = jcode_build_meta::VERSION;
|
||||
update::print_centered(&format!(
|
||||
"Update available: {} -> {}",
|
||||
current, release.tag_name
|
||||
));
|
||||
update::print_centered(&format!("Downloading {}...", release.tag_name));
|
||||
|
||||
match update::download_and_install_blocking_with_progress(&release, |progress| {
|
||||
update::print_centered(&format!(
|
||||
"{} {}",
|
||||
release.tag_name,
|
||||
update::format_download_progress_bar(progress)
|
||||
));
|
||||
}) {
|
||||
Ok(path) => {
|
||||
update::print_centered(&format!("✓ Installed {}", release.tag_name));
|
||||
reload_server_after_update("installed update");
|
||||
|
||||
let is_selfdev = crate::cli::selfdev::client_selfdev_requested();
|
||||
let exe = build::client_update_candidate(is_selfdev)
|
||||
.map(|(p, _)| p)
|
||||
.unwrap_or(path);
|
||||
|
||||
update::print_centered(&format!("Restarting with session {}...", session_id));
|
||||
|
||||
crate::env::set_var("JCODE_RESUMING", "1");
|
||||
|
||||
let mut cmd = ProcessCommand::new(&exe);
|
||||
if is_selfdev {
|
||||
cmd.arg("self-dev");
|
||||
}
|
||||
cmd.arg("--resume")
|
||||
.arg(session_id)
|
||||
.arg("--no-update")
|
||||
.current_dir(&cwd);
|
||||
let err = crate::platform::replace_process(&mut cmd);
|
||||
return Err(anyhow::anyhow!("Failed to exec {:?}: {}", exe, err));
|
||||
}
|
||||
Err(e) => {
|
||||
update::print_centered(&format!("✗ Download failed: {}", e));
|
||||
update::print_centered("Resuming session with current version...");
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(None) => {
|
||||
if repair_stale_shared_server_after_update_check() {
|
||||
reload_server_after_update("repaired stale server target");
|
||||
}
|
||||
update::print_centered(&format!(
|
||||
"Already up to date ({})",
|
||||
jcode_build_meta::VERSION
|
||||
));
|
||||
}
|
||||
Err(e) => {
|
||||
update::print_centered(&format!("✗ Update check failed: {}", e));
|
||||
update::print_centered("Resuming session with current version...");
|
||||
}
|
||||
}
|
||||
|
||||
crate::env::set_var("JCODE_RESUMING", "1");
|
||||
let exe = std::env::current_exe()?;
|
||||
let is_selfdev = crate::cli::selfdev::client_selfdev_requested();
|
||||
let mut cmd = ProcessCommand::new(&exe);
|
||||
if is_selfdev {
|
||||
cmd.arg("self-dev");
|
||||
}
|
||||
cmd.arg("--resume")
|
||||
.arg(session_id)
|
||||
.arg("--no-update")
|
||||
.current_dir(&cwd);
|
||||
let err = crate::platform::replace_process(&mut cmd);
|
||||
Err(anyhow::anyhow!("Failed to exec {:?}: {}", exe, err))
|
||||
}
|
||||
|
||||
pub fn get_repo_dir() -> Option<std::path::PathBuf> {
|
||||
build::get_repo_dir()
|
||||
}
|
||||
|
||||
/// Minimum interval between `git fetch` update probes across all jcode
|
||||
/// processes. Every source-build client spawn used to fetch unconditionally,
|
||||
/// so spawning N clients at once ran N concurrent `git fetch` + ssh sessions
|
||||
/// against the remote. One probe per interval per machine is plenty; a marker
|
||||
/// file's mtime coordinates it (same pattern as the session-backup pruner).
|
||||
const UPDATE_FETCH_INTERVAL_SECS: u64 = 15 * 60;
|
||||
|
||||
fn claim_update_fetch_slot() -> bool {
|
||||
let Ok(base) = crate::storage::jcode_dir() else {
|
||||
// Cannot coordinate without a home dir; fall back to probing.
|
||||
return true;
|
||||
};
|
||||
let marker = base.join("update-fetch.stamp");
|
||||
if let Ok(metadata) = std::fs::metadata(&marker)
|
||||
&& let Ok(modified) = metadata.modified()
|
||||
&& let Ok(age) = std::time::SystemTime::now().duration_since(modified)
|
||||
&& age.as_secs() < UPDATE_FETCH_INTERVAL_SECS
|
||||
{
|
||||
return false;
|
||||
}
|
||||
// Touch before fetching so a spawn burst collapses to ~one fetch.
|
||||
std::fs::write(&marker, b"").is_ok()
|
||||
}
|
||||
|
||||
pub fn check_for_updates() -> Option<bool> {
|
||||
let repo_dir = get_repo_dir()?;
|
||||
|
||||
if claim_update_fetch_slot() {
|
||||
let fetch = ProcessCommand::new("git")
|
||||
.args(["fetch", "-q"])
|
||||
.current_dir(&repo_dir)
|
||||
.output()
|
||||
.ok()?;
|
||||
|
||||
if !fetch.status.success() {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
// When the fetch slot was claimed by another recent process, still answer
|
||||
// from the (fresh enough) local refs instead of skipping the check.
|
||||
|
||||
let behind = ProcessCommand::new("git")
|
||||
.args(["rev-list", "--count", "HEAD..@{u}"])
|
||||
.current_dir(&repo_dir)
|
||||
.output()
|
||||
.ok()?;
|
||||
|
||||
if behind.status.success() {
|
||||
let count: u32 = String::from_utf8_lossy(&behind.stdout)
|
||||
.trim()
|
||||
.parse()
|
||||
.unwrap_or(0);
|
||||
Some(count > 0)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
pub fn run_auto_update() -> Result<()> {
|
||||
use crate::bus::{Bus, BusEvent, UpdateStatus};
|
||||
|
||||
let repo_dir =
|
||||
get_repo_dir().ok_or_else(|| anyhow::anyhow!("Could not find jcode repository"))?;
|
||||
|
||||
update::run_git_pull_ff_only(&repo_dir, true)?;
|
||||
|
||||
crate::logging::info("Building updated source version...");
|
||||
let build_output = ProcessCommand::new("cargo")
|
||||
.args(["build", "--release"])
|
||||
.current_dir(&repo_dir)
|
||||
.output()?;
|
||||
|
||||
if !build_output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&build_output.stderr);
|
||||
let stdout = String::from_utf8_lossy(&build_output.stdout);
|
||||
if !stderr.trim().is_empty() {
|
||||
crate::logging::error(&format!("auto-update cargo stderr:\n{}", stderr.trim()));
|
||||
}
|
||||
if !stdout.trim().is_empty() {
|
||||
crate::logging::info(&format!("auto-update cargo stdout:\n{}", stdout.trim()));
|
||||
}
|
||||
anyhow::bail!("cargo build failed");
|
||||
}
|
||||
|
||||
if let Err(e) = build::install_local_release(&repo_dir) {
|
||||
crate::logging::warn(&format!("auto-update install failed: {}", e));
|
||||
}
|
||||
|
||||
let hash = ProcessCommand::new("git")
|
||||
.args(["rev-parse", "--short", "HEAD"])
|
||||
.current_dir(&repo_dir)
|
||||
.output()?;
|
||||
let hash = String::from_utf8_lossy(&hash.stdout);
|
||||
let version = format!("main-{}", hash.trim());
|
||||
Bus::global().publish(BusEvent::UpdateStatus(UpdateStatus::Installed {
|
||||
version: version.clone(),
|
||||
}));
|
||||
crate::logging::info(&format!("Updated to {}. Restarting...", version));
|
||||
std::thread::sleep(std::time::Duration::from_millis(250));
|
||||
|
||||
let exe = build::client_update_candidate(false)
|
||||
.map(|(p, _)| p)
|
||||
.or_else(|| std::env::current_exe().ok())
|
||||
.ok_or_else(|| anyhow::anyhow!("No executable path found after update"))?;
|
||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||
|
||||
let err =
|
||||
crate::platform::replace_process(ProcessCommand::new(&exe).args(&args).arg("--no-update"));
|
||||
|
||||
Err(anyhow::anyhow!(
|
||||
"Failed to exec new binary {:?}: {}",
|
||||
exe,
|
||||
err
|
||||
))
|
||||
}
|
||||
|
||||
pub fn run_update() -> Result<()> {
|
||||
if update::is_release_build() {
|
||||
update::print_centered("Checking GitHub for latest release...");
|
||||
match update::check_for_update_blocking() {
|
||||
Ok(Some(release)) => {
|
||||
update::print_centered(&format!(
|
||||
"Downloading {} \u{2192} {}...",
|
||||
jcode_build_meta::VERSION,
|
||||
release.tag_name
|
||||
));
|
||||
let _path =
|
||||
update::download_and_install_blocking_with_progress(&release, |progress| {
|
||||
update::print_centered(&format!(
|
||||
"{} {}",
|
||||
release.tag_name,
|
||||
update::format_download_progress_bar(progress)
|
||||
));
|
||||
})?;
|
||||
update::print_centered(&format!("✅ Updated to {}", release.tag_name));
|
||||
reload_server_after_update("installed update");
|
||||
update::print_centered("Restart jcode to use the new version.");
|
||||
}
|
||||
Ok(None) => {
|
||||
if repair_stale_shared_server_after_update_check() {
|
||||
reload_server_after_update("repaired stale server target");
|
||||
}
|
||||
update::print_centered(&format!(
|
||||
"Already up to date ({})",
|
||||
jcode_build_meta::VERSION
|
||||
));
|
||||
}
|
||||
Err(e) => {
|
||||
anyhow::bail!("Update check failed: {}", e);
|
||||
}
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let repo_dir =
|
||||
get_repo_dir().ok_or_else(|| anyhow::anyhow!("Could not find jcode repository"))?;
|
||||
|
||||
update::print_centered(&format!("Updating jcode from {}...", repo_dir.display()));
|
||||
|
||||
update::print_centered("Pulling latest changes (fast-forward only)...");
|
||||
update::run_git_pull_ff_only(&repo_dir, true)?;
|
||||
|
||||
update::print_centered("Building...");
|
||||
let build_status = ProcessCommand::new("cargo")
|
||||
.args(["build", "--release"])
|
||||
.current_dir(&repo_dir)
|
||||
.status()?;
|
||||
|
||||
if !build_status.success() {
|
||||
anyhow::bail!("cargo build failed");
|
||||
}
|
||||
|
||||
if let Err(e) = build::install_local_release(&repo_dir) {
|
||||
update::print_centered(&format!("Warning: install failed: {}", e));
|
||||
}
|
||||
|
||||
let hash = ProcessCommand::new("git")
|
||||
.args(["rev-parse", "--short", "HEAD"])
|
||||
.current_dir(&repo_dir)
|
||||
.output()?;
|
||||
|
||||
let hash = String::from_utf8_lossy(&hash.stdout);
|
||||
update::print_centered(&format!("Successfully updated to {}", hash.trim()));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn repair_stale_shared_server_after_update_check() -> bool {
|
||||
match build::repair_stale_shared_server_channel() {
|
||||
Ok(build::SharedServerRepair::Repaired {
|
||||
previous,
|
||||
repaired_to,
|
||||
}) => {
|
||||
crate::logging::info(&format!(
|
||||
"update: repaired stale shared-server channel {:?} -> {}",
|
||||
previous, repaired_to
|
||||
));
|
||||
update::print_centered(&format!(
|
||||
"Repaired stale server reload target: {}",
|
||||
repaired_to
|
||||
));
|
||||
true
|
||||
}
|
||||
Ok(build::SharedServerRepair::AlreadyCurrent) => false,
|
||||
Err(error) => {
|
||||
crate::logging::warn(&format!(
|
||||
"update: failed to repair stale shared-server channel: {}",
|
||||
error
|
||||
));
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn reload_server_after_update(reason: &str) {
|
||||
let exe = build::client_update_candidate(false)
|
||||
.map(|(path, _)| path)
|
||||
.or_else(|| std::env::current_exe().ok());
|
||||
let Some(exe) = exe else {
|
||||
crate::logging::warn("update: could not find jcode binary to reload stale server");
|
||||
return;
|
||||
};
|
||||
|
||||
let output = ProcessCommand::new(&exe)
|
||||
.args(["--no-update", "server", "reload", "--force"])
|
||||
.output();
|
||||
match output {
|
||||
Ok(output) if output.status.success() => {
|
||||
crate::logging::info(&format!(
|
||||
"update: requested server reload after {} via {:?}",
|
||||
reason, exe
|
||||
));
|
||||
}
|
||||
Ok(output) => {
|
||||
crate::logging::warn(&format!(
|
||||
"update: server reload after {} failed with status {:?}: {}",
|
||||
reason,
|
||||
output.status.code(),
|
||||
String::from_utf8_lossy(&output.stderr).trim()
|
||||
));
|
||||
}
|
||||
Err(error) => {
|
||||
crate::logging::warn(&format!(
|
||||
"update: failed to request server reload after {} via {:?}: {}",
|
||||
reason, exe, error
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
+1451
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,379 @@
|
||||
//! Jcode subscription device-code (magic-link) login flow.
|
||||
//!
|
||||
//! Contract (the live backend lives in the private solosystems-backend repo):
|
||||
//! - `POST {auth_base}/v1/auth/device {"email": "..."}` ->
|
||||
//! `{device_code, verify_url, expires_in, interval}`
|
||||
//! - `POST {auth_base}/v1/auth/token {"device_code": "..."}` ->
|
||||
//! HTTP 202 (or `{"status":"pending"}`) until approved, then
|
||||
//! `{api_key, account_id, email, tier}`
|
||||
//!
|
||||
//! `auth_base` is derived by stripping a trailing `/v1` from the configured
|
||||
//! jcode API base (`JCODE_API_BASE` / `DEFAULT_JCODE_API_BASE`), since the
|
||||
//! auth endpoints live at the service root rather than under the model API
|
||||
//! `/v1` prefix.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use serde::Deserialize;
|
||||
use std::time::Duration;
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub(super) struct DeviceAuthResponse {
|
||||
pub device_code: String,
|
||||
pub verify_url: String,
|
||||
#[serde(default = "default_expires_in")]
|
||||
pub expires_in: u64,
|
||||
#[serde(default = "default_interval")]
|
||||
pub interval: u64,
|
||||
}
|
||||
|
||||
fn default_expires_in() -> u64 {
|
||||
900
|
||||
}
|
||||
|
||||
fn default_interval() -> u64 {
|
||||
5
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub(super) struct TokenApprovedResponse {
|
||||
pub api_key: String,
|
||||
#[serde(default)]
|
||||
pub account_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub email: Option<String>,
|
||||
#[serde(default)]
|
||||
pub tier: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Default)]
|
||||
struct TokenErrorResponse {
|
||||
#[serde(default)]
|
||||
status: Option<String>,
|
||||
#[serde(default)]
|
||||
error: Option<ErrorField>,
|
||||
#[serde(default)]
|
||||
error_description: Option<String>,
|
||||
#[serde(default)]
|
||||
message: Option<String>,
|
||||
}
|
||||
|
||||
/// The backend nests errors as `{"error":{"code","message"}}`; also accept a
|
||||
/// flat OAuth-style `{"error":"code","error_description":"..."}` shape.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
enum ErrorField {
|
||||
Code(String),
|
||||
Object {
|
||||
#[serde(default)]
|
||||
code: Option<String>,
|
||||
#[serde(default)]
|
||||
message: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
impl TokenErrorResponse {
|
||||
fn code(&self) -> Option<&str> {
|
||||
match &self.error {
|
||||
Some(ErrorField::Code(code)) => Some(code.as_str()),
|
||||
Some(ErrorField::Object { code, .. }) => code.as_deref(),
|
||||
None => self.status.as_deref(),
|
||||
}
|
||||
}
|
||||
|
||||
fn description(&self) -> Option<String> {
|
||||
if let Some(ErrorField::Object {
|
||||
message: Some(message),
|
||||
..
|
||||
}) = &self.error
|
||||
{
|
||||
return Some(message.clone());
|
||||
}
|
||||
self.error_description
|
||||
.clone()
|
||||
.or_else(|| self.message.clone())
|
||||
}
|
||||
}
|
||||
|
||||
/// Outcome of a single `/v1/auth/token` poll attempt.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(super) enum PollOutcome {
|
||||
Pending,
|
||||
SlowDown,
|
||||
Approved(TokenApprovedState),
|
||||
Expired,
|
||||
Denied(String),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(super) struct TokenApprovedState {
|
||||
pub api_key: String,
|
||||
pub account_id: Option<String>,
|
||||
pub email: Option<String>,
|
||||
pub tier: Option<String>,
|
||||
}
|
||||
|
||||
/// Derive the auth service base URL from the configured (or default) jcode
|
||||
/// model API base by stripping a trailing `/v1` segment.
|
||||
pub(super) fn auth_base_url() -> String {
|
||||
let base = crate::subscription_catalog::configured_api_base()
|
||||
.unwrap_or_else(|| crate::subscription_catalog::DEFAULT_JCODE_API_BASE.to_string());
|
||||
strip_v1_suffix(&base)
|
||||
}
|
||||
|
||||
pub(super) fn strip_v1_suffix(base: &str) -> String {
|
||||
let trimmed = base.trim().trim_end_matches('/');
|
||||
trimmed
|
||||
.strip_suffix("/v1")
|
||||
.unwrap_or(trimmed)
|
||||
.trim_end_matches('/')
|
||||
.to_string()
|
||||
}
|
||||
|
||||
pub(super) async fn request_device_code(
|
||||
client: &reqwest::Client,
|
||||
auth_base: &str,
|
||||
email: &str,
|
||||
) -> Result<DeviceAuthResponse> {
|
||||
let url = format!("{}/v1/auth/device", auth_base.trim_end_matches('/'));
|
||||
let resp = client
|
||||
.post(&url)
|
||||
.json(&serde_json::json!({ "email": email }))
|
||||
.send()
|
||||
.await
|
||||
.with_context(|| format!("Failed to reach jcode auth service at {}", url))?;
|
||||
|
||||
let status = resp.status();
|
||||
if !status.is_success() {
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
anyhow::bail!(
|
||||
"jcode auth service rejected the device authorization request (HTTP {}): {}",
|
||||
status.as_u16(),
|
||||
body.trim()
|
||||
);
|
||||
}
|
||||
|
||||
resp.json::<DeviceAuthResponse>()
|
||||
.await
|
||||
.context("Failed to parse device authorization response")
|
||||
}
|
||||
|
||||
/// Perform one poll of `/v1/auth/token` and classify the response.
|
||||
pub(super) async fn poll_token_once(
|
||||
client: &reqwest::Client,
|
||||
auth_base: &str,
|
||||
device_code: &str,
|
||||
) -> Result<PollOutcome> {
|
||||
let url = format!("{}/v1/auth/token", auth_base.trim_end_matches('/'));
|
||||
let resp = client
|
||||
.post(&url)
|
||||
.json(&serde_json::json!({ "device_code": device_code }))
|
||||
.send()
|
||||
.await
|
||||
.with_context(|| format!("Failed to reach jcode auth service at {}", url))?;
|
||||
|
||||
let status = resp.status();
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
|
||||
if status.as_u16() == 202 || status.as_u16() == 428 {
|
||||
return Ok(PollOutcome::Pending);
|
||||
}
|
||||
if status.as_u16() == 429 {
|
||||
return Ok(PollOutcome::SlowDown);
|
||||
}
|
||||
|
||||
if status.is_success() {
|
||||
// Some backends signal pending with 200 + {"status":"pending"}.
|
||||
if let Ok(err_body) = serde_json::from_str::<TokenErrorResponse>(&body)
|
||||
&& matches!(
|
||||
err_body.status.as_deref(),
|
||||
Some("pending") | Some("authorization_pending")
|
||||
)
|
||||
{
|
||||
return Ok(PollOutcome::Pending);
|
||||
}
|
||||
let approved: TokenApprovedResponse = serde_json::from_str(&body)
|
||||
.context("Failed to parse approved token response from jcode auth service")?;
|
||||
if approved.api_key.trim().is_empty() {
|
||||
anyhow::bail!("jcode auth service returned an empty API key");
|
||||
}
|
||||
return Ok(PollOutcome::Approved(TokenApprovedState {
|
||||
api_key: approved.api_key,
|
||||
account_id: approved.account_id,
|
||||
email: approved.email,
|
||||
tier: approved.tier,
|
||||
}));
|
||||
}
|
||||
|
||||
let parsed: TokenErrorResponse = serde_json::from_str(&body).unwrap_or_default();
|
||||
let code = parsed.code().unwrap_or("");
|
||||
match code {
|
||||
"authorization_pending" | "pending" => Ok(PollOutcome::Pending),
|
||||
"slow_down" => Ok(PollOutcome::SlowDown),
|
||||
"expired_token" | "expired" | "expired_device_code" => Ok(PollOutcome::Expired),
|
||||
"access_denied" | "denied" => {
|
||||
Ok(PollOutcome::Denied(parsed.description().unwrap_or_else(
|
||||
|| "Authorization was denied.".to_string(),
|
||||
)))
|
||||
}
|
||||
_ if status.as_u16() == 404 || status.as_u16() == 410 => Ok(PollOutcome::Expired),
|
||||
_ => anyhow::bail!(
|
||||
"jcode auth service returned an unexpected error (HTTP {}): {}",
|
||||
status.as_u16(),
|
||||
body.trim()
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// Poll `/v1/auth/token` until approval, denial, or expiry.
|
||||
///
|
||||
/// `interval` and `expires_in` come from the device authorization response.
|
||||
pub(super) async fn poll_for_api_key(
|
||||
client: &reqwest::Client,
|
||||
auth_base: &str,
|
||||
device_code: &str,
|
||||
interval: u64,
|
||||
expires_in: u64,
|
||||
) -> Result<TokenApprovedState> {
|
||||
let interval = interval.max(1);
|
||||
let deadline = std::time::Instant::now() + Duration::from_secs(expires_in.max(interval));
|
||||
let mut wait = Duration::from_secs(interval);
|
||||
|
||||
loop {
|
||||
tokio::time::sleep(wait).await;
|
||||
if std::time::Instant::now() >= deadline {
|
||||
anyhow::bail!(
|
||||
"The sign-in link expired before it was approved. Run `jcode login jcode` again."
|
||||
);
|
||||
}
|
||||
match poll_token_once(client, auth_base, device_code).await? {
|
||||
PollOutcome::Pending => {}
|
||||
PollOutcome::SlowDown => {
|
||||
wait += Duration::from_secs(5);
|
||||
}
|
||||
PollOutcome::Approved(state) => return Ok(state),
|
||||
PollOutcome::Expired => {
|
||||
anyhow::bail!(
|
||||
"The sign-in link expired before it was approved. Run `jcode login jcode` again."
|
||||
);
|
||||
}
|
||||
PollOutcome::Denied(reason) => {
|
||||
anyhow::bail!("jcode sign-in was denied: {}", reason);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Persist an approved subscription credential set to the jcode-subscription
|
||||
/// env file and process environment, preserving any configured API base.
|
||||
pub(super) fn persist_subscription_credentials(state: &TokenApprovedState) -> Result<()> {
|
||||
use crate::subscription_catalog as cat;
|
||||
|
||||
crate::provider_catalog::save_env_value_to_env_file(
|
||||
cat::JCODE_API_KEY_ENV,
|
||||
cat::JCODE_ENV_FILE,
|
||||
Some(state.api_key.trim()),
|
||||
)?;
|
||||
crate::provider_catalog::save_env_value_to_env_file(
|
||||
cat::JCODE_ACCOUNT_ID_ENV,
|
||||
cat::JCODE_ENV_FILE,
|
||||
state
|
||||
.account_id
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty()),
|
||||
)?;
|
||||
crate::provider_catalog::save_env_value_to_env_file(
|
||||
cat::JCODE_ACCOUNT_EMAIL_ENV,
|
||||
cat::JCODE_ENV_FILE,
|
||||
state
|
||||
.email
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty()),
|
||||
)?;
|
||||
crate::provider_catalog::save_env_value_to_env_file(
|
||||
cat::JCODE_TIER_ENV,
|
||||
cat::JCODE_ENV_FILE,
|
||||
state
|
||||
.tier
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty()),
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Full interactive device-code login flow for the jcode subscription.
|
||||
pub(super) async fn login_jcode_device_flow(email: &str, no_browser: bool) -> Result<()> {
|
||||
let client = crate::provider::shared_http_client();
|
||||
let auth_base = auth_base_url();
|
||||
|
||||
let device = request_device_code(&client, &auth_base, email).await?;
|
||||
|
||||
eprintln!();
|
||||
eprintln!(" Check your email ({}) for a sign-in link.", email);
|
||||
eprintln!(" If it does not arrive, open this URL to approve the sign-in:");
|
||||
eprintln!(" {}", device.verify_url);
|
||||
eprintln!();
|
||||
eprintln!(" Waiting for approval...");
|
||||
|
||||
super::maybe_open_browser(&device.verify_url, no_browser);
|
||||
|
||||
let approved = poll_for_api_key(
|
||||
&client,
|
||||
&auth_base,
|
||||
&device.device_code,
|
||||
device.interval,
|
||||
device.expires_in,
|
||||
)
|
||||
.await?;
|
||||
|
||||
persist_subscription_credentials(&approved)?;
|
||||
crate::auth::AuthStatus::invalidate_cache();
|
||||
|
||||
let config_dir = crate::storage::app_config_dir()?;
|
||||
eprintln!();
|
||||
eprintln!(
|
||||
" ✓ Signed in to the jcode subscription{}{}",
|
||||
approved
|
||||
.email
|
||||
.as_deref()
|
||||
.map(|value| format!(" as {}", value))
|
||||
.unwrap_or_default(),
|
||||
approved
|
||||
.tier
|
||||
.as_deref()
|
||||
.map(|value| format!(" ({} tier)", value))
|
||||
.unwrap_or_default(),
|
||||
);
|
||||
eprintln!(
|
||||
" Credentials stored at {}",
|
||||
config_dir
|
||||
.join(crate::subscription_catalog::JCODE_ENV_FILE)
|
||||
.display()
|
||||
);
|
||||
if approved
|
||||
.tier
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.is_none_or(|tier| tier.eq_ignore_ascii_case("none") || tier.is_empty())
|
||||
{
|
||||
eprintln!();
|
||||
eprintln!(
|
||||
" Choose a hosted-token plan at {}",
|
||||
crate::subscription_catalog::JCODE_PRICING_URL
|
||||
);
|
||||
eprintln!(" The sign-in tab is already connected to this account.");
|
||||
}
|
||||
|
||||
crate::telemetry::record_auth_success("jcode-subscription", "device_code_magic_link");
|
||||
// TODO(telemetry): emit a dedicated `account_linked` event carrying
|
||||
// `account_id` once jcode-telemetry-core grows a generic event with an
|
||||
// account field; the current AuthEvent schema has no account_id slot and
|
||||
// adding one is out of scope for this change.
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,279 @@
|
||||
use super::*;
|
||||
use std::io::{Read, Write};
|
||||
|
||||
/// Spawn a local HTTP server that answers successive requests with the given
|
||||
/// scripted `(status, body)` responses, then exits. Returns the base URL
|
||||
/// (with a `/v1` suffix, mirroring how the model API base is configured).
|
||||
fn spawn_scripted_http_server(responses: Vec<(u16, String)>) -> String {
|
||||
let listener = std::net::TcpListener::bind(("127.0.0.1", 0)).expect("bind test server");
|
||||
let addr = listener.local_addr().expect("local addr");
|
||||
std::thread::spawn(move || {
|
||||
for (status, body) in responses {
|
||||
let Ok((mut stream, _)) = listener.accept() else {
|
||||
return;
|
||||
};
|
||||
let mut buf = [0u8; 4096];
|
||||
let _ = stream.read(&mut buf);
|
||||
let status_text = match status {
|
||||
200 => "OK",
|
||||
202 => "Accepted",
|
||||
400 => "Bad Request",
|
||||
404 => "Not Found",
|
||||
410 => "Gone",
|
||||
429 => "Too Many Requests",
|
||||
_ => "OK",
|
||||
};
|
||||
let response = format!(
|
||||
"HTTP/1.1 {} {}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
|
||||
status,
|
||||
status_text,
|
||||
body.len(),
|
||||
body
|
||||
);
|
||||
let _ = stream.write_all(response.as_bytes());
|
||||
}
|
||||
});
|
||||
format!("http://127.0.0.1:{}/v1", addr.port())
|
||||
}
|
||||
|
||||
fn test_client() -> reqwest::Client {
|
||||
reqwest::Client::builder()
|
||||
.no_proxy()
|
||||
.build()
|
||||
.expect("build test client")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strip_v1_suffix_derives_auth_base() {
|
||||
assert_eq!(
|
||||
strip_v1_suffix("https://api.jcode.sh/v1"),
|
||||
"https://api.jcode.sh"
|
||||
);
|
||||
assert_eq!(
|
||||
strip_v1_suffix("https://api.jcode.sh/v1/"),
|
||||
"https://api.jcode.sh"
|
||||
);
|
||||
assert_eq!(
|
||||
strip_v1_suffix("https://api.jcode.sh"),
|
||||
"https://api.jcode.sh"
|
||||
);
|
||||
assert_eq!(
|
||||
strip_v1_suffix("https://example.com/router/v1"),
|
||||
"https://example.com/router"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn device_code_request_parses_response() {
|
||||
let base = spawn_scripted_http_server(vec![(
|
||||
200,
|
||||
r#"{"device_code":"dc-123","verify_url":"https://verify.example/dc-123","expires_in":600,"interval":2}"#
|
||||
.to_string(),
|
||||
)]);
|
||||
let auth_base = strip_v1_suffix(&base);
|
||||
|
||||
let device = request_device_code(&test_client(), &auth_base, "user@example.com")
|
||||
.await
|
||||
.expect("device code response");
|
||||
assert_eq!(device.device_code, "dc-123");
|
||||
assert_eq!(device.verify_url, "https://verify.example/dc-123");
|
||||
assert_eq!(device.expires_in, 600);
|
||||
assert_eq!(device.interval, 2);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
// The env lock is a std Mutex shared with sync tests; holding it across the
|
||||
// scripted-server awaits is intentional (same pattern as provider_init_tests).
|
||||
#[allow(clippy::await_holding_lock)]
|
||||
async fn poll_state_machine_pending_then_approved_persists_key() {
|
||||
let _guard = crate::storage::lock_test_env();
|
||||
let temp = tempfile::TempDir::new().expect("temp dir");
|
||||
let prev_home = std::env::var_os("JCODE_HOME");
|
||||
let prev_key = std::env::var_os(crate::subscription_catalog::JCODE_API_KEY_ENV);
|
||||
let prev_account = std::env::var_os(crate::subscription_catalog::JCODE_ACCOUNT_ID_ENV);
|
||||
let prev_email = std::env::var_os(crate::subscription_catalog::JCODE_ACCOUNT_EMAIL_ENV);
|
||||
crate::env::set_var("JCODE_HOME", temp.path());
|
||||
crate::env::remove_var(crate::subscription_catalog::JCODE_API_KEY_ENV);
|
||||
crate::env::remove_var(crate::subscription_catalog::JCODE_ACCOUNT_ID_ENV);
|
||||
crate::env::remove_var(crate::subscription_catalog::JCODE_ACCOUNT_EMAIL_ENV);
|
||||
|
||||
let base = spawn_scripted_http_server(vec![
|
||||
(202, String::new()),
|
||||
(200, r#"{"status":"pending"}"#.to_string()),
|
||||
(
|
||||
200,
|
||||
r#"{"api_key":"jk-live-abc","account_id":"acct_42","email":"user@example.com","tier":"plus"}"#
|
||||
.to_string(),
|
||||
),
|
||||
]);
|
||||
let auth_base = strip_v1_suffix(&base);
|
||||
let client = test_client();
|
||||
|
||||
// Walk the state machine explicitly: pending -> pending -> approved.
|
||||
assert_eq!(
|
||||
poll_token_once(&client, &auth_base, "dc-1")
|
||||
.await
|
||||
.expect("poll 1"),
|
||||
PollOutcome::Pending
|
||||
);
|
||||
assert_eq!(
|
||||
poll_token_once(&client, &auth_base, "dc-1")
|
||||
.await
|
||||
.expect("poll 2"),
|
||||
PollOutcome::Pending
|
||||
);
|
||||
let outcome = poll_token_once(&client, &auth_base, "dc-1")
|
||||
.await
|
||||
.expect("poll 3");
|
||||
let PollOutcome::Approved(state) = outcome else {
|
||||
panic!("expected approval, got {:?}", outcome);
|
||||
};
|
||||
assert_eq!(state.api_key, "jk-live-abc");
|
||||
assert_eq!(state.account_id.as_deref(), Some("acct_42"));
|
||||
assert_eq!(state.email.as_deref(), Some("user@example.com"));
|
||||
assert_eq!(state.tier.as_deref(), Some("plus"));
|
||||
|
||||
persist_subscription_credentials(&state).expect("persist credentials");
|
||||
|
||||
let env_path = crate::storage::app_config_dir()
|
||||
.expect("config dir")
|
||||
.join(crate::subscription_catalog::JCODE_ENV_FILE);
|
||||
let content = std::fs::read_to_string(&env_path).expect("env file written");
|
||||
assert!(content.contains("JCODE_API_KEY=jk-live-abc"), "{content}");
|
||||
assert!(content.contains("JCODE_ACCOUNT_ID=acct_42"), "{content}");
|
||||
assert!(
|
||||
content.contains("JCODE_ACCOUNT_EMAIL=user@example.com"),
|
||||
"{content}"
|
||||
);
|
||||
assert_eq!(
|
||||
crate::subscription_catalog::configured_api_key().as_deref(),
|
||||
Some("jk-live-abc")
|
||||
);
|
||||
|
||||
for (key, value) in [
|
||||
("JCODE_HOME", prev_home),
|
||||
(crate::subscription_catalog::JCODE_API_KEY_ENV, prev_key),
|
||||
(
|
||||
crate::subscription_catalog::JCODE_ACCOUNT_ID_ENV,
|
||||
prev_account,
|
||||
),
|
||||
(
|
||||
crate::subscription_catalog::JCODE_ACCOUNT_EMAIL_ENV,
|
||||
prev_email,
|
||||
),
|
||||
] {
|
||||
match value {
|
||||
Some(value) => crate::env::set_var(key, value),
|
||||
None => crate::env::remove_var(key),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn poll_for_api_key_resolves_after_pending() {
|
||||
let base = spawn_scripted_http_server(vec![
|
||||
(202, String::new()),
|
||||
(
|
||||
200,
|
||||
r#"{"api_key":"jk-live-xyz","account_id":"acct_7","email":"a@b.c","tier":"flagship"}"#
|
||||
.to_string(),
|
||||
),
|
||||
]);
|
||||
let auth_base = strip_v1_suffix(&base);
|
||||
|
||||
let state = poll_for_api_key(&test_client(), &auth_base, "dc-2", 1, 30)
|
||||
.await
|
||||
.expect("approved");
|
||||
assert_eq!(state.api_key, "jk-live-xyz");
|
||||
assert_eq!(state.account_id.as_deref(), Some("acct_7"));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn poll_state_machine_expired_token_yields_clear_error() {
|
||||
let base = spawn_scripted_http_server(vec![(
|
||||
400,
|
||||
r#"{"error":"expired_token","error_description":"device code expired"}"#.to_string(),
|
||||
)]);
|
||||
let auth_base = strip_v1_suffix(&base);
|
||||
|
||||
let outcome = poll_token_once(&test_client(), &auth_base, "dc-3")
|
||||
.await
|
||||
.expect("classified outcome");
|
||||
assert_eq!(outcome, PollOutcome::Expired);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn poll_for_api_key_expiry_produces_clear_error() {
|
||||
let base = spawn_scripted_http_server(vec![(400, r#"{"error":"expired_token"}"#.to_string())]);
|
||||
let auth_base = strip_v1_suffix(&base);
|
||||
|
||||
let err = poll_for_api_key(&test_client(), &auth_base, "dc-4", 1, 30)
|
||||
.await
|
||||
.expect_err("expected expiry error");
|
||||
assert!(
|
||||
err.to_string().contains("expired"),
|
||||
"unexpected error: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn poll_state_machine_denied_yields_denied_outcome() {
|
||||
let base = spawn_scripted_http_server(vec![(
|
||||
400,
|
||||
r#"{"error":"access_denied","error_description":"user rejected the sign-in"}"#.to_string(),
|
||||
)]);
|
||||
let auth_base = strip_v1_suffix(&base);
|
||||
|
||||
let outcome = poll_token_once(&test_client(), &auth_base, "dc-5")
|
||||
.await
|
||||
.expect("classified outcome");
|
||||
assert_eq!(
|
||||
outcome,
|
||||
PollOutcome::Denied("user rejected the sign-in".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn poll_state_machine_treats_gone_as_expired() {
|
||||
let base = spawn_scripted_http_server(vec![(410, String::new())]);
|
||||
let auth_base = strip_v1_suffix(&base);
|
||||
|
||||
let outcome = poll_token_once(&test_client(), &auth_base, "dc-6")
|
||||
.await
|
||||
.expect("classified outcome");
|
||||
assert_eq!(outcome, PollOutcome::Expired);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn poll_state_machine_handles_live_worker_shapes() {
|
||||
// The live backend (subscription worker in the private solosystems-backend
|
||||
// repo) replies 428 + nested error while pending,
|
||||
// and 400 + nested {"error":{"code":"expired_token",...}} on expiry.
|
||||
let base = spawn_scripted_http_server(vec![
|
||||
(
|
||||
428,
|
||||
r#"{"error":{"code":"authorization_pending","message":"user has not approved yet"}}"#
|
||||
.to_string(),
|
||||
),
|
||||
(
|
||||
400,
|
||||
r#"{"error":{"code":"expired_token","message":"device code is invalid or expired"}}"#
|
||||
.to_string(),
|
||||
),
|
||||
]);
|
||||
let auth_base = strip_v1_suffix(&base);
|
||||
let client = test_client();
|
||||
|
||||
assert_eq!(
|
||||
poll_token_once(&client, &auth_base, "dc-7")
|
||||
.await
|
||||
.expect("pending outcome"),
|
||||
PollOutcome::Pending
|
||||
);
|
||||
assert_eq!(
|
||||
poll_token_once(&client, &auth_base, "dc-7")
|
||||
.await
|
||||
.expect("expired outcome"),
|
||||
PollOutcome::Expired
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,751 @@
|
||||
use super::*;
|
||||
|
||||
pub(super) fn auto_scriptable_flow_reason(
|
||||
provider: LoginProviderDescriptor,
|
||||
options: &LoginOptions,
|
||||
stdin_is_terminal: bool,
|
||||
) -> Option<&'static str> {
|
||||
if options.print_auth_url || options.complete || options.has_provided_input() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let supports_scriptable = matches!(
|
||||
provider.target,
|
||||
LoginProviderTarget::Claude
|
||||
| LoginProviderTarget::OpenAi
|
||||
| LoginProviderTarget::Gemini
|
||||
| LoginProviderTarget::Antigravity
|
||||
| LoginProviderTarget::Google
|
||||
| LoginProviderTarget::Copilot
|
||||
);
|
||||
if !supports_scriptable {
|
||||
return None;
|
||||
}
|
||||
|
||||
if !stdin_is_terminal {
|
||||
Some("non_interactive_terminal")
|
||||
} else if auth::browser_suppressed(options.no_browser) {
|
||||
Some("no_browser_requested")
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn run_scriptable_login_provider(
|
||||
provider: LoginProviderDescriptor,
|
||||
account_label: Option<&str>,
|
||||
options: &LoginOptions,
|
||||
) -> Result<LoginFlowOutcome> {
|
||||
if options.print_auth_url {
|
||||
return start_scriptable_login(provider, account_label, options).await;
|
||||
}
|
||||
|
||||
let input = options.resolve_provided_input()?;
|
||||
if options.complete && input.is_some() {
|
||||
anyhow::bail!(
|
||||
"Use either --complete or an explicit --callback-url / --auth-code input, not both."
|
||||
);
|
||||
}
|
||||
complete_scriptable_login(provider, account_label, options, input).await
|
||||
}
|
||||
|
||||
pub(super) async fn start_scriptable_login(
|
||||
provider: LoginProviderDescriptor,
|
||||
account_label: Option<&str>,
|
||||
options: &LoginOptions,
|
||||
) -> Result<LoginFlowOutcome> {
|
||||
let (pending, auth_url, input_kind, user_code, expires_at_ms) = match provider.target {
|
||||
LoginProviderTarget::Claude => {
|
||||
let label = auth::claude::login_target_label(account_label)?;
|
||||
let (verifier, challenge) = auth::oauth::generate_pkce_public();
|
||||
let redirect_uri = auth::oauth::claude::REDIRECT_URI.to_string();
|
||||
let auth_url = auth::oauth::claude_auth_url(&redirect_uri, &challenge, &verifier);
|
||||
(
|
||||
PendingScriptableLogin::Claude {
|
||||
account_label: label,
|
||||
verifier,
|
||||
redirect_uri,
|
||||
},
|
||||
auth_url,
|
||||
"auth_code_or_callback_url",
|
||||
None,
|
||||
PendingScriptableLogin::Claude {
|
||||
account_label: String::new(),
|
||||
verifier: String::new(),
|
||||
redirect_uri: String::new(),
|
||||
}
|
||||
.default_expires_at_ms(),
|
||||
)
|
||||
}
|
||||
LoginProviderTarget::OpenAi => {
|
||||
let label = auth::codex::login_target_label(account_label)?;
|
||||
let (verifier, challenge) = auth::oauth::generate_pkce_public();
|
||||
let state = auth::oauth::generate_state_public();
|
||||
let redirect_uri = auth::oauth::openai::default_redirect_uri();
|
||||
let auth_url = auth::oauth::openai_auth_url_with_prompt(
|
||||
&redirect_uri,
|
||||
&challenge,
|
||||
&state,
|
||||
Some("login"),
|
||||
);
|
||||
(
|
||||
PendingScriptableLogin::Openai {
|
||||
account_label: label,
|
||||
verifier,
|
||||
state,
|
||||
redirect_uri,
|
||||
},
|
||||
auth_url,
|
||||
"callback_url",
|
||||
None,
|
||||
PendingScriptableLogin::Openai {
|
||||
account_label: String::new(),
|
||||
verifier: String::new(),
|
||||
state: String::new(),
|
||||
redirect_uri: String::new(),
|
||||
}
|
||||
.default_expires_at_ms(),
|
||||
)
|
||||
}
|
||||
LoginProviderTarget::Gemini => {
|
||||
let (verifier, challenge) = auth::oauth::generate_pkce_public();
|
||||
let state = auth::oauth::generate_state_public();
|
||||
let redirect_uri = auth::gemini::GEMINI_MANUAL_REDIRECT_URI.to_string();
|
||||
let auth_url = auth::gemini::build_manual_auth_url(&redirect_uri, &challenge, &state)?;
|
||||
(
|
||||
PendingScriptableLogin::Gemini {
|
||||
verifier,
|
||||
redirect_uri,
|
||||
},
|
||||
auth_url,
|
||||
"auth_code",
|
||||
None,
|
||||
PendingScriptableLogin::Gemini {
|
||||
verifier: String::new(),
|
||||
redirect_uri: String::new(),
|
||||
}
|
||||
.default_expires_at_ms(),
|
||||
)
|
||||
}
|
||||
LoginProviderTarget::Antigravity => {
|
||||
let (verifier, challenge) = auth::oauth::generate_pkce_public();
|
||||
let state = auth::oauth::generate_state_public();
|
||||
let redirect_uri = auth::antigravity::redirect_uri(auth::antigravity::DEFAULT_PORT);
|
||||
let auth_url = auth::antigravity::build_auth_url(&redirect_uri, &challenge, &state)?;
|
||||
(
|
||||
PendingScriptableLogin::Antigravity {
|
||||
verifier,
|
||||
state,
|
||||
redirect_uri,
|
||||
},
|
||||
auth_url,
|
||||
"callback_url",
|
||||
None,
|
||||
PendingScriptableLogin::Antigravity {
|
||||
verifier: String::new(),
|
||||
state: String::new(),
|
||||
redirect_uri: String::new(),
|
||||
}
|
||||
.default_expires_at_ms(),
|
||||
)
|
||||
}
|
||||
LoginProviderTarget::Google => {
|
||||
let creds = auth::google::load_credentials().context(
|
||||
"Google/Gmail scriptable auth requires saved OAuth credentials first. Run `jcode login --provider google` once or save google credentials manually.",
|
||||
)?;
|
||||
let tier = options
|
||||
.google_access_tier
|
||||
.unwrap_or(auth::google::GmailAccessTier::Full);
|
||||
let (verifier, challenge) = auth::oauth::generate_pkce_public();
|
||||
let state = auth::oauth::generate_state_public();
|
||||
let redirect_uri = format!("http://127.0.0.1:{}", auth::google::DEFAULT_PORT);
|
||||
let auth_url =
|
||||
auth::google::build_auth_url(&creds, tier, &redirect_uri, &challenge, &state);
|
||||
(
|
||||
PendingScriptableLogin::Google {
|
||||
verifier,
|
||||
state,
|
||||
redirect_uri,
|
||||
tier,
|
||||
},
|
||||
auth_url,
|
||||
"callback_url",
|
||||
None,
|
||||
PendingScriptableLogin::Google {
|
||||
verifier: String::new(),
|
||||
state: String::new(),
|
||||
redirect_uri: String::new(),
|
||||
tier,
|
||||
}
|
||||
.default_expires_at_ms(),
|
||||
)
|
||||
}
|
||||
LoginProviderTarget::Copilot => {
|
||||
let client = crate::provider::shared_http_client();
|
||||
let device_resp = auth::copilot::initiate_device_flow(&client).await?;
|
||||
(
|
||||
PendingScriptableLogin::Copilot {
|
||||
device_code: device_resp.device_code.clone(),
|
||||
user_code: device_resp.user_code.clone(),
|
||||
verification_uri: device_resp.verification_uri.clone(),
|
||||
interval: device_resp.interval,
|
||||
},
|
||||
device_resp.verification_uri,
|
||||
"complete",
|
||||
Some(device_resp.user_code),
|
||||
current_time_ms() + (device_resp.expires_in as i64 * 1000),
|
||||
)
|
||||
}
|
||||
_ => {
|
||||
anyhow::bail!(
|
||||
"`--print-auth-url` is currently supported for: claude, openai, gemini, antigravity, google, copilot."
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
let pending_path = pending.pending_path()?;
|
||||
cleanup_stale_pending_login_files()?;
|
||||
let record = PendingScriptableLoginRecord {
|
||||
expires_at_ms,
|
||||
login: pending,
|
||||
};
|
||||
crate::storage::write_json_secret(&pending_path, &record)?;
|
||||
emit_scriptable_auth_prompt(
|
||||
provider.id,
|
||||
&auth_url,
|
||||
input_kind,
|
||||
&pending_path,
|
||||
user_code.as_deref(),
|
||||
expires_at_ms,
|
||||
options.json,
|
||||
)?;
|
||||
Ok(LoginFlowOutcome::Deferred)
|
||||
}
|
||||
|
||||
pub(super) async fn complete_scriptable_login(
|
||||
provider: LoginProviderDescriptor,
|
||||
account_label: Option<&str>,
|
||||
options: &LoginOptions,
|
||||
input: Option<ProvidedAuthInput>,
|
||||
) -> Result<LoginFlowOutcome> {
|
||||
if account_label.is_some() {
|
||||
anyhow::bail!(
|
||||
"Do not pass --account when completing a scriptable login. The pending login already stores the target account."
|
||||
);
|
||||
}
|
||||
|
||||
match provider.target {
|
||||
LoginProviderTarget::Claude => {
|
||||
complete_scriptable_claude_login(provider.id, options, require_scriptable_input(input)?)
|
||||
.await
|
||||
}
|
||||
LoginProviderTarget::OpenAi => {
|
||||
complete_scriptable_openai_login(provider.id, options, require_scriptable_input(input)?)
|
||||
.await
|
||||
}
|
||||
LoginProviderTarget::Gemini => {
|
||||
complete_scriptable_gemini_login(provider.id, options, require_scriptable_input(input)?)
|
||||
.await
|
||||
}
|
||||
LoginProviderTarget::Antigravity => {
|
||||
complete_scriptable_antigravity_login(
|
||||
provider.id,
|
||||
options,
|
||||
require_scriptable_input(input)?,
|
||||
)
|
||||
.await
|
||||
}
|
||||
LoginProviderTarget::Google => {
|
||||
complete_scriptable_google_login(provider.id, options, require_scriptable_input(input)?)
|
||||
.await
|
||||
}
|
||||
LoginProviderTarget::Copilot => {
|
||||
if input.is_some() {
|
||||
anyhow::bail!(
|
||||
"Copilot completion uses `--complete` and does not accept --callback-url or --auth-code."
|
||||
)
|
||||
}
|
||||
if !options.complete {
|
||||
anyhow::bail!("Copilot completion requires `--complete`.")
|
||||
}
|
||||
complete_scriptable_copilot_login(provider.id, options).await
|
||||
}
|
||||
_ => anyhow::bail!(
|
||||
"Scriptable completion is currently supported for: claude, openai, gemini, antigravity, google, copilot."
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn complete_scriptable_claude_login(
|
||||
provider_id: &str,
|
||||
options: &LoginOptions,
|
||||
input: ProvidedAuthInput,
|
||||
) -> Result<LoginFlowOutcome> {
|
||||
let pending_path = pending_login_path("claude")?;
|
||||
let PendingScriptableLogin::Claude {
|
||||
account_label,
|
||||
verifier,
|
||||
redirect_uri,
|
||||
} = load_pending_login(&pending_path, "claude")?
|
||||
else {
|
||||
anyhow::bail!("Pending Claude login state is invalid.");
|
||||
};
|
||||
|
||||
let raw_input = match input {
|
||||
ProvidedAuthInput::CallbackUrl(value) | ProvidedAuthInput::AuthCode(value) => value,
|
||||
};
|
||||
let selected_redirect_uri =
|
||||
auth::oauth::claude_redirect_uri_for_input(&raw_input, &redirect_uri);
|
||||
let tokens =
|
||||
auth::oauth::exchange_claude_code(&verifier, &raw_input, &selected_redirect_uri).await?;
|
||||
auth::oauth::save_claude_tokens_for_account(&tokens, &account_label)?;
|
||||
let profile_email =
|
||||
auth::oauth::update_claude_account_profile(&account_label, &tokens.access_token)
|
||||
.await
|
||||
.unwrap_or(None);
|
||||
clear_pending_login(&pending_path);
|
||||
crate::telemetry::record_auth_success(provider_id, "oauth");
|
||||
emit_scriptable_auth_success(
|
||||
options.json,
|
||||
ScriptableAuthSuccess {
|
||||
status: "authenticated",
|
||||
provider: provider_id.to_string(),
|
||||
account_label: Some(account_label.clone()),
|
||||
credentials_path: Some(auth::claude::jcode_path()?.display().to_string()),
|
||||
email: profile_email.clone(),
|
||||
},
|
||||
)?;
|
||||
if !options.json {
|
||||
eprintln!("Successfully logged in to Claude!");
|
||||
eprintln!(
|
||||
"Account '{}' stored at {}",
|
||||
account_label,
|
||||
auth::claude::jcode_path()?.display()
|
||||
);
|
||||
if let Some(email) = profile_email {
|
||||
eprintln!("Profile email: {}", email);
|
||||
}
|
||||
}
|
||||
Ok(LoginFlowOutcome::Completed)
|
||||
}
|
||||
|
||||
pub(super) async fn complete_scriptable_openai_login(
|
||||
provider_id: &str,
|
||||
options: &LoginOptions,
|
||||
input: ProvidedAuthInput,
|
||||
) -> Result<LoginFlowOutcome> {
|
||||
let pending_path = pending_login_path("openai")?;
|
||||
let PendingScriptableLogin::Openai {
|
||||
account_label,
|
||||
verifier,
|
||||
state,
|
||||
redirect_uri,
|
||||
} = load_pending_login(&pending_path, "openai")?
|
||||
else {
|
||||
anyhow::bail!("Pending OpenAI login state is invalid.");
|
||||
};
|
||||
|
||||
let callback_input = match input {
|
||||
ProvidedAuthInput::CallbackUrl(value) => value,
|
||||
ProvidedAuthInput::AuthCode(_) => {
|
||||
anyhow::bail!(
|
||||
"OpenAI completion requires --callback-url because state validation is required."
|
||||
)
|
||||
}
|
||||
};
|
||||
let tokens = auth::oauth::exchange_openai_callback_input(
|
||||
&verifier,
|
||||
&callback_input,
|
||||
&state,
|
||||
&redirect_uri,
|
||||
)
|
||||
.await?;
|
||||
auth::oauth::save_openai_tokens_for_account(&tokens, &account_label)?;
|
||||
clear_pending_login(&pending_path);
|
||||
crate::telemetry::record_auth_success(provider_id, "oauth");
|
||||
let credentials_path = crate::storage::jcode_dir()?.join("openai-auth.json");
|
||||
emit_scriptable_auth_success(
|
||||
options.json,
|
||||
ScriptableAuthSuccess {
|
||||
status: "authenticated",
|
||||
provider: provider_id.to_string(),
|
||||
account_label: Some(account_label.clone()),
|
||||
credentials_path: Some(credentials_path.display().to_string()),
|
||||
email: None,
|
||||
},
|
||||
)?;
|
||||
if !options.json {
|
||||
eprintln!(
|
||||
"Successfully logged in to OpenAI! Account '{}' saved to {}",
|
||||
account_label,
|
||||
credentials_path.display()
|
||||
);
|
||||
}
|
||||
Ok(LoginFlowOutcome::Completed)
|
||||
}
|
||||
|
||||
pub(super) async fn complete_scriptable_gemini_login(
|
||||
provider_id: &str,
|
||||
options: &LoginOptions,
|
||||
input: ProvidedAuthInput,
|
||||
) -> Result<LoginFlowOutcome> {
|
||||
let pending_path = pending_login_path("gemini")?;
|
||||
let PendingScriptableLogin::Gemini {
|
||||
verifier,
|
||||
redirect_uri,
|
||||
} = load_pending_login(&pending_path, "gemini")?
|
||||
else {
|
||||
anyhow::bail!("Pending Gemini login state is invalid.");
|
||||
};
|
||||
|
||||
let auth_code = match input {
|
||||
ProvidedAuthInput::AuthCode(value) => value,
|
||||
ProvidedAuthInput::CallbackUrl(_) => {
|
||||
anyhow::bail!("Gemini completion requires --auth-code.")
|
||||
}
|
||||
};
|
||||
let tokens = auth::gemini::exchange_callback_code(&auth_code, &verifier, &redirect_uri).await?;
|
||||
clear_pending_login(&pending_path);
|
||||
crate::telemetry::record_auth_success(provider_id, "oauth");
|
||||
emit_scriptable_auth_success(
|
||||
options.json,
|
||||
ScriptableAuthSuccess {
|
||||
status: "authenticated",
|
||||
provider: provider_id.to_string(),
|
||||
account_label: None,
|
||||
credentials_path: Some(auth::gemini::tokens_path()?.display().to_string()),
|
||||
email: tokens.email.clone(),
|
||||
},
|
||||
)?;
|
||||
if !options.json {
|
||||
eprintln!("Successfully logged in to Gemini!");
|
||||
eprintln!("Tokens saved to {}", auth::gemini::tokens_path()?.display());
|
||||
if let Some(email) = tokens.email.as_deref() {
|
||||
eprintln!("Google account: {}", email);
|
||||
}
|
||||
}
|
||||
Ok(LoginFlowOutcome::Completed)
|
||||
}
|
||||
|
||||
pub(super) async fn complete_scriptable_antigravity_login(
|
||||
provider_id: &str,
|
||||
options: &LoginOptions,
|
||||
input: ProvidedAuthInput,
|
||||
) -> Result<LoginFlowOutcome> {
|
||||
let pending_path = pending_login_path("antigravity")?;
|
||||
let PendingScriptableLogin::Antigravity {
|
||||
verifier,
|
||||
state,
|
||||
redirect_uri,
|
||||
} = load_pending_login(&pending_path, "antigravity")?
|
||||
else {
|
||||
anyhow::bail!("Pending Antigravity login state is invalid.");
|
||||
};
|
||||
|
||||
let callback_input = match input {
|
||||
ProvidedAuthInput::CallbackUrl(value) => value,
|
||||
ProvidedAuthInput::AuthCode(_) => {
|
||||
anyhow::bail!("Antigravity completion requires --callback-url.")
|
||||
}
|
||||
};
|
||||
let tokens = auth::antigravity::exchange_callback_input(
|
||||
&verifier,
|
||||
&callback_input,
|
||||
Some(&state),
|
||||
&redirect_uri,
|
||||
)
|
||||
.await?;
|
||||
clear_pending_login(&pending_path);
|
||||
crate::telemetry::record_auth_success(provider_id, "oauth");
|
||||
emit_scriptable_auth_success(
|
||||
options.json,
|
||||
ScriptableAuthSuccess {
|
||||
status: "authenticated",
|
||||
provider: provider_id.to_string(),
|
||||
account_label: None,
|
||||
credentials_path: Some(auth::antigravity::tokens_path()?.display().to_string()),
|
||||
email: tokens.email.clone(),
|
||||
},
|
||||
)?;
|
||||
if !options.json {
|
||||
eprintln!("Successfully logged in to Antigravity!");
|
||||
eprintln!(
|
||||
"Tokens saved to {}",
|
||||
auth::antigravity::tokens_path()?.display()
|
||||
);
|
||||
if let Some(email) = tokens.email.as_deref() {
|
||||
eprintln!("Google account: {}", email);
|
||||
}
|
||||
if let Some(project_id) = tokens.project_id.as_deref() {
|
||||
eprintln!("Resolved Antigravity project: {}", project_id);
|
||||
}
|
||||
}
|
||||
Ok(LoginFlowOutcome::Completed)
|
||||
}
|
||||
|
||||
pub(super) async fn complete_scriptable_google_login(
|
||||
provider_id: &str,
|
||||
options: &LoginOptions,
|
||||
input: ProvidedAuthInput,
|
||||
) -> Result<LoginFlowOutcome> {
|
||||
let pending_path = pending_login_path("google")?;
|
||||
let PendingScriptableLogin::Google {
|
||||
verifier,
|
||||
state,
|
||||
redirect_uri,
|
||||
tier,
|
||||
} = load_pending_login(&pending_path, "google")?
|
||||
else {
|
||||
anyhow::bail!("Pending Google login state is invalid.");
|
||||
};
|
||||
|
||||
let callback_input = match input {
|
||||
ProvidedAuthInput::CallbackUrl(value) => value,
|
||||
ProvidedAuthInput::AuthCode(_) => {
|
||||
anyhow::bail!("Google completion requires --callback-url.")
|
||||
}
|
||||
};
|
||||
let creds = auth::google::load_credentials().context(
|
||||
"Google/Gmail completion requires saved OAuth credentials first. Run `jcode login --provider google` once or save google credentials manually.",
|
||||
)?;
|
||||
let tokens = auth::google::exchange_callback_input(
|
||||
&creds,
|
||||
&verifier,
|
||||
&callback_input,
|
||||
&state,
|
||||
&redirect_uri,
|
||||
tier,
|
||||
)
|
||||
.await?;
|
||||
clear_pending_login(&pending_path);
|
||||
crate::telemetry::record_auth_success(provider_id, "oauth");
|
||||
emit_scriptable_auth_success(
|
||||
options.json,
|
||||
ScriptableAuthSuccess {
|
||||
status: "authenticated",
|
||||
provider: provider_id.to_string(),
|
||||
account_label: None,
|
||||
credentials_path: Some(auth::google::tokens_path()?.display().to_string()),
|
||||
email: tokens.email.clone(),
|
||||
},
|
||||
)?;
|
||||
if !options.json {
|
||||
eprintln!("Successfully logged in to Google/Gmail!");
|
||||
if let Some(email) = tokens.email.as_deref() {
|
||||
eprintln!("Account: {}", email);
|
||||
}
|
||||
eprintln!("Access tier: {}", tokens.tier.label());
|
||||
eprintln!("Tokens saved to {}", auth::google::tokens_path()?.display());
|
||||
}
|
||||
Ok(LoginFlowOutcome::Completed)
|
||||
}
|
||||
|
||||
pub(super) async fn complete_scriptable_copilot_login(
|
||||
provider_id: &str,
|
||||
options: &LoginOptions,
|
||||
) -> Result<LoginFlowOutcome> {
|
||||
let pending_path = pending_login_path("copilot")?;
|
||||
let PendingScriptableLogin::Copilot {
|
||||
device_code,
|
||||
interval,
|
||||
..
|
||||
} = load_pending_login(&pending_path, "copilot")?
|
||||
else {
|
||||
anyhow::bail!("Pending Copilot login state is invalid.");
|
||||
};
|
||||
|
||||
let client = crate::provider::shared_http_client();
|
||||
let token = auth::copilot::poll_for_access_token(&client, &device_code, interval).await?;
|
||||
let username = auth::copilot::fetch_github_username(&client, &token)
|
||||
.await
|
||||
.unwrap_or_else(|_| "unknown".to_string());
|
||||
auth::copilot::save_github_token(&token, &username)?;
|
||||
clear_pending_login(&pending_path);
|
||||
crate::telemetry::record_auth_success(provider_id, "oauth_device_code");
|
||||
emit_scriptable_auth_success(
|
||||
options.json,
|
||||
ScriptableAuthSuccess {
|
||||
status: "authenticated",
|
||||
provider: provider_id.to_string(),
|
||||
account_label: Some(username.clone()),
|
||||
credentials_path: Some(auth::copilot::saved_hosts_path().display().to_string()),
|
||||
email: None,
|
||||
},
|
||||
)?;
|
||||
if !options.json {
|
||||
eprintln!("✓ Authenticated as {} via GitHub Copilot", username);
|
||||
eprintln!("Saved at {}", auth::copilot::saved_hosts_path().display());
|
||||
}
|
||||
Ok(LoginFlowOutcome::Completed)
|
||||
}
|
||||
|
||||
pub(super) fn pending_login_path(key: &str) -> Result<PathBuf> {
|
||||
Ok(crate::storage::jcode_dir()?
|
||||
.join("pending-login")
|
||||
.join(format!("{key}.json")))
|
||||
}
|
||||
|
||||
pub(super) fn pending_login_dir() -> Result<PathBuf> {
|
||||
Ok(crate::storage::jcode_dir()?.join("pending-login"))
|
||||
}
|
||||
|
||||
pub(super) fn require_scriptable_input(
|
||||
input: Option<ProvidedAuthInput>,
|
||||
) -> Result<ProvidedAuthInput> {
|
||||
input.ok_or_else(|| anyhow::anyhow!("No scriptable auth input was provided."))
|
||||
}
|
||||
|
||||
pub(super) fn load_pending_login(path: &PathBuf, provider: &str) -> Result<PendingScriptableLogin> {
|
||||
if !path.exists() {
|
||||
anyhow::bail!(
|
||||
"No pending {} login state found. Run `jcode login --provider {} --print-auth-url` first.",
|
||||
provider,
|
||||
provider
|
||||
);
|
||||
}
|
||||
crate::storage::harden_secret_file_permissions(path);
|
||||
let data = std::fs::read_to_string(path).with_context(|| {
|
||||
format!(
|
||||
"Failed to read pending {} login state from {}",
|
||||
provider,
|
||||
path.display()
|
||||
)
|
||||
})?;
|
||||
if let Ok(record) = serde_json::from_str::<PendingScriptableLoginRecord>(&data) {
|
||||
if record.expires_at_ms <= current_time_ms() {
|
||||
clear_pending_login(path);
|
||||
anyhow::bail!(
|
||||
"Pending {} login state expired. Run `jcode login --provider {} --print-auth-url` again.",
|
||||
provider,
|
||||
provider
|
||||
);
|
||||
}
|
||||
cleanup_stale_pending_login_files()?;
|
||||
return Ok(record.login);
|
||||
}
|
||||
let login = serde_json::from_str::<PendingScriptableLogin>(&data).with_context(|| {
|
||||
format!(
|
||||
"Failed to load pending {} login state from {}",
|
||||
provider,
|
||||
path.display()
|
||||
)
|
||||
})?;
|
||||
cleanup_stale_pending_login_files()?;
|
||||
Ok(login)
|
||||
}
|
||||
|
||||
pub(super) fn clear_pending_login(path: &PathBuf) {
|
||||
let _ = std::fs::remove_file(path);
|
||||
}
|
||||
|
||||
pub(super) fn cleanup_stale_pending_login_files() -> Result<()> {
|
||||
let dir = pending_login_dir()?;
|
||||
if !dir.exists() {
|
||||
return Ok(());
|
||||
}
|
||||
for entry in std::fs::read_dir(&dir)? {
|
||||
let entry = match entry {
|
||||
Ok(entry) => entry,
|
||||
Err(_) => continue,
|
||||
};
|
||||
let path = entry.path();
|
||||
if path.extension().and_then(|ext| ext.to_str()) != Some("json") {
|
||||
continue;
|
||||
}
|
||||
let Ok(data) = std::fs::read_to_string(&path) else {
|
||||
continue;
|
||||
};
|
||||
let Ok(record) = serde_json::from_str::<PendingScriptableLoginRecord>(&data) else {
|
||||
continue;
|
||||
};
|
||||
if record.expires_at_ms <= current_time_ms() {
|
||||
let _ = std::fs::remove_file(&path);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn current_time_ms() -> i64 {
|
||||
chrono::Utc::now().timestamp_millis()
|
||||
}
|
||||
|
||||
pub(super) fn resolve_auth_input(value: &str) -> Result<String> {
|
||||
if value != "-" {
|
||||
return Ok(value.to_string());
|
||||
}
|
||||
|
||||
let mut input = String::new();
|
||||
std::io::stdin()
|
||||
.read_line(&mut input)
|
||||
.context("Failed to read auth input from stdin")?;
|
||||
let trimmed = input.trim();
|
||||
if trimmed.is_empty() {
|
||||
anyhow::bail!("No auth input was provided on stdin.");
|
||||
}
|
||||
Ok(trimmed.to_string())
|
||||
}
|
||||
|
||||
pub(super) fn emit_scriptable_auth_prompt(
|
||||
provider: &str,
|
||||
auth_url: &str,
|
||||
input_kind: &str,
|
||||
pending_path: &Path,
|
||||
user_code: Option<&str>,
|
||||
expires_at_ms: i64,
|
||||
json: bool,
|
||||
) -> Result<()> {
|
||||
let resume_command = scriptable_resume_command(provider, input_kind);
|
||||
let prompt = ScriptableAuthPrompt {
|
||||
status: "pending",
|
||||
provider: provider.to_string(),
|
||||
auth_url: auth_url.to_string(),
|
||||
input_kind: input_kind.to_string(),
|
||||
pending_path: pending_path.display().to_string(),
|
||||
user_code: user_code.map(str::to_string),
|
||||
expires_at_ms,
|
||||
resume_command: resume_command.clone(),
|
||||
};
|
||||
if json {
|
||||
println!("{}", serde_json::to_string(&prompt)?);
|
||||
} else {
|
||||
println!("{}", auth_url);
|
||||
if let Some(user_code) = user_code {
|
||||
eprintln!("User code: {}", user_code);
|
||||
}
|
||||
eprintln!("Auth URL printed to stdout.");
|
||||
eprintln!("Complete this login later with `{}`.", resume_command);
|
||||
eprintln!(
|
||||
"This pending login expires at {} ms since epoch.",
|
||||
expires_at_ms
|
||||
);
|
||||
eprintln!("Pending login state saved at {}", pending_path.display());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn scriptable_resume_command(provider: &str, input_kind: &str) -> String {
|
||||
match input_kind {
|
||||
"callback_url" => {
|
||||
format!(
|
||||
"jcode login --provider {} --callback-url '<url-or-query>'",
|
||||
provider
|
||||
)
|
||||
}
|
||||
"auth_code" => format!("jcode login --provider {} --auth-code '<code>'", provider),
|
||||
"complete" => format!("jcode login --provider {} --complete", provider),
|
||||
_ => format!(
|
||||
"jcode login --provider {} --callback-url '<url>' # or --auth-code '<code>'",
|
||||
provider
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn emit_scriptable_auth_success(
|
||||
json: bool,
|
||||
success: ScriptableAuthSuccess,
|
||||
) -> Result<()> {
|
||||
if json {
|
||||
println!("{}", serde_json::to_string(&success)?);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
use super::*;
|
||||
|
||||
fn set_or_clear_env(key: &str, value: Option<std::ffi::OsString>) {
|
||||
if let Some(value) = value {
|
||||
crate::env::set_var(key, value);
|
||||
} else {
|
||||
crate::env::remove_var(key);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scriptable_resume_command_matches_input_kind() {
|
||||
assert_eq!(
|
||||
scriptable_resume_command("openai", "callback_url"),
|
||||
"jcode login --provider openai --callback-url '<url-or-query>'"
|
||||
);
|
||||
assert_eq!(
|
||||
scriptable_resume_command("gemini", "auth_code"),
|
||||
"jcode login --provider gemini --auth-code '<code>'"
|
||||
);
|
||||
assert_eq!(
|
||||
scriptable_resume_command("copilot", "complete"),
|
||||
"jcode login --provider copilot --complete"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_pending_login_removes_expired_record() {
|
||||
let _guard = crate::storage::lock_test_env();
|
||||
let temp = tempfile::TempDir::new().expect("temp dir");
|
||||
let prev_home = std::env::var_os("JCODE_HOME");
|
||||
crate::env::set_var("JCODE_HOME", temp.path());
|
||||
|
||||
let path = pending_login_path("openai").expect("pending path");
|
||||
let record = PendingScriptableLoginRecord {
|
||||
expires_at_ms: current_time_ms() - 1,
|
||||
login: PendingScriptableLogin::Openai {
|
||||
account_label: "default".to_string(),
|
||||
verifier: "verifier".to_string(),
|
||||
state: "state".to_string(),
|
||||
redirect_uri: "http://localhost:1455/auth/callback".to_string(),
|
||||
},
|
||||
};
|
||||
crate::storage::write_json_secret(&path, &record).expect("write pending login");
|
||||
|
||||
let err = load_pending_login(&path, "openai").expect_err("expected expired state");
|
||||
assert!(err.to_string().contains("expired"));
|
||||
assert!(!path.exists(), "expired pending login should be removed");
|
||||
|
||||
set_or_clear_env("JCODE_HOME", prev_home);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_pending_login_accepts_legacy_format() {
|
||||
let _guard = crate::storage::lock_test_env();
|
||||
let temp = tempfile::TempDir::new().expect("temp dir");
|
||||
let prev_home = std::env::var_os("JCODE_HOME");
|
||||
crate::env::set_var("JCODE_HOME", temp.path());
|
||||
|
||||
let path = pending_login_path("gemini").expect("pending path");
|
||||
let legacy = PendingScriptableLogin::Gemini {
|
||||
verifier: "verifier".to_string(),
|
||||
redirect_uri: auth::gemini::GEMINI_MANUAL_REDIRECT_URI.to_string(),
|
||||
};
|
||||
crate::storage::write_json_secret(&path, &legacy).expect("write legacy pending login");
|
||||
|
||||
let loaded = load_pending_login(&path, "gemini").expect("load legacy pending login");
|
||||
match loaded {
|
||||
PendingScriptableLogin::Gemini {
|
||||
verifier,
|
||||
redirect_uri,
|
||||
} => {
|
||||
assert_eq!(verifier, "verifier");
|
||||
assert_eq!(redirect_uri, auth::gemini::GEMINI_MANUAL_REDIRECT_URI);
|
||||
}
|
||||
other => panic!("unexpected login variant: {:?}", other),
|
||||
}
|
||||
|
||||
set_or_clear_env("JCODE_HOME", prev_home);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uses_scriptable_flow_detects_dash_input_without_consuming_stdin() {
|
||||
let options = LoginOptions {
|
||||
callback_url: Some("-".to_string()),
|
||||
..LoginOptions::default()
|
||||
};
|
||||
assert!(
|
||||
options
|
||||
.uses_scriptable_flow()
|
||||
.expect("uses scriptable flow")
|
||||
);
|
||||
assert!(options.has_provided_input());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_scriptable_flow_reason_prefers_non_interactive_for_oauth_provider() {
|
||||
let provider =
|
||||
crate::provider_catalog::resolve_login_provider("openai").expect("resolve openai provider");
|
||||
let reason = auto_scriptable_flow_reason(provider, &LoginOptions::default(), false);
|
||||
assert_eq!(reason, Some("non_interactive_terminal"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_scriptable_flow_reason_uses_no_browser_reason_when_requested() {
|
||||
let provider =
|
||||
crate::provider_catalog::resolve_login_provider("claude").expect("resolve claude provider");
|
||||
let reason = auto_scriptable_flow_reason(
|
||||
provider,
|
||||
&LoginOptions {
|
||||
no_browser: true,
|
||||
..LoginOptions::default()
|
||||
},
|
||||
true,
|
||||
);
|
||||
assert_eq!(reason, Some("no_browser_requested"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_scriptable_flow_reason_skips_api_key_only_provider() {
|
||||
let provider = crate::provider_catalog::resolve_login_provider("openrouter")
|
||||
.expect("resolve openrouter provider");
|
||||
let reason = auto_scriptable_flow_reason(provider, &LoginOptions::default(), false);
|
||||
assert_eq!(reason, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_scriptable_flow_reason_skips_when_scriptable_input_already_explicit() {
|
||||
let provider =
|
||||
crate::provider_catalog::resolve_login_provider("openai").expect("resolve openai provider");
|
||||
let reason = auto_scriptable_flow_reason(
|
||||
provider,
|
||||
&LoginOptions {
|
||||
print_auth_url: true,
|
||||
..LoginOptions::default()
|
||||
},
|
||||
false,
|
||||
);
|
||||
assert_eq!(reason, None);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
pub mod acp;
|
||||
pub mod args;
|
||||
pub mod auth_test;
|
||||
pub mod commands;
|
||||
pub mod debug;
|
||||
pub mod dispatch;
|
||||
pub mod hot_exec;
|
||||
pub mod login;
|
||||
pub mod output;
|
||||
pub mod proctitle;
|
||||
pub mod provider_doctor;
|
||||
pub mod provider_init;
|
||||
pub mod selfdev;
|
||||
pub mod startup;
|
||||
pub mod terminal;
|
||||
pub mod tui_launch;
|
||||
@@ -0,0 +1,27 @@
|
||||
pub const QUIET_ENV: &str = "JCODE_QUIET";
|
||||
|
||||
pub fn set_quiet_enabled(enabled: bool) {
|
||||
if enabled {
|
||||
crate::env::set_var(QUIET_ENV, "1");
|
||||
} else {
|
||||
crate::env::remove_var(QUIET_ENV);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn quiet_enabled() -> bool {
|
||||
std::env::var(QUIET_ENV)
|
||||
.map(|value| value == "1" || value.eq_ignore_ascii_case("true"))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
pub fn stderr_info(message: impl AsRef<str>) {
|
||||
if !quiet_enabled() {
|
||||
eprintln!("{}", message.as_ref());
|
||||
}
|
||||
}
|
||||
|
||||
pub fn stderr_blank_line() {
|
||||
if !quiet_enabled() {
|
||||
eprintln!();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
//! Mapping from parsed CLI arguments to an initial process title.
|
||||
//!
|
||||
//! This logic depends on the clap `Args`/`Command` types defined in `cli`, so
|
||||
//! it lives in the CLI layer. The low-level title-setting primitives it uses
|
||||
//! (`compact_process_title`, `session_name`, `set_title`) live in the
|
||||
//! `process_title` core module.
|
||||
|
||||
use crate::cli::args::{AmbientCommand, Args, Command};
|
||||
use crate::process_title::{compact_process_title, session_name, set_title};
|
||||
|
||||
pub(crate) fn initial_title(args: &Args) -> String {
|
||||
match &args.command {
|
||||
Some(Command::Serve { .. }) => "jcode:server".to_string(),
|
||||
Some(Command::Acp) => "jcode acp".to_string(),
|
||||
Some(Command::Server { .. }) => "jcode server".to_string(),
|
||||
Some(Command::Connect) => "jcode:client".to_string(),
|
||||
Some(Command::Run { .. }) => "jcode run".to_string(),
|
||||
Some(Command::Login { .. }) => "jcode login".to_string(),
|
||||
Some(Command::Repl) => "jcode repl".to_string(),
|
||||
Some(Command::Update) => "jcode update".to_string(),
|
||||
Some(Command::Version { .. }) => "jcode version".to_string(),
|
||||
Some(Command::Usage { .. }) => "jcode usage".to_string(),
|
||||
Some(Command::SelfDev { .. }) => "jcode:selfdev".to_string(),
|
||||
Some(Command::Debug { .. }) => "jcode debug".to_string(),
|
||||
Some(Command::Auth(_)) => "jcode auth".to_string(),
|
||||
Some(Command::Provider(_)) => "jcode provider".to_string(),
|
||||
Some(Command::Memory(_)) => "jcode memory".to_string(),
|
||||
Some(Command::Session(_)) => "jcode session".to_string(),
|
||||
Some(Command::Ambient(subcommand)) => match subcommand {
|
||||
AmbientCommand::RunVisible => "jcode ambient visible".to_string(),
|
||||
_ => "jcode ambient".to_string(),
|
||||
},
|
||||
Some(Command::Cloud(_)) => "jcode cloud".to_string(),
|
||||
Some(Command::Pair { .. }) => "jcode pair".to_string(),
|
||||
Some(Command::Permissions) => "jcode permissions".to_string(),
|
||||
Some(Command::Transcript { .. }) => "jcode transcript".to_string(),
|
||||
Some(Command::Dictate { .. }) => "jcode dictate".to_string(),
|
||||
Some(Command::SetupHotkey {
|
||||
listen_macos_hotkey,
|
||||
}) => {
|
||||
if *listen_macos_hotkey {
|
||||
"jcode hotkey listener".to_string()
|
||||
} else {
|
||||
"jcode hotkey setup".to_string()
|
||||
}
|
||||
}
|
||||
Some(Command::Browser { .. }) => "jcode browser".to_string(),
|
||||
Some(Command::Replay { .. }) => "jcode replay".to_string(),
|
||||
Some(Command::Model(_)) => "jcode model".to_string(),
|
||||
Some(Command::ProviderTestCoverage { .. }) => "jcode provider-test-coverage".to_string(),
|
||||
Some(Command::ProviderDoctor { .. }) => "jcode provider-doctor".to_string(),
|
||||
Some(Command::AuthTest { .. }) => "jcode auth-test".to_string(),
|
||||
Some(Command::Restart { .. }) => "jcode restart".to_string(),
|
||||
Some(Command::Menubar { .. }) => "jcode menubar".to_string(),
|
||||
Some(Command::SetupLauncher) => "jcode setup-launcher".to_string(),
|
||||
None => {
|
||||
if let Some(resume) = args.resume.as_deref().filter(|resume| !resume.is_empty()) {
|
||||
let prefix = if crate::cli::selfdev::client_selfdev_requested() {
|
||||
"jcode:d:"
|
||||
} else {
|
||||
"jcode:c:"
|
||||
};
|
||||
compact_process_title(prefix, Some(&session_name(resume)))
|
||||
} else if crate::cli::selfdev::client_selfdev_requested() {
|
||||
"jcode:selfdev".to_string()
|
||||
} else {
|
||||
"jcode:client".to_string()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn set_initial_title(args: &Args) {
|
||||
set_title(initial_title(args));
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::storage::lock_test_env;
|
||||
use clap::Parser;
|
||||
|
||||
const SELFDEV_ENV: &str = jcode_selfdev_types::CLIENT_SELFDEV_ENV;
|
||||
|
||||
fn with_selfdev_env_removed<T>(f: impl FnOnce() -> T) -> T {
|
||||
let _guard = lock_test_env();
|
||||
let previous = std::env::var_os(SELFDEV_ENV);
|
||||
crate::env::remove_var(SELFDEV_ENV);
|
||||
let result = f();
|
||||
if let Some(value) = previous {
|
||||
crate::env::set_var(SELFDEV_ENV, value);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn initial_title_labels_server() {
|
||||
with_selfdev_env_removed(|| {
|
||||
let args = Args::parse_from(["jcode", "serve"]);
|
||||
assert_eq!(initial_title(&args), "jcode:server");
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn initial_title_labels_resume_client_with_short_name() {
|
||||
with_selfdev_env_removed(|| {
|
||||
let args = Args::parse_from(["jcode", "--resume", "session_fox_123"]);
|
||||
assert_eq!(initial_title(&args), "jcode:c:fox");
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn initial_title_labels_selfdev_command() {
|
||||
with_selfdev_env_removed(|| {
|
||||
let args = Args::parse_from(["jcode", "self-dev"]);
|
||||
assert_eq!(initial_title(&args), "jcode:selfdev");
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
//! `jcode provider-doctor` command: a user-facing strict provider/model diagnostic.
|
||||
|
||||
use std::io::IsTerminal;
|
||||
|
||||
use anyhow::{Context, Result, anyhow};
|
||||
|
||||
use crate::live_tests::LiveVerificationStageStatus;
|
||||
use jcode_provider_doctor::{
|
||||
DoctorReport, DoctorTier, NativeProviderKind, native_doctor_supports_provider,
|
||||
run_antigravity_native_e2e, run_claude_native_e2e, run_generic_native_e2e, run_provider_e2e,
|
||||
};
|
||||
|
||||
pub async fn run_provider_doctor_command(
|
||||
provider: &str,
|
||||
model: Option<&str>,
|
||||
tier: &str,
|
||||
emit_json: bool,
|
||||
) -> Result<()> {
|
||||
let tier: DoctorTier = tier
|
||||
.parse()
|
||||
.map_err(|message: String| anyhow!("{message}"))?;
|
||||
|
||||
// Native-runtime providers cannot be driven by the OpenAI-compatible doctor;
|
||||
// route them to their native drivers, which exercise the production runtime.
|
||||
// Claude and Antigravity keep bespoke drivers (unusual credential/catalog
|
||||
// stories); everything else flows through the generic native driver.
|
||||
if native_doctor_supports_provider(provider) {
|
||||
let normalized = crate::auth::lifecycle::normalized_auth_provider_id(Some(provider));
|
||||
let report = match normalized {
|
||||
Some("claude") => run_claude_native_e2e(provider, model, tier).await?,
|
||||
Some("antigravity") => run_antigravity_native_e2e(provider, model, tier).await?,
|
||||
Some(other) => {
|
||||
let kind = NativeProviderKind::from_normalized(other)
|
||||
.ok_or_else(|| anyhow!("`{provider}` has no native provider-doctor driver"))?;
|
||||
run_generic_native_e2e(kind, model, tier).await?
|
||||
}
|
||||
None => anyhow::bail!("`{provider}` has no native provider-doctor driver"),
|
||||
};
|
||||
emit_report(&report, emit_json);
|
||||
return if report.tier_passed {
|
||||
Ok(())
|
||||
} else {
|
||||
anyhow::bail!("provider-doctor: one or more checks failed for {provider}")
|
||||
};
|
||||
}
|
||||
|
||||
let profile =
|
||||
crate::provider_catalog::openai_compatible_profile_by_id(provider).with_context(|| {
|
||||
format!(
|
||||
"`{provider}` is not a known OpenAI-compatible provider. \
|
||||
Run `jcode provider-test-coverage` to see provider ids, or check your spelling."
|
||||
)
|
||||
})?;
|
||||
let resolved = crate::provider_catalog::resolve_openai_compatible_profile(profile);
|
||||
|
||||
// Resolve the API key when the tier needs one.
|
||||
let api_key = if tier.requires_api_key() {
|
||||
let key = crate::provider_catalog::load_api_key_from_env_or_config(
|
||||
&resolved.api_key_env,
|
||||
&resolved.env_file,
|
||||
)
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"no API key found for `{provider}` (looked in env `{}` and `{}`). \
|
||||
Run `jcode login --provider {provider}`, or use `--tier offline` to check wiring only.",
|
||||
resolved.api_key_env, resolved.env_file
|
||||
)
|
||||
})?;
|
||||
Some(key)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let report = run_provider_e2e(profile, api_key.as_deref(), model, tier).await?;
|
||||
|
||||
emit_report(&report, emit_json);
|
||||
|
||||
// Non-zero exit when the chosen tier did not fully pass, so scripts/CI can gate on it.
|
||||
if report.tier_passed {
|
||||
Ok(())
|
||||
} else {
|
||||
anyhow::bail!("provider-doctor: one or more checks failed for {provider}")
|
||||
}
|
||||
}
|
||||
|
||||
fn emit_report(report: &DoctorReport, emit_json: bool) {
|
||||
if emit_json {
|
||||
println!("{}", report_to_json(report));
|
||||
} else {
|
||||
let colorize = std::io::stdout().is_terminal()
|
||||
&& std::env::var_os("NO_COLOR").is_none()
|
||||
&& std::env::var_os("JCODE_NO_COLOR").is_none();
|
||||
print!("{}", format_report(report, colorize));
|
||||
}
|
||||
}
|
||||
|
||||
fn status_symbol(status: LiveVerificationStageStatus) -> &'static str {
|
||||
match status {
|
||||
LiveVerificationStageStatus::Passed => "PASS",
|
||||
LiveVerificationStageStatus::Failed => "FAIL",
|
||||
LiveVerificationStageStatus::Blocked => "BLOCK",
|
||||
LiveVerificationStageStatus::Skipped => "skip",
|
||||
LiveVerificationStageStatus::NotRun => "----",
|
||||
}
|
||||
}
|
||||
|
||||
fn status_color(status: LiveVerificationStageStatus) -> &'static str {
|
||||
match status {
|
||||
LiveVerificationStageStatus::Passed => "32", // green
|
||||
LiveVerificationStageStatus::Failed | LiveVerificationStageStatus::Blocked => "31", // red
|
||||
LiveVerificationStageStatus::Skipped => "90", // dim
|
||||
LiveVerificationStageStatus::NotRun => "90",
|
||||
}
|
||||
}
|
||||
|
||||
fn format_report(report: &DoctorReport, colorize: bool) -> String {
|
||||
let mut out = String::new();
|
||||
out.push_str(&format!(
|
||||
"Provider doctor: {} / {}\n",
|
||||
report.provider_label,
|
||||
if report.model.is_empty() {
|
||||
"<no model>"
|
||||
} else {
|
||||
&report.model
|
||||
}
|
||||
));
|
||||
out.push_str(&format!("Tier: {} ", report.tier.as_str()));
|
||||
out.push_str(match report.tier {
|
||||
DoctorTier::Offline => "(no API key, no spend: validates jcode wiring only)\n",
|
||||
DoctorTier::Catalog => "(API key, ~no spend: adds live catalog fetch)\n",
|
||||
DoctorTier::Full => "(API key, spends balance: chat + streaming + tools)\n",
|
||||
});
|
||||
out.push_str(
|
||||
"Each line is one strict checkpoint. PASS/FAIL exercise it; skip means the\n\
|
||||
current tier does not run it (use --tier full for the API-dependent ones).\n\n",
|
||||
);
|
||||
|
||||
for check in &report.checks {
|
||||
let symbol = status_symbol(check.status);
|
||||
let line = format!(" [{symbol:>5}] {:<38} {}\n", check.label, check.detail);
|
||||
if colorize {
|
||||
let color = status_color(check.status);
|
||||
out.push_str(&format!("\x1b[{color}m{line}\x1b[0m"));
|
||||
} else {
|
||||
out.push_str(&line);
|
||||
}
|
||||
}
|
||||
|
||||
out.push('\n');
|
||||
if report.tier.spends_balance() {
|
||||
out.push_str(&format!(
|
||||
"Spend this run: {}\n",
|
||||
report.spend.human_summary()
|
||||
));
|
||||
}
|
||||
if report.strict_passed {
|
||||
out.push_str("Verdict: READY. Every strict checkpoint passed for this provider/model.\n");
|
||||
} else if report.tier_passed {
|
||||
out.push_str(&format!(
|
||||
"Verdict: tier `{}` passed. Run `--tier full` to confirm full readiness (spends balance).\n",
|
||||
report.tier.as_str()
|
||||
));
|
||||
} else if let Some(failure) = report.first_failure() {
|
||||
out.push_str(&format!(
|
||||
"Verdict: FAILED at `{}`.\n Reason: {}\n",
|
||||
failure.label, failure.detail
|
||||
));
|
||||
out.push_str(&next_step_hint(failure.checkpoint));
|
||||
} else {
|
||||
out.push_str("Verdict: FAILED.\n");
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn next_step_hint(checkpoint: &str) -> String {
|
||||
use crate::live_tests::checkpoints as cp;
|
||||
let hint = match checkpoint {
|
||||
cp::AUTH_CREDENTIAL_LOADED => {
|
||||
" Next: run `jcode login --provider <provider>` to store a working credential."
|
||||
}
|
||||
cp::MODEL_CATALOG_LIVE_ENDPOINT => {
|
||||
" Next: the live /models call failed. Check the key, network, and provider status."
|
||||
}
|
||||
cp::CATALOG_HOT_RELOAD_CURRENT_SESSION
|
||||
| cp::PICKER_LIVE_MODELS
|
||||
| cp::PICKER_FALLBACK_LABELING
|
||||
| cp::MODEL_SWITCH_ROUTE => {
|
||||
" Next: this is a jcode-side routing/picker bug for this provider. \
|
||||
Please file an issue with this output."
|
||||
}
|
||||
cp::NON_STREAMING_CHAT_COMPLETION | cp::STREAMING_CHAT_COMPLETION => {
|
||||
" Next: the model did not return a usable completion. Try another model from the catalog."
|
||||
}
|
||||
cp::TOOL_CALL_PARSE
|
||||
| cp::TOOL_EXECUTION_LOOP
|
||||
| cp::TOOL_RESULT_FOLLOWUP
|
||||
| cp::REAL_JCODE_TOOL_SMOKE => {
|
||||
" Next: this model did not produce a valid tool call. It may not support tools well."
|
||||
}
|
||||
_ => "",
|
||||
};
|
||||
if hint.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!("{hint}\n")
|
||||
}
|
||||
}
|
||||
|
||||
fn report_to_json(report: &DoctorReport) -> String {
|
||||
let checks: Vec<serde_json::Value> = report
|
||||
.checks
|
||||
.iter()
|
||||
.map(|check| {
|
||||
serde_json::json!({
|
||||
"checkpoint": check.checkpoint,
|
||||
"label": check.label,
|
||||
"status": status_symbol(check.status).to_ascii_lowercase(),
|
||||
"detail": check.detail,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
serde_json::to_string_pretty(&serde_json::json!({
|
||||
"provider_id": report.provider_id,
|
||||
"provider_label": report.provider_label,
|
||||
"model": report.model,
|
||||
"tier": report.tier.as_str(),
|
||||
"tier_passed": report.tier_passed,
|
||||
"strict_passed": report.strict_passed,
|
||||
"spend": report.spend.to_json(),
|
||||
"checks": checks,
|
||||
}))
|
||||
.unwrap_or_else(|_| "{}".to_string())
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,952 @@
|
||||
use super::*;
|
||||
// These moved from cli::provider_init to crate::external_auth in the
|
||||
// tui->cli layering refactor (a9a82827); provider_init.rs only re-imports the
|
||||
// subset it uses, so `super::*` no longer re-exports them to this test module.
|
||||
use crate::external_auth::{
|
||||
parse_external_auth_review_selection, pending_external_auth_review_candidates,
|
||||
};
|
||||
use crate::provider_catalog::{self, resolve_login_selection, resolve_openai_compatible_profile};
|
||||
use std::collections::HashSet;
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
use tempfile::TempDir;
|
||||
|
||||
static ENV_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
|
||||
|
||||
fn lock_env() -> std::sync::MutexGuard<'static, ()> {
|
||||
let mutex = ENV_LOCK.get_or_init(|| Mutex::new(()));
|
||||
match mutex.lock() {
|
||||
Ok(guard) => guard,
|
||||
Err(poisoned) => poisoned.into_inner(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[allow(deprecated)]
|
||||
fn test_provider_choice_arg_values() {
|
||||
assert_eq!(ProviderChoice::Jcode.as_arg_value(), "jcode");
|
||||
assert_eq!(ProviderChoice::Claude.as_arg_value(), "claude");
|
||||
assert_eq!(ProviderChoice::AnthropicApi.as_arg_value(), "anthropic-api");
|
||||
assert_eq!(
|
||||
ProviderChoice::ClaudeSubprocess.as_arg_value(),
|
||||
"claude-subprocess"
|
||||
);
|
||||
assert_eq!(ProviderChoice::Openai.as_arg_value(), "openai");
|
||||
assert_eq!(ProviderChoice::OpenaiApi.as_arg_value(), "openai-api");
|
||||
assert_eq!(ProviderChoice::Openrouter.as_arg_value(), "openrouter");
|
||||
assert_eq!(ProviderChoice::Bedrock.as_arg_value(), "bedrock");
|
||||
assert_eq!(ProviderChoice::Azure.as_arg_value(), "azure");
|
||||
assert_eq!(ProviderChoice::Opencode.as_arg_value(), "opencode");
|
||||
assert_eq!(ProviderChoice::OpencodeGo.as_arg_value(), "opencode-go");
|
||||
assert_eq!(ProviderChoice::Zai.as_arg_value(), "zai");
|
||||
assert_eq!(ProviderChoice::Groq.as_arg_value(), "groq");
|
||||
assert_eq!(ProviderChoice::Mistral.as_arg_value(), "mistral");
|
||||
assert_eq!(ProviderChoice::Perplexity.as_arg_value(), "perplexity");
|
||||
assert_eq!(ProviderChoice::TogetherAi.as_arg_value(), "togetherai");
|
||||
assert_eq!(ProviderChoice::Deepinfra.as_arg_value(), "deepinfra");
|
||||
assert_eq!(ProviderChoice::Fireworks.as_arg_value(), "fireworks");
|
||||
assert_eq!(ProviderChoice::Minimax.as_arg_value(), "minimax");
|
||||
assert_eq!(ProviderChoice::Xai.as_arg_value(), "xai");
|
||||
assert_eq!(ProviderChoice::XiaomiMimo.as_arg_value(), "xiaomi-mimo");
|
||||
assert_eq!(ProviderChoice::Lmstudio.as_arg_value(), "lmstudio");
|
||||
assert_eq!(ProviderChoice::Ollama.as_arg_value(), "ollama");
|
||||
assert_eq!(ProviderChoice::Chutes.as_arg_value(), "chutes");
|
||||
assert_eq!(ProviderChoice::Cerebras.as_arg_value(), "cerebras");
|
||||
assert_eq!(
|
||||
ProviderChoice::AlibabaCodingPlan.as_arg_value(),
|
||||
"alibaba-coding-plan"
|
||||
);
|
||||
assert_eq!(
|
||||
ProviderChoice::OpenaiCompatible.as_arg_value(),
|
||||
"openai-compatible"
|
||||
);
|
||||
assert_eq!(ProviderChoice::Cursor.as_arg_value(), "cursor");
|
||||
assert_eq!(ProviderChoice::Copilot.as_arg_value(), "copilot");
|
||||
assert_eq!(ProviderChoice::Gemini.as_arg_value(), "gemini");
|
||||
assert_eq!(ProviderChoice::Antigravity.as_arg_value(), "antigravity");
|
||||
assert_eq!(ProviderChoice::Google.as_arg_value(), "google");
|
||||
assert_eq!(ProviderChoice::Auto.as_arg_value(), "auto");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
#[expect(
|
||||
clippy::await_holding_lock,
|
||||
reason = "test env locks intentionally stay held across provider init to isolate process-global auth env"
|
||||
)]
|
||||
async fn explicit_anthropic_api_choice_pins_api_key_over_available_oauth() {
|
||||
let _guard = lock_env();
|
||||
let _env_guard = crate::storage::lock_test_env();
|
||||
let dir = TempDir::new().expect("temp dir");
|
||||
let keys = [
|
||||
"JCODE_HOME",
|
||||
"ANTHROPIC_API_KEY",
|
||||
"JCODE_RUNTIME_PROVIDER",
|
||||
"JCODE_ACTIVE_PROVIDER",
|
||||
"JCODE_FORCE_PROVIDER",
|
||||
];
|
||||
let saved: Vec<(&str, Option<String>)> = keys
|
||||
.iter()
|
||||
.map(|key| (*key, std::env::var(key).ok()))
|
||||
.collect();
|
||||
|
||||
crate::env::set_var("JCODE_HOME", dir.path());
|
||||
crate::env::set_var("ANTHROPIC_API_KEY", "sk-ant-api-test");
|
||||
for key in [
|
||||
"JCODE_RUNTIME_PROVIDER",
|
||||
"JCODE_ACTIVE_PROVIDER",
|
||||
"JCODE_FORCE_PROVIDER",
|
||||
] {
|
||||
crate::env::remove_var(key);
|
||||
}
|
||||
std::fs::write(
|
||||
dir.path().join("auth.json"),
|
||||
serde_json::json!({
|
||||
"anthropic_accounts": [{
|
||||
"label": "primary",
|
||||
"access": "oauth-access-token",
|
||||
"refresh": "oauth-refresh-token",
|
||||
"expires": chrono::Utc::now().timestamp_millis() + 3_600_000,
|
||||
"scopes": ["user:inference"]
|
||||
}],
|
||||
"active_anthropic_account": "primary"
|
||||
})
|
||||
.to_string(),
|
||||
)
|
||||
.expect("write competing OAuth credentials");
|
||||
crate::config::invalidate_config_cache();
|
||||
crate::auth::AuthStatus::invalidate_cache();
|
||||
|
||||
let provider =
|
||||
init_provider_for_validation(&ProviderChoice::AnthropicApi, Some("claude-haiku-4-5"))
|
||||
.await
|
||||
.expect("explicit Anthropic API provider should initialize");
|
||||
|
||||
assert_eq!(provider.active_auth_method_label(), Some("API key"));
|
||||
assert_eq!(provider.model(), "claude-haiku-4-5");
|
||||
assert_eq!(
|
||||
std::env::var("JCODE_RUNTIME_PROVIDER").ok().as_deref(),
|
||||
Some("claude-api")
|
||||
);
|
||||
|
||||
for (key, value) in saved {
|
||||
if let Some(value) = value {
|
||||
crate::env::set_var(key, value);
|
||||
} else {
|
||||
crate::env::remove_var(key);
|
||||
}
|
||||
}
|
||||
crate::config::invalidate_config_cache();
|
||||
crate::auth::AuthStatus::invalidate_cache();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_server_bootstrap_login_selection_preserves_order() {
|
||||
let providers = provider_catalog::server_bootstrap_login_providers();
|
||||
assert_eq!(
|
||||
resolve_login_selection("1", &providers).map(|provider| provider.id),
|
||||
Some("claude")
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_login_selection("2", &providers).map(|provider| provider.id),
|
||||
Some("anthropic-api")
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_login_selection("4", &providers).map(|provider| provider.id),
|
||||
Some("jcode")
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_login_selection("5", &providers).map(|provider| provider.id),
|
||||
Some("copilot")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_auto_init_login_selection_preserves_order() {
|
||||
let providers = provider_catalog::auto_init_login_providers();
|
||||
assert_eq!(
|
||||
resolve_login_selection("1", &providers).map(|provider| provider.id),
|
||||
Some("claude")
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_login_selection("2", &providers).map(|provider| provider.id),
|
||||
Some("anthropic-api")
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_login_selection("11", &providers).map(|provider| provider.id),
|
||||
Some("alibaba-coding-plan")
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_login_selection("12", &providers).map(|provider| provider.id),
|
||||
Some("cursor")
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_login_selection("13", &providers).map(|provider| provider.id),
|
||||
Some("copilot")
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_login_selection("14", &providers).map(|provider| provider.id),
|
||||
Some("gemini")
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_login_selection("15", &providers).map(|provider| provider.id),
|
||||
Some("antigravity")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_init_provider_jcode_delegates_runtime_profile_to_wrapper() {
|
||||
let _guard = lock_env();
|
||||
let _env_guard = crate::storage::lock_test_env();
|
||||
// Sandbox JCODE_HOME: with the real home, persisted auth/credential state
|
||||
// (e.g. a pinned anthropic api-key route) re-pins JCODE_RUNTIME_PROVIDER
|
||||
// during MultiProvider construction and breaks the assertions below.
|
||||
let dir = TempDir::new().expect("temp dir");
|
||||
let saved_home = std::env::var("JCODE_HOME").ok();
|
||||
crate::env::set_var("JCODE_HOME", dir.path());
|
||||
crate::subscription_catalog::clear_runtime_env();
|
||||
crate::env::remove_var("JCODE_OPENROUTER_MODEL");
|
||||
crate::env::remove_var("JCODE_RUNTIME_PROVIDER");
|
||||
crate::env::remove_var("JCODE_ACTIVE_PROVIDER");
|
||||
crate::env::remove_var("JCODE_FORCE_PROVIDER");
|
||||
|
||||
let runtime = tokio::runtime::Runtime::new().expect("tokio runtime");
|
||||
let provider = runtime
|
||||
.block_on(init_provider(&ProviderChoice::Jcode, None))
|
||||
.expect("init jcode provider");
|
||||
|
||||
assert_eq!(provider.name(), "Jcode Subscription");
|
||||
assert!(crate::subscription_catalog::is_runtime_mode_enabled());
|
||||
assert_eq!(
|
||||
std::env::var("JCODE_OPENROUTER_MODEL").ok().as_deref(),
|
||||
Some(crate::subscription_catalog::default_model().id)
|
||||
);
|
||||
assert_eq!(
|
||||
std::env::var("JCODE_ACTIVE_PROVIDER").ok().as_deref(),
|
||||
Some("openrouter")
|
||||
);
|
||||
assert_eq!(
|
||||
std::env::var("JCODE_RUNTIME_PROVIDER").ok().as_deref(),
|
||||
Some("jcode")
|
||||
);
|
||||
assert_eq!(
|
||||
std::env::var("JCODE_FORCE_PROVIDER").ok().as_deref(),
|
||||
Some("1")
|
||||
);
|
||||
|
||||
crate::subscription_catalog::clear_runtime_env();
|
||||
crate::env::remove_var("JCODE_OPENROUTER_MODEL");
|
||||
crate::env::remove_var("JCODE_RUNTIME_PROVIDER");
|
||||
crate::env::remove_var("JCODE_ACTIVE_PROVIDER");
|
||||
crate::env::remove_var("JCODE_FORCE_PROVIDER");
|
||||
match saved_home {
|
||||
Some(home) => crate::env::set_var("JCODE_HOME", home),
|
||||
None => crate::env::remove_var("JCODE_HOME"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_openai_compatible_profile_overrides() {
|
||||
let _guard = lock_env();
|
||||
let keys = [
|
||||
"JCODE_OPENAI_COMPAT_API_BASE",
|
||||
"JCODE_OPENAI_COMPAT_API_KEY_NAME",
|
||||
"JCODE_OPENAI_COMPAT_ENV_FILE",
|
||||
"JCODE_OPENAI_COMPAT_DEFAULT_MODEL",
|
||||
];
|
||||
let saved: Vec<(String, Option<String>)> = keys
|
||||
.iter()
|
||||
.map(|k| (k.to_string(), std::env::var(k).ok()))
|
||||
.collect();
|
||||
|
||||
crate::env::set_var(
|
||||
"JCODE_OPENAI_COMPAT_API_BASE",
|
||||
"https://api.groq.com/openai/v1/",
|
||||
);
|
||||
crate::env::set_var("JCODE_OPENAI_COMPAT_API_KEY_NAME", "GROQ_API_KEY");
|
||||
crate::env::set_var("JCODE_OPENAI_COMPAT_ENV_FILE", "groq.env");
|
||||
crate::env::set_var("JCODE_OPENAI_COMPAT_DEFAULT_MODEL", "openai/gpt-oss-120b");
|
||||
|
||||
let resolved = resolve_openai_compatible_profile(provider_catalog::OPENAI_COMPAT_PROFILE);
|
||||
assert_eq!(resolved.api_base, "https://api.groq.com/openai/v1");
|
||||
assert_eq!(resolved.api_key_env, "GROQ_API_KEY");
|
||||
assert_eq!(resolved.env_file, "groq.env");
|
||||
assert_eq!(
|
||||
resolved.default_model.as_deref(),
|
||||
Some("openai/gpt-oss-120b")
|
||||
);
|
||||
|
||||
for (key, value) in saved {
|
||||
if let Some(value) = value {
|
||||
crate::env::set_var(&key, value);
|
||||
} else {
|
||||
crate::env::remove_var(&key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_openai_compatible_profile_rejects_invalid_overrides() {
|
||||
let _guard = lock_env();
|
||||
let keys = [
|
||||
"JCODE_OPENAI_COMPAT_API_BASE",
|
||||
"JCODE_OPENAI_COMPAT_API_KEY_NAME",
|
||||
"JCODE_OPENAI_COMPAT_ENV_FILE",
|
||||
];
|
||||
let saved: Vec<(String, Option<String>)> = keys
|
||||
.iter()
|
||||
.map(|k| (k.to_string(), std::env::var(k).ok()))
|
||||
.collect();
|
||||
|
||||
crate::env::set_var("JCODE_OPENAI_COMPAT_API_BASE", "http://example.com/v1");
|
||||
crate::env::set_var("JCODE_OPENAI_COMPAT_API_KEY_NAME", "bad-key-name");
|
||||
crate::env::set_var("JCODE_OPENAI_COMPAT_ENV_FILE", "../bad.env");
|
||||
|
||||
let resolved = resolve_openai_compatible_profile(provider_catalog::OPENAI_COMPAT_PROFILE);
|
||||
assert_eq!(
|
||||
resolved.api_base,
|
||||
provider_catalog::OPENAI_COMPAT_PROFILE.api_base
|
||||
);
|
||||
assert_eq!(
|
||||
resolved.api_key_env,
|
||||
provider_catalog::OPENAI_COMPAT_PROFILE.api_key_env
|
||||
);
|
||||
assert_eq!(
|
||||
resolved.env_file,
|
||||
provider_catalog::OPENAI_COMPAT_PROFILE.env_file
|
||||
);
|
||||
|
||||
for (key, value) in saved {
|
||||
if let Some(value) = value {
|
||||
crate::env::set_var(&key, value);
|
||||
} else {
|
||||
crate::env::remove_var(&key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_external_auth_review_selection_supports_all_and_deduped_indices() {
|
||||
assert_eq!(
|
||||
parse_external_auth_review_selection("", 3).unwrap(),
|
||||
Vec::<usize>::new()
|
||||
);
|
||||
assert_eq!(
|
||||
parse_external_auth_review_selection("a", 3).unwrap(),
|
||||
vec![0, 1, 2]
|
||||
);
|
||||
assert_eq!(
|
||||
parse_external_auth_review_selection("2,1,2", 3).unwrap(),
|
||||
vec![1, 0]
|
||||
);
|
||||
assert!(parse_external_auth_review_selection("4", 3).is_err());
|
||||
assert!(parse_external_auth_review_selection("nope", 3).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_login_provider_selection_supports_skip_and_names() {
|
||||
let providers = provider_catalog::cli_login_providers();
|
||||
|
||||
assert!(
|
||||
parse_login_provider_selection_input("", &providers)
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
assert!(
|
||||
parse_login_provider_selection_input("skip", &providers)
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
assert_eq!(
|
||||
parse_login_provider_selection_input("claude", &providers)
|
||||
.unwrap()
|
||||
.map(|provider| provider.id),
|
||||
Some("claude")
|
||||
);
|
||||
let first_provider = providers[0].id;
|
||||
assert_eq!(
|
||||
parse_login_provider_selection_input("1", &providers)
|
||||
.unwrap()
|
||||
.map(|provider| provider.id),
|
||||
Some(first_provider)
|
||||
);
|
||||
assert!(parse_login_provider_selection_input("not-a-provider", &providers).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn login_provider_menu_shows_autodetected_auth_and_skip() {
|
||||
let providers = vec![
|
||||
provider_catalog::CLAUDE_LOGIN_PROVIDER,
|
||||
provider_catalog::OPENAI_LOGIN_PROVIDER,
|
||||
];
|
||||
let status = auth::AuthStatus {
|
||||
anthropic: auth::ProviderAuth {
|
||||
state: auth::AuthState::Available,
|
||||
has_oauth: true,
|
||||
oauth_state: auth::AuthState::Available,
|
||||
has_api_key: false,
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let menu = render_login_provider_selection_menu("Choose a provider:", &providers, &status);
|
||||
assert!(menu.contains("Autodetected auth:"));
|
||||
assert!(menu.contains("Anthropic/Claude: configured: OAuth"));
|
||||
assert!(menu.contains("[configured"));
|
||||
assert!(menu.contains("[not configured"));
|
||||
assert!(menu.contains("Skip: press Enter"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn choice_for_login_provider_round_trips_core_targets() {
|
||||
assert_eq!(
|
||||
choice_for_login_provider(provider_catalog::JCODE_LOGIN_PROVIDER),
|
||||
Some(ProviderChoice::Jcode)
|
||||
);
|
||||
assert_eq!(
|
||||
choice_for_login_provider(provider_catalog::OPENROUTER_LOGIN_PROVIDER),
|
||||
Some(ProviderChoice::Openrouter)
|
||||
);
|
||||
assert_eq!(
|
||||
choice_for_login_provider(provider_catalog::ANTHROPIC_API_LOGIN_PROVIDER),
|
||||
Some(ProviderChoice::AnthropicApi)
|
||||
);
|
||||
assert_eq!(
|
||||
choice_for_login_provider(provider_catalog::AZURE_LOGIN_PROVIDER),
|
||||
Some(ProviderChoice::Azure)
|
||||
);
|
||||
assert_eq!(
|
||||
choice_for_login_provider(provider_catalog::CURSOR_LOGIN_PROVIDER),
|
||||
Some(ProviderChoice::Cursor)
|
||||
);
|
||||
assert_eq!(
|
||||
choice_for_login_provider(provider_catalog::AUTO_IMPORT_LOGIN_PROVIDER),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn choice_for_login_provider_round_trips_openai_compatible_profiles() {
|
||||
assert_eq!(
|
||||
choice_for_login_provider(provider_catalog::OPENCODE_LOGIN_PROVIDER),
|
||||
Some(ProviderChoice::Opencode)
|
||||
);
|
||||
assert_eq!(
|
||||
choice_for_login_provider(provider_catalog::LMSTUDIO_LOGIN_PROVIDER),
|
||||
Some(ProviderChoice::Lmstudio)
|
||||
);
|
||||
assert_eq!(
|
||||
choice_for_login_provider(provider_catalog::OPENAI_COMPAT_LOGIN_PROVIDER),
|
||||
Some(ProviderChoice::OpenaiCompatible)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn login_provider_choice_table_round_trips_catalog_providers() {
|
||||
let mut seen_choices = HashSet::new();
|
||||
let mut reverse_mapped_provider_ids = HashSet::new();
|
||||
|
||||
for (choice, provider) in login_provider_choice_mappings() {
|
||||
assert!(
|
||||
seen_choices.insert(choice.as_arg_value()),
|
||||
"duplicate provider choice mapping for {}",
|
||||
choice.as_arg_value()
|
||||
);
|
||||
assert_eq!(
|
||||
login_provider_for_choice(choice).map(|candidate| candidate.id),
|
||||
Some(provider.id),
|
||||
"choice {} should resolve to {}",
|
||||
choice.as_arg_value(),
|
||||
provider.id
|
||||
);
|
||||
|
||||
if reverse_mapped_provider_ids.insert(provider.id) {
|
||||
assert_eq!(
|
||||
choice_for_login_provider(*provider),
|
||||
Some(*choice),
|
||||
"provider {} should reverse-map to {}",
|
||||
provider.id,
|
||||
choice.as_arg_value()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
for provider in provider_catalog::login_providers() {
|
||||
if matches!(
|
||||
provider.target,
|
||||
provider_catalog::LoginProviderTarget::AutoImport
|
||||
) {
|
||||
assert_eq!(choice_for_login_provider(*provider), None);
|
||||
} else {
|
||||
assert!(
|
||||
reverse_mapped_provider_ids.contains(provider.id),
|
||||
"provider {} is in the catalog but not the CLI choice table",
|
||||
provider.id
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auth_integration_registry_matches_cli_choice_runtime_wiring() {
|
||||
for provider in provider_catalog::login_providers() {
|
||||
let integration = crate::auth::integration::auth_provider_integration(provider.id)
|
||||
.expect("catalog provider should have integration metadata");
|
||||
assert_eq!(integration.descriptor, *provider);
|
||||
|
||||
if !matches!(
|
||||
provider.target,
|
||||
provider_catalog::LoginProviderTarget::AutoImport
|
||||
) {
|
||||
assert!(
|
||||
choice_for_login_provider(*provider).is_some(),
|
||||
"provider {} is missing a CLI choice mapping",
|
||||
provider.id
|
||||
);
|
||||
}
|
||||
|
||||
let status = auth::AuthStatus::default();
|
||||
let assessment = status.assessment_for_provider(*provider);
|
||||
assert!(
|
||||
!assessment.method_detail.is_empty(),
|
||||
"provider {} should have non-empty auth status method detail",
|
||||
provider.id
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolved_profile_default_model_uses_openai_compatible_override() {
|
||||
let _guard = lock_env();
|
||||
let _env_guard = crate::storage::lock_test_env();
|
||||
let saved: Vec<(String, Option<String>)> = [
|
||||
"JCODE_OPENAI_COMPAT_API_BASE",
|
||||
"JCODE_OPENAI_COMPAT_API_KEY_NAME",
|
||||
"JCODE_OPENAI_COMPAT_ENV_FILE",
|
||||
"JCODE_OPENAI_COMPAT_DEFAULT_MODEL",
|
||||
]
|
||||
.iter()
|
||||
.map(|k| (k.to_string(), std::env::var(k).ok()))
|
||||
.collect();
|
||||
|
||||
crate::env::set_var("JCODE_OPENAI_COMPAT_API_BASE", "http://localhost:11434/v1");
|
||||
crate::env::set_var("JCODE_OPENAI_COMPAT_DEFAULT_MODEL", "llama3.2");
|
||||
|
||||
assert_eq!(
|
||||
resolved_profile_default_model(provider_catalog::OPENAI_COMPAT_PROFILE).as_deref(),
|
||||
Some("llama3.2")
|
||||
);
|
||||
|
||||
for (key, value) in saved {
|
||||
if let Some(value) = value {
|
||||
crate::env::set_var(&key, value);
|
||||
} else {
|
||||
crate::env::remove_var(&key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_login_provider_profile_env_locks_compatible_profile_for_auto_spawn() {
|
||||
let _guard = lock_env();
|
||||
let _env_guard = crate::storage::lock_test_env();
|
||||
let saved: Vec<(String, Option<String>)> = [
|
||||
"JCODE_OPENROUTER_API_BASE",
|
||||
"JCODE_OPENROUTER_API_KEY_NAME",
|
||||
"JCODE_OPENROUTER_ENV_FILE",
|
||||
"JCODE_OPENROUTER_CACHE_NAMESPACE",
|
||||
"JCODE_OPENROUTER_PROVIDER_FEATURES",
|
||||
"JCODE_OPENROUTER_TRANSPORT_STATE",
|
||||
"JCODE_OPENROUTER_ALLOW_NO_AUTH",
|
||||
"JCODE_OPENROUTER_STATIC_MODELS",
|
||||
"JCODE_PROVIDER_PROFILE_ACTIVE",
|
||||
"JCODE_PROVIDER_PROFILE_NAME",
|
||||
"JCODE_NAMED_PROVIDER_PROFILE",
|
||||
]
|
||||
.iter()
|
||||
.map(|k| (k.to_string(), std::env::var(k).ok()))
|
||||
.collect();
|
||||
|
||||
for (key, _) in &saved {
|
||||
crate::env::remove_var(key);
|
||||
}
|
||||
|
||||
apply_login_provider_profile_env(provider_catalog::OPENCODE_GO_LOGIN_PROVIDER);
|
||||
|
||||
assert_eq!(
|
||||
std::env::var("JCODE_OPENROUTER_API_BASE").ok().as_deref(),
|
||||
Some("https://opencode.ai/zen/go/v1")
|
||||
);
|
||||
assert_eq!(
|
||||
std::env::var("JCODE_OPENROUTER_API_KEY_NAME")
|
||||
.ok()
|
||||
.as_deref(),
|
||||
Some("OPENCODE_GO_API_KEY")
|
||||
);
|
||||
assert_eq!(
|
||||
std::env::var("JCODE_OPENROUTER_ENV_FILE").ok().as_deref(),
|
||||
Some("opencode-go.env")
|
||||
);
|
||||
assert_eq!(
|
||||
std::env::var("JCODE_PROVIDER_PROFILE_ACTIVE")
|
||||
.ok()
|
||||
.as_deref(),
|
||||
Some("1")
|
||||
);
|
||||
|
||||
// Mirrors the daemon child process starting with `--provider auto`: with the
|
||||
// active marker present, auto init must not erase the selected profile env.
|
||||
provider_catalog::apply_openai_compatible_profile_env(None);
|
||||
assert_eq!(
|
||||
std::env::var("JCODE_OPENROUTER_API_KEY_NAME")
|
||||
.ok()
|
||||
.as_deref(),
|
||||
Some("OPENCODE_GO_API_KEY")
|
||||
);
|
||||
|
||||
// A later explicit compatible-provider selection in the same process must
|
||||
// still replace the active profile instead of being blocked by the marker.
|
||||
apply_login_provider_profile_env(provider_catalog::OPENCODE_LOGIN_PROVIDER);
|
||||
assert_eq!(
|
||||
std::env::var("JCODE_OPENROUTER_API_KEY_NAME")
|
||||
.ok()
|
||||
.as_deref(),
|
||||
Some("OPENCODE_API_KEY")
|
||||
);
|
||||
assert_eq!(
|
||||
std::env::var("JCODE_PROVIDER_PROFILE_ACTIVE")
|
||||
.ok()
|
||||
.as_deref(),
|
||||
Some("1")
|
||||
);
|
||||
|
||||
for (key, value) in saved {
|
||||
if let Some(value) = value {
|
||||
crate::env::set_var(&key, value);
|
||||
} else {
|
||||
crate::env::remove_var(&key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[expect(
|
||||
clippy::await_holding_lock,
|
||||
reason = "test env locks intentionally stay held across provider init to isolate process-global runtime env"
|
||||
)]
|
||||
async fn init_provider_for_ollama_reapplies_local_compat_runtime_env_after_disabling_subscription_mode()
|
||||
{
|
||||
let _guard = lock_env();
|
||||
let _env_guard = crate::storage::lock_test_env();
|
||||
let dir = TempDir::new().expect("temp dir");
|
||||
let saved: Vec<(String, Option<String>)> = [
|
||||
"JCODE_HOME",
|
||||
"JCODE_OPENROUTER_API_BASE",
|
||||
"JCODE_OPENROUTER_API_KEY_NAME",
|
||||
"JCODE_OPENROUTER_ENV_FILE",
|
||||
"JCODE_OPENROUTER_CACHE_NAMESPACE",
|
||||
"JCODE_OPENROUTER_PROVIDER_FEATURES",
|
||||
"JCODE_OPENROUTER_TRANSPORT_STATE",
|
||||
"JCODE_OPENROUTER_ALLOW_NO_AUTH",
|
||||
"JCODE_RUNTIME_PROVIDER",
|
||||
"JCODE_FORCE_PROVIDER",
|
||||
"JCODE_ACTIVE_PROVIDER",
|
||||
]
|
||||
.iter()
|
||||
.map(|k| (k.to_string(), std::env::var(k).ok()))
|
||||
.collect();
|
||||
|
||||
crate::env::set_var("JCODE_HOME", dir.path());
|
||||
crate::subscription_catalog::apply_runtime_env();
|
||||
|
||||
let provider = init_provider_for_validation(&ProviderChoice::Ollama, Some("llama3.2"))
|
||||
.await
|
||||
.expect("init ollama provider");
|
||||
|
||||
assert_eq!(
|
||||
std::env::var("JCODE_OPENROUTER_API_BASE").ok().as_deref(),
|
||||
Some("http://localhost:11434/v1")
|
||||
);
|
||||
assert_eq!(
|
||||
std::env::var("JCODE_OPENROUTER_API_KEY_NAME")
|
||||
.ok()
|
||||
.as_deref(),
|
||||
Some("OLLAMA_API_KEY")
|
||||
);
|
||||
assert_eq!(
|
||||
std::env::var("JCODE_OPENROUTER_ENV_FILE").ok().as_deref(),
|
||||
Some("ollama.env")
|
||||
);
|
||||
assert_eq!(
|
||||
std::env::var("JCODE_OPENROUTER_ALLOW_NO_AUTH")
|
||||
.ok()
|
||||
.as_deref(),
|
||||
Some("1")
|
||||
);
|
||||
assert_eq!(
|
||||
std::env::var("JCODE_FORCE_PROVIDER").ok().as_deref(),
|
||||
Some("1")
|
||||
);
|
||||
assert_eq!(
|
||||
std::env::var("JCODE_ACTIVE_PROVIDER").ok().as_deref(),
|
||||
Some("openrouter")
|
||||
);
|
||||
assert_eq!(
|
||||
std::env::var("JCODE_RUNTIME_PROVIDER").ok().as_deref(),
|
||||
Some("openai-compatible")
|
||||
);
|
||||
assert_eq!(provider.name(), "openrouter");
|
||||
assert_eq!(provider.model(), "llama3.2");
|
||||
|
||||
for (key, value) in saved {
|
||||
if let Some(value) = value {
|
||||
crate::env::set_var(&key, value);
|
||||
} else {
|
||||
crate::env::remove_var(&key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[expect(
|
||||
clippy::await_holding_lock,
|
||||
reason = "test env locks intentionally stay held across provider init to isolate process-global runtime env"
|
||||
)]
|
||||
async fn auto_provider_uses_config_default_named_no_auth_provider() {
|
||||
let _guard = lock_env();
|
||||
let _env_guard = crate::storage::lock_test_env();
|
||||
let dir = TempDir::new().expect("temp dir");
|
||||
let saved: Vec<(String, Option<String>)> = [
|
||||
"JCODE_HOME",
|
||||
"JCODE_NON_INTERACTIVE",
|
||||
"ANTHROPIC_API_KEY",
|
||||
"OPENAI_API_KEY",
|
||||
"OPENROUTER_API_KEY",
|
||||
"GITHUB_TOKEN",
|
||||
"GEMINI_API_KEY",
|
||||
"CURSOR_API_KEY",
|
||||
"JCODE_OPENROUTER_API_BASE",
|
||||
"JCODE_OPENROUTER_API_KEY_NAME",
|
||||
"JCODE_OPENROUTER_ALLOW_NO_AUTH",
|
||||
"JCODE_OPENROUTER_ENV_FILE",
|
||||
"JCODE_OPENROUTER_DEFAULT_MODEL",
|
||||
"JCODE_OPENROUTER_CACHE_NAMESPACE",
|
||||
"JCODE_PROVIDER_PROFILE_ACTIVE",
|
||||
"JCODE_PROVIDER_PROFILE_NAME",
|
||||
"JCODE_NAMED_PROVIDER_PROFILE",
|
||||
"JCODE_RUNTIME_PROVIDER",
|
||||
"JCODE_ACTIVE_PROVIDER",
|
||||
"JCODE_FORCE_PROVIDER",
|
||||
]
|
||||
.iter()
|
||||
.map(|k| (k.to_string(), std::env::var(k).ok()))
|
||||
.collect();
|
||||
|
||||
crate::env::set_var("JCODE_HOME", dir.path());
|
||||
crate::env::set_var("JCODE_NON_INTERACTIVE", "1");
|
||||
for key in [
|
||||
"ANTHROPIC_API_KEY",
|
||||
"OPENAI_API_KEY",
|
||||
"OPENROUTER_API_KEY",
|
||||
"GITHUB_TOKEN",
|
||||
"GEMINI_API_KEY",
|
||||
"CURSOR_API_KEY",
|
||||
"JCODE_OPENROUTER_API_BASE",
|
||||
"JCODE_OPENROUTER_API_KEY_NAME",
|
||||
"JCODE_OPENROUTER_ALLOW_NO_AUTH",
|
||||
"JCODE_OPENROUTER_ENV_FILE",
|
||||
"JCODE_OPENROUTER_DEFAULT_MODEL",
|
||||
"JCODE_OPENROUTER_CACHE_NAMESPACE",
|
||||
"JCODE_PROVIDER_PROFILE_ACTIVE",
|
||||
"JCODE_PROVIDER_PROFILE_NAME",
|
||||
"JCODE_NAMED_PROVIDER_PROFILE",
|
||||
"JCODE_RUNTIME_PROVIDER",
|
||||
"JCODE_ACTIVE_PROVIDER",
|
||||
"JCODE_FORCE_PROVIDER",
|
||||
] {
|
||||
crate::env::remove_var(key);
|
||||
}
|
||||
std::fs::write(
|
||||
dir.path().join("config.toml"),
|
||||
r#"
|
||||
[provider]
|
||||
default_provider = "ollama-local"
|
||||
default_model = "llama3.1:8b"
|
||||
|
||||
[providers.ollama-local]
|
||||
type = "openai-compatible"
|
||||
base_url = "http://localhost:11434/v1"
|
||||
auth = "none"
|
||||
default_model = "llama3.1:8b"
|
||||
requires_api_key = false
|
||||
|
||||
[[providers.ollama-local.models]]
|
||||
id = "llama3.1:8b"
|
||||
"#,
|
||||
)
|
||||
.expect("write config");
|
||||
crate::config::invalidate_config_cache();
|
||||
|
||||
let provider = init_provider_for_validation(&ProviderChoice::Auto, None)
|
||||
.await
|
||||
.expect("auto provider should honor config default_provider named profile");
|
||||
|
||||
assert_eq!(provider.model(), "llama3.1:8b");
|
||||
assert!(provider.model_routes().iter().any(|route| {
|
||||
route.provider == "ollama-local" && route.model == "llama3.1:8b" && route.available
|
||||
}));
|
||||
|
||||
for (key, value) in saved {
|
||||
if let Some(value) = value {
|
||||
crate::env::set_var(&key, value);
|
||||
} else {
|
||||
crate::env::remove_var(&key);
|
||||
}
|
||||
}
|
||||
crate::config::invalidate_config_cache();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[expect(
|
||||
clippy::await_holding_lock,
|
||||
reason = "test env locks intentionally stay held across provider init to isolate process-global auth env"
|
||||
)]
|
||||
async fn auto_provider_noninteractive_skips_untrusted_external_auth_instead_of_blocking() {
|
||||
let _guard = lock_env();
|
||||
let _env_guard = crate::storage::lock_test_env();
|
||||
let dir = TempDir::new().expect("temp dir");
|
||||
let saved: Vec<(String, Option<String>)> = [
|
||||
"JCODE_HOME",
|
||||
"JCODE_NON_INTERACTIVE",
|
||||
"JCODE_DEFERRED_AUTH_BOOTSTRAP",
|
||||
"ANTHROPIC_API_KEY",
|
||||
"OPENAI_API_KEY",
|
||||
"OPENROUTER_API_KEY",
|
||||
"GITHUB_TOKEN",
|
||||
"GEMINI_API_KEY",
|
||||
"CURSOR_API_KEY",
|
||||
"JCODE_RUNTIME_PROVIDER",
|
||||
"JCODE_ACTIVE_PROVIDER",
|
||||
"JCODE_FORCE_PROVIDER",
|
||||
]
|
||||
.iter()
|
||||
.map(|k| (k.to_string(), std::env::var(k).ok()))
|
||||
.collect();
|
||||
|
||||
crate::env::set_var("JCODE_HOME", dir.path());
|
||||
crate::env::set_var("JCODE_NON_INTERACTIVE", "1");
|
||||
for key in [
|
||||
"JCODE_DEFERRED_AUTH_BOOTSTRAP",
|
||||
"ANTHROPIC_API_KEY",
|
||||
"OPENAI_API_KEY",
|
||||
"OPENROUTER_API_KEY",
|
||||
"GITHUB_TOKEN",
|
||||
"GEMINI_API_KEY",
|
||||
"CURSOR_API_KEY",
|
||||
"JCODE_ACTIVE_PROVIDER",
|
||||
"JCODE_FORCE_PROVIDER",
|
||||
] {
|
||||
crate::env::remove_var(key);
|
||||
}
|
||||
|
||||
let opencode_path = crate::auth::claude::ExternalClaudeAuthSource::OpenCode
|
||||
.path()
|
||||
.expect("opencode path");
|
||||
std::fs::create_dir_all(opencode_path.parent().expect("opencode parent"))
|
||||
.expect("create opencode dir");
|
||||
std::fs::write(
|
||||
&opencode_path,
|
||||
serde_json::json!({
|
||||
"anthropic": {
|
||||
"access": "oc_acc",
|
||||
"refresh": "oc_ref",
|
||||
"expires": chrono::Utc::now().timestamp_millis() + 60_000
|
||||
}
|
||||
})
|
||||
.to_string(),
|
||||
)
|
||||
.expect("write opencode auth");
|
||||
|
||||
let result = init_provider_for_validation(&ProviderChoice::Auto, None).await;
|
||||
let err = match result {
|
||||
Ok(provider) => panic!(
|
||||
"auto init should still fail without trusted/direct credentials, got provider {}",
|
||||
provider.name()
|
||||
),
|
||||
Err(err) => err,
|
||||
};
|
||||
let message = err.to_string();
|
||||
assert!(
|
||||
message.contains("No credentials configured"),
|
||||
"unexpected error: {message}"
|
||||
);
|
||||
assert!(
|
||||
!message.contains("will not read them without confirmation"),
|
||||
"auto mode should skip untrusted external auth, not fail with the consent prompt error: {message}"
|
||||
);
|
||||
|
||||
for (key, value) in saved {
|
||||
if let Some(value) = value {
|
||||
crate::env::set_var(&key, value);
|
||||
} else {
|
||||
crate::env::remove_var(&key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pending_external_auth_review_candidates_include_shared_and_legacy_sources() {
|
||||
let _guard = lock_env();
|
||||
let _env_guard = crate::storage::lock_test_env();
|
||||
let dir = TempDir::new().expect("temp dir");
|
||||
let prev_home = std::env::var_os("JCODE_HOME");
|
||||
crate::env::set_var("JCODE_HOME", dir.path());
|
||||
|
||||
let opencode_path = crate::auth::external::ExternalAuthSource::OpenCode
|
||||
.path()
|
||||
.expect("opencode path");
|
||||
std::fs::create_dir_all(opencode_path.parent().expect("opencode parent"))
|
||||
.expect("create opencode dir");
|
||||
std::fs::write(
|
||||
&opencode_path,
|
||||
serde_json::json!({
|
||||
"openai": {
|
||||
"type": "oauth",
|
||||
"access": "sk-openai",
|
||||
"refresh": "refresh",
|
||||
"expires": chrono::Utc::now().timestamp_millis() + 60_000
|
||||
}
|
||||
})
|
||||
.to_string(),
|
||||
)
|
||||
.expect("write opencode auth");
|
||||
|
||||
let codex_path = crate::auth::codex::legacy_auth_file_path().expect("codex path");
|
||||
std::fs::create_dir_all(codex_path.parent().expect("codex parent")).expect("create codex dir");
|
||||
std::fs::write(
|
||||
&codex_path,
|
||||
serde_json::json!({
|
||||
"tokens": {
|
||||
"access_token": "sk-codex",
|
||||
"refresh_token": "refresh",
|
||||
"expires_at": chrono::Utc::now().timestamp_millis() + 60_000
|
||||
}
|
||||
})
|
||||
.to_string(),
|
||||
)
|
||||
.expect("write codex auth");
|
||||
|
||||
let candidates = pending_external_auth_review_candidates().expect("candidates");
|
||||
assert!(candidates.iter().any(|candidate| {
|
||||
candidate.source_name() == "OpenCode auth.json"
|
||||
&& candidate.provider_summary().contains("OpenAI/Codex")
|
||||
}));
|
||||
assert!(candidates.iter().any(|candidate| {
|
||||
candidate.source_name() == "Codex auth.json"
|
||||
&& candidate.provider_summary() == "OpenAI/Codex"
|
||||
}));
|
||||
|
||||
if let Some(prev_home) = prev_home {
|
||||
crate::env::set_var("JCODE_HOME", prev_home);
|
||||
} else {
|
||||
crate::env::remove_var("JCODE_HOME");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
#![cfg_attr(test, allow(clippy::await_holding_lock))]
|
||||
|
||||
use anyhow::Result;
|
||||
use std::path::PathBuf;
|
||||
use std::process::Command;
|
||||
|
||||
use crate::{build, logging, session, startup_profile};
|
||||
|
||||
use super::output;
|
||||
use super::provider_init::ProviderChoice;
|
||||
|
||||
pub use jcode_selfdev_types::CLIENT_SELFDEV_ENV;
|
||||
pub use jcode_selfdev_types::client_selfdev_requested;
|
||||
|
||||
const JCODE_REPO_URL: &str = "https://github.com/1jehuang/jcode.git";
|
||||
|
||||
fn selfdev_clone_dir() -> Result<PathBuf> {
|
||||
Ok(crate::storage::jcode_dir()?.join("source").join("jcode"))
|
||||
}
|
||||
|
||||
fn resolve_or_clone_repo_dir() -> Result<PathBuf> {
|
||||
if let Some(repo_dir) = build::get_repo_dir() {
|
||||
return Ok(repo_dir);
|
||||
}
|
||||
|
||||
let repo_dir = selfdev_clone_dir()?;
|
||||
if repo_dir.exists() {
|
||||
if build::is_jcode_repo(&repo_dir) {
|
||||
return Ok(repo_dir);
|
||||
}
|
||||
|
||||
anyhow::bail!(
|
||||
"Self-dev source directory exists but is not a jcode repository: {}\n\
|
||||
Move it aside or clone {} there, then retry.",
|
||||
repo_dir.display(),
|
||||
JCODE_REPO_URL
|
||||
);
|
||||
}
|
||||
|
||||
let parent = repo_dir
|
||||
.parent()
|
||||
.ok_or_else(|| anyhow::anyhow!("Invalid self-dev clone path: {}", repo_dir.display()))?;
|
||||
std::fs::create_dir_all(parent)?;
|
||||
|
||||
output::stderr_info(format!(
|
||||
"No local jcode checkout found; cloning self-dev source into {}...",
|
||||
repo_dir.display()
|
||||
));
|
||||
|
||||
let status = Command::new("git")
|
||||
.arg("clone")
|
||||
.arg(JCODE_REPO_URL)
|
||||
.arg(&repo_dir)
|
||||
.status()
|
||||
.map_err(|e| anyhow::anyhow!("Failed to run git clone for self-dev source: {e}"))?;
|
||||
|
||||
if !status.success() {
|
||||
anyhow::bail!(
|
||||
"Failed to clone self-dev source from {} into {} (git exited with {}).\n\
|
||||
Clone it manually with: git clone {} {}",
|
||||
JCODE_REPO_URL,
|
||||
repo_dir.display(),
|
||||
status,
|
||||
JCODE_REPO_URL,
|
||||
repo_dir.display()
|
||||
);
|
||||
}
|
||||
|
||||
if !build::is_jcode_repo(&repo_dir) {
|
||||
anyhow::bail!(
|
||||
"Cloned self-dev source is not a valid jcode repository: {}",
|
||||
repo_dir.display()
|
||||
);
|
||||
}
|
||||
|
||||
Ok(repo_dir)
|
||||
}
|
||||
|
||||
async fn wait_for_reloading_server() -> bool {
|
||||
match crate::server::await_reload_handoff(
|
||||
&crate::server::socket_path(),
|
||||
std::time::Duration::from_secs(30),
|
||||
)
|
||||
.await
|
||||
{
|
||||
crate::server::ReloadWaitStatus::Ready => true,
|
||||
crate::server::ReloadWaitStatus::Failed(detail) => {
|
||||
logging::warn(&format!(
|
||||
"Reload handoff failed while resuming self-dev session on {}: {}; recent_state={}",
|
||||
crate::server::socket_path().display(),
|
||||
detail.unwrap_or_else(|| "unknown reload failure".to_string()),
|
||||
crate::server::reload_state_summary(std::time::Duration::from_secs(60))
|
||||
));
|
||||
false
|
||||
}
|
||||
crate::server::ReloadWaitStatus::Idle => false,
|
||||
crate::server::ReloadWaitStatus::Waiting { .. } => false,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn run_self_dev(should_build: bool, resume_session: Option<String>) -> Result<()> {
|
||||
startup_profile::mark("run_self_dev_enter");
|
||||
crate::env::set_var(CLIENT_SELFDEV_ENV, "1");
|
||||
|
||||
let repo_dir = resolve_or_clone_repo_dir()?;
|
||||
crate::env::set_var("JCODE_REPO_DIR", &repo_dir);
|
||||
|
||||
startup_profile::mark("selfdev_session_create");
|
||||
let is_resume = resume_session.is_some();
|
||||
let session_id = if let Some(id) = resume_session {
|
||||
if let Ok(mut session) = session::Session::load(&id)
|
||||
&& !session.is_canary
|
||||
{
|
||||
session.set_canary("self-dev");
|
||||
let _ = session.save();
|
||||
}
|
||||
id
|
||||
} else {
|
||||
let mut session =
|
||||
session::Session::create(None, Some("Self-development session".to_string()));
|
||||
session.set_canary("self-dev");
|
||||
session.id.clone()
|
||||
};
|
||||
|
||||
crate::process_title::set_client_session_title(&session_id, true);
|
||||
|
||||
if should_build {
|
||||
let source = build::current_source_state(&repo_dir)?;
|
||||
let build = build::selfdev_build_command(&repo_dir);
|
||||
output::stderr_info(format!("Building with {}...", build.display));
|
||||
|
||||
build::run_selfdev_build(&repo_dir)?;
|
||||
build::ensure_source_state_matches(&repo_dir, &source)?;
|
||||
|
||||
build::publish_local_current_build_for_source(&repo_dir, &source)?;
|
||||
|
||||
output::stderr_info("✓ Build complete; updated current launcher");
|
||||
}
|
||||
|
||||
let target_binary = build::client_update_candidate(true)
|
||||
.map(|(path, _)| path)
|
||||
.or_else(|| build::find_dev_binary(&repo_dir))
|
||||
.unwrap_or_else(|| build::release_binary_path(&repo_dir));
|
||||
|
||||
if !target_binary.exists() {
|
||||
anyhow::bail!(
|
||||
"No binary found at {:?}\n\
|
||||
Run 'jcode self-dev --build' first, or build with '{}' and then publish current.",
|
||||
target_binary,
|
||||
build::selfdev_build_command(&repo_dir).display,
|
||||
);
|
||||
}
|
||||
|
||||
let hash = build::current_git_hash(&repo_dir)?;
|
||||
startup_profile::mark("selfdev_git_hash");
|
||||
|
||||
if !is_resume {
|
||||
output::stderr_info(format!("Starting self-dev session with {}...", hash));
|
||||
} else {
|
||||
logging::info(&format!("Resuming self-dev session with {}...", hash));
|
||||
}
|
||||
|
||||
if is_resume {
|
||||
crate::env::set_var("JCODE_RESUMING", "1");
|
||||
}
|
||||
|
||||
let mut server_running = super::dispatch::server_is_running().await;
|
||||
if !server_running && std::env::var("JCODE_RESUMING").is_ok() {
|
||||
if let Some(state) = crate::server::recent_reload_state(std::time::Duration::from_secs(30))
|
||||
{
|
||||
match state.phase {
|
||||
crate::server::ReloadPhase::Starting => {
|
||||
logging::info(
|
||||
"Reload state=starting while resuming self-dev session; waiting for existing server to come back",
|
||||
);
|
||||
server_running = wait_for_reloading_server().await;
|
||||
}
|
||||
crate::server::ReloadPhase::Failed => {
|
||||
if let Ok(Some(version)) =
|
||||
build::rollback_pending_activation_for_session(&session_id)
|
||||
{
|
||||
logging::warn(&format!(
|
||||
"Rolled back failed pending activation for build {} while resuming self-dev session",
|
||||
version
|
||||
));
|
||||
}
|
||||
logging::warn(&format!(
|
||||
"Reload state=failed while resuming self-dev session on {}: {}; recent_state={}",
|
||||
crate::server::socket_path().display(),
|
||||
state
|
||||
.detail
|
||||
.unwrap_or_else(|| "unknown reload failure".to_string()),
|
||||
crate::server::reload_state_summary(std::time::Duration::from_secs(60))
|
||||
));
|
||||
}
|
||||
crate::server::ReloadPhase::SocketReady => {}
|
||||
}
|
||||
}
|
||||
|
||||
if !server_running {
|
||||
server_running = super::dispatch::wait_for_resuming_server(
|
||||
"self-dev resume without reload marker",
|
||||
std::time::Duration::from_secs(5),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
if server_running
|
||||
&& let Ok(Some(version)) = build::complete_pending_activation_for_session(&session_id)
|
||||
{
|
||||
logging::info(&format!(
|
||||
"Marked pending self-dev activation as successful for build {}",
|
||||
version
|
||||
));
|
||||
}
|
||||
|
||||
if !server_running {
|
||||
super::dispatch::maybe_prompt_server_bootstrap_login(&ProviderChoice::Auto).await?;
|
||||
super::dispatch::spawn_server(&ProviderChoice::Auto, None, None).await?;
|
||||
}
|
||||
|
||||
if std::env::var("JCODE_RESUMING").is_err() && server_running {
|
||||
output::stderr_info("Connecting to shared server...");
|
||||
}
|
||||
|
||||
output::stderr_info("Starting self-dev TUI...");
|
||||
|
||||
super::tui_launch::run_tui_client(Some(session_id), None, !server_running, false, None).await
|
||||
}
|
||||
#[cfg(test)]
|
||||
#[path = "selfdev_tests.rs"]
|
||||
mod selfdev_tests;
|
||||
@@ -0,0 +1,352 @@
|
||||
use super::wait_for_reloading_server;
|
||||
use crate::build;
|
||||
use crate::{provider, session, storage, tool};
|
||||
use std::ffi::{OsStr, OsString};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
fn lock_env() -> std::sync::MutexGuard<'static, ()> {
|
||||
storage::lock_test_env()
|
||||
}
|
||||
|
||||
struct EnvVarGuard {
|
||||
vars: Vec<(&'static str, Option<OsString>)>,
|
||||
}
|
||||
|
||||
impl EnvVarGuard {
|
||||
fn capture(names: &[&'static str]) -> Self {
|
||||
Self {
|
||||
vars: names
|
||||
.iter()
|
||||
.map(|name| (*name, std::env::var_os(name)))
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for EnvVarGuard {
|
||||
fn drop(&mut self) {
|
||||
for (name, value) in &self.vars {
|
||||
if let Some(value) = value {
|
||||
crate::env::set_var(name, value);
|
||||
} else {
|
||||
crate::env::remove_var(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn set_socket_test_env(socket_path: &Path, runtime_dir: &Path) -> EnvVarGuard {
|
||||
let guard = EnvVarGuard::capture(&["JCODE_SOCKET", "JCODE_RUNTIME_DIR"]);
|
||||
crate::server::set_socket_path(socket_path.to_str().expect("utf8 socket path"));
|
||||
crate::env::set_var("JCODE_RUNTIME_DIR", runtime_dir);
|
||||
guard
|
||||
}
|
||||
|
||||
struct TestEnvGuard {
|
||||
_lock: std::sync::MutexGuard<'static, ()>,
|
||||
_env: EnvVarGuard,
|
||||
_temp_home: tempfile::TempDir,
|
||||
}
|
||||
|
||||
impl TestEnvGuard {
|
||||
fn new() -> anyhow::Result<Self> {
|
||||
let lock = lock_env();
|
||||
let temp_home = tempfile::Builder::new()
|
||||
.prefix("jcode-selfdev-test-home-")
|
||||
.tempdir()?;
|
||||
let env = EnvVarGuard::capture(&["JCODE_HOME", "JCODE_TEST_SESSION"]);
|
||||
|
||||
crate::env::set_var("JCODE_HOME", temp_home.path());
|
||||
crate::env::set_var("JCODE_TEST_SESSION", "1");
|
||||
|
||||
Ok(Self {
|
||||
_lock: lock,
|
||||
_env: env,
|
||||
_temp_home: temp_home,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn setup_test_env() -> TestEnvGuard {
|
||||
TestEnvGuard::new().expect("failed to setup isolated test environment")
|
||||
}
|
||||
|
||||
struct TestProvider;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl provider::Provider for TestProvider {
|
||||
fn name(&self) -> &str {
|
||||
"test"
|
||||
}
|
||||
|
||||
fn model(&self) -> String {
|
||||
"test".to_string()
|
||||
}
|
||||
|
||||
fn available_models(&self) -> Vec<&'static str> {
|
||||
vec![]
|
||||
}
|
||||
|
||||
fn available_models_display(&self) -> Vec<String> {
|
||||
vec![]
|
||||
}
|
||||
|
||||
async fn prefetch_models(&self) -> anyhow::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn set_model(&self, _model: &str) -> anyhow::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn handles_tools_internally(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
async fn complete(
|
||||
&self,
|
||||
_messages: &[crate::message::Message],
|
||||
_tools: &[crate::message::ToolDefinition],
|
||||
_system: &str,
|
||||
_session_id: Option<&str>,
|
||||
) -> anyhow::Result<crate::provider::EventStream> {
|
||||
Err(anyhow::anyhow!(
|
||||
"TestProvider should not be used for streaming completions in selfdev tests"
|
||||
))
|
||||
}
|
||||
|
||||
fn fork(&self) -> Arc<dyn provider::Provider> {
|
||||
Arc::new(TestProvider)
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_selfdev_tool_registration() {
|
||||
let _env = setup_test_env();
|
||||
|
||||
let mut session = session::Session::create(None, Some("Test".to_string()));
|
||||
session.set_canary("test");
|
||||
assert!(session.is_canary, "Session should be marked as canary");
|
||||
|
||||
let provider = Arc::new(TestProvider) as Arc<dyn provider::Provider>;
|
||||
let registry = tool::Registry::new(provider).await;
|
||||
|
||||
let tools_before: Vec<String> = registry.tool_names().await;
|
||||
let has_selfdev_before = tools_before.contains(&"selfdev".to_string());
|
||||
|
||||
registry.register_selfdev_tools().await;
|
||||
|
||||
let tools_after: Vec<String> = registry.tool_names().await;
|
||||
let has_selfdev_after = tools_after.contains(&"selfdev".to_string());
|
||||
|
||||
println!(
|
||||
"Before: selfdev={}, tools={:?}",
|
||||
has_selfdev_before,
|
||||
tools_before.len()
|
||||
);
|
||||
println!(
|
||||
"After: selfdev={}, tools={:?}",
|
||||
has_selfdev_after,
|
||||
tools_after.len()
|
||||
);
|
||||
|
||||
assert!(has_selfdev_after, "selfdev should be registered");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_selfdev_session_and_registry() {
|
||||
let _env = setup_test_env();
|
||||
|
||||
let mut session = session::Session::create(None, Some("Test E2E".to_string()));
|
||||
session.set_canary("test-build");
|
||||
let session_id = session.id.clone();
|
||||
session.save().expect("Failed to save session");
|
||||
|
||||
let loaded = session::Session::load(&session_id).expect("Failed to load session");
|
||||
assert!(loaded.is_canary, "Loaded session should be canary");
|
||||
|
||||
let provider = Arc::new(TestProvider) as Arc<dyn provider::Provider>;
|
||||
let registry = tool::Registry::new(provider.clone()).await;
|
||||
|
||||
let tools_before = registry.tool_names().await;
|
||||
assert!(
|
||||
tools_before.contains(&"selfdev".to_string()),
|
||||
"selfdev should be available in all sessions initially"
|
||||
);
|
||||
|
||||
registry.register_selfdev_tools().await;
|
||||
|
||||
let tools_after = registry.tool_names().await;
|
||||
assert!(
|
||||
tools_after.contains(&"selfdev".to_string()),
|
||||
"selfdev SHOULD be registered after register_selfdev_tools"
|
||||
);
|
||||
|
||||
let ctx = tool::ToolContext {
|
||||
session_id: session_id.clone(),
|
||||
message_id: "test".to_string(),
|
||||
tool_call_id: "test".to_string(),
|
||||
working_dir: None,
|
||||
stdin_request_tx: None,
|
||||
graceful_shutdown_signal: None,
|
||||
execution_mode: tool::ToolExecutionMode::Direct,
|
||||
};
|
||||
let result = registry
|
||||
.execute("selfdev", serde_json::json!({"action": "status"}), ctx)
|
||||
.await;
|
||||
|
||||
println!("selfdev status result: {:?}", result);
|
||||
assert!(result.is_ok(), "selfdev tool should execute successfully");
|
||||
|
||||
let _ = std::fs::remove_file(
|
||||
storage::jcode_dir()
|
||||
.unwrap()
|
||||
.join("sessions")
|
||||
.join(format!("{}.json", session_id)),
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_wait_for_reloading_server_returns_false_when_reload_failed() {
|
||||
let _guard = crate::storage::lock_test_env();
|
||||
let temp = tempfile::tempdir().expect("tempdir");
|
||||
let socket_path = temp.path().join("jcode.sock");
|
||||
let _env = set_socket_test_env(&socket_path, temp.path());
|
||||
crate::server::write_reload_state(
|
||||
"reload-test",
|
||||
"hash",
|
||||
crate::server::ReloadPhase::Failed,
|
||||
Some("boom".to_string()),
|
||||
);
|
||||
|
||||
assert!(!wait_for_reloading_server().await);
|
||||
|
||||
crate::server::clear_reload_marker();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_wait_for_reloading_server_returns_true_for_live_listener() {
|
||||
let _guard = crate::storage::lock_test_env();
|
||||
let temp = tempfile::tempdir().expect("tempdir");
|
||||
let socket_path = temp.path().join("jcode.sock");
|
||||
let _env = set_socket_test_env(&socket_path, temp.path());
|
||||
let _listener = crate::transport::Listener::bind(&socket_path).expect("bind listener");
|
||||
|
||||
assert!(wait_for_reloading_server().await);
|
||||
}
|
||||
|
||||
fn isolated_launcher_env() -> (
|
||||
std::sync::MutexGuard<'static, ()>,
|
||||
EnvVarGuard,
|
||||
tempfile::TempDir,
|
||||
) {
|
||||
let lock = lock_env();
|
||||
let temp = tempfile::tempdir().expect("tempdir");
|
||||
let env = EnvVarGuard::capture(&["JCODE_INSTALL_DIR", "JCODE_HOME", "HOME", "USERPROFILE"]);
|
||||
crate::env::set_var("HOME", temp.path());
|
||||
crate::env::set_var("USERPROFILE", temp.path());
|
||||
crate::env::remove_var("JCODE_INSTALL_DIR");
|
||||
crate::env::remove_var("JCODE_HOME");
|
||||
(lock, env, temp)
|
||||
}
|
||||
|
||||
fn set_var<T: AsRef<OsStr>>(name: &str, value: T) {
|
||||
crate::env::set_var(name, value);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_launcher_dir_uses_trimmed_install_dir_before_jcode_home() {
|
||||
let (_lock, _env, temp) = isolated_launcher_env();
|
||||
let install_dir = temp.path().join("install bin");
|
||||
let jcode_home = temp.path().join("jcode-home");
|
||||
set_var(
|
||||
"JCODE_INSTALL_DIR",
|
||||
format!(" {} ", install_dir.display()),
|
||||
);
|
||||
set_var("JCODE_HOME", &jcode_home);
|
||||
|
||||
assert_eq!(build::launcher_dir().expect("launcher dir"), install_dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_launcher_dir_ignores_blank_overrides_and_uses_home_default() {
|
||||
let (_lock, _env, temp) = isolated_launcher_env();
|
||||
set_var("JCODE_INSTALL_DIR", " ");
|
||||
set_var("JCODE_HOME", "\t");
|
||||
|
||||
let expected = default_launcher_dir(temp.path());
|
||||
assert_eq!(build::launcher_dir().expect("launcher dir"), expected);
|
||||
}
|
||||
|
||||
fn default_launcher_dir(home: &Path) -> PathBuf {
|
||||
if cfg!(windows) {
|
||||
home.join("AppData").join("Local").join("jcode").join("bin")
|
||||
} else {
|
||||
home.join(".local").join("bin")
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_selfdev_build_command_prefers_repo_wrapper_when_present() {
|
||||
let temp = tempfile::tempdir().expect("tempdir");
|
||||
let scripts_dir = temp.path().join("scripts");
|
||||
std::fs::create_dir_all(&scripts_dir).expect("create scripts dir");
|
||||
std::fs::write(scripts_dir.join("dev_cargo.sh"), "#!/usr/bin/env bash\n")
|
||||
.expect("write wrapper");
|
||||
|
||||
let build = build::selfdev_build_command(temp.path());
|
||||
assert_eq!(build.program, "bash");
|
||||
assert_eq!(build.args.first().map(String::as_str), Some("-lc"));
|
||||
let command = build.args.get(1).expect("shell command");
|
||||
assert!(command.contains("dev_cargo.sh' build --profile selfdev -p jcode --bin jcode"));
|
||||
assert!(!command.contains("jcode-desktop"));
|
||||
assert!(build.display.contains("-p jcode --bin jcode"));
|
||||
assert!(!build.display.contains("jcode-desktop"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_selfdev_build_command_falls_back_to_cargo_when_wrapper_missing() {
|
||||
let temp = tempfile::tempdir().expect("tempdir");
|
||||
let build = build::selfdev_build_command(temp.path());
|
||||
assert_eq!(build.program, "bash");
|
||||
assert_eq!(build.args.first().map(String::as_str), Some("-lc"));
|
||||
let command = build.args.get(1).expect("shell command");
|
||||
assert!(command.contains("cargo build --profile selfdev -p jcode --bin jcode"));
|
||||
assert!(!command.contains("jcode-desktop"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_selfdev_build_command_can_target_all() {
|
||||
let temp = tempfile::tempdir().expect("tempdir");
|
||||
let build =
|
||||
build::selfdev_build_command_for_target(temp.path(), build::SelfDevBuildTarget::All);
|
||||
assert!(build.display.contains("-p jcode --bin jcode"));
|
||||
assert!(
|
||||
build
|
||||
.display
|
||||
.contains("-p jcode-desktop --bin jcode-desktop")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_selfdev_build_command_can_target_tui_only() {
|
||||
let temp = tempfile::tempdir().expect("tempdir");
|
||||
let build =
|
||||
build::selfdev_build_command_for_target(temp.path(), build::SelfDevBuildTarget::Tui);
|
||||
assert!(build.display.contains("-p jcode --bin jcode"));
|
||||
assert!(!build.display.contains("jcode-desktop"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_selfdev_build_command_can_target_desktop_only() {
|
||||
let temp = tempfile::tempdir().expect("tempdir");
|
||||
let build =
|
||||
build::selfdev_build_command_for_target(temp.path(), build::SelfDevBuildTarget::Desktop);
|
||||
assert!(!build.display.contains("-p jcode --bin jcode"));
|
||||
assert!(
|
||||
build
|
||||
.display
|
||||
.contains("-p jcode-desktop --bin jcode-desktop")
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,437 @@
|
||||
use anyhow::Result;
|
||||
use clap::Parser;
|
||||
use std::process::Command as ProcessCommand;
|
||||
|
||||
use crate::{build, logging, perf, server, startup_profile, storage, telemetry, update};
|
||||
|
||||
use super::{
|
||||
args::{Args, Command},
|
||||
dispatch, hot_exec, output, terminal,
|
||||
};
|
||||
|
||||
pub async fn run() -> Result<()> {
|
||||
startup_profile::init();
|
||||
|
||||
terminal::install_panic_hook();
|
||||
startup_profile::mark("panic_hook");
|
||||
|
||||
logging::init();
|
||||
startup_profile::mark("logging_init");
|
||||
// Old log pruning now runs on a background thread inside logging::init(),
|
||||
// so it no longer blocks startup. Memory-event logs have a separate,
|
||||
// longer (14-day) retention, so prune them on their own background thread.
|
||||
std::thread::Builder::new()
|
||||
.name("jcode-memlog-cleanup".to_string())
|
||||
.spawn(crate::memory_log::cleanup_old_memory_logs)
|
||||
.ok();
|
||||
// Prune stale per-session `.bak` recovery copies (never the transcripts
|
||||
// themselves) so the sessions directory does not grow without bound.
|
||||
std::thread::Builder::new()
|
||||
.name("jcode-session-bak-prune".to_string())
|
||||
.spawn(crate::session::prune_old_session_backups)
|
||||
.ok();
|
||||
logging::info("jcode starting");
|
||||
|
||||
// Wire config-reload reactions without making config depend on auth/bus:
|
||||
// when the config cache reloads, invalidate the auth-status cache and
|
||||
// broadcast a models-updated event.
|
||||
crate::config::on_config_reloaded(crate::auth::AuthStatus::invalidate_cache);
|
||||
crate::config::on_config_reloaded(|| crate::bus::Bus::global().publish_models_updated());
|
||||
|
||||
// Invert the legacy provider_catalog -> auth dependency: provider_catalog
|
||||
// consults registered fallback resolvers, and auth (the higher layer)
|
||||
// registers its external-CLI credential scan here.
|
||||
crate::provider_catalog::register_api_key_fallback_resolver(
|
||||
crate::auth::external::load_api_key_for_env,
|
||||
);
|
||||
|
||||
// Register externally-implemented provider runtimes with the base
|
||||
// provider registry. These crates sit downstream of jcode-base (so
|
||||
// provider edits do not rebuild the app spine), which means base cannot
|
||||
// name their concrete types; this composition root wires them up instead.
|
||||
register_external_provider_runtimes();
|
||||
|
||||
// Invert the legacy safety -> notifications dependency: safety raises a
|
||||
// permission request and the notifications layer (which depends on safety
|
||||
// types) delivers it via the dispatcher registered here.
|
||||
crate::safety::register_permission_notifier(|action, description, request_id| {
|
||||
crate::notifications::NotificationDispatcher::new().dispatch_permission_request(
|
||||
action,
|
||||
description,
|
||||
request_id,
|
||||
);
|
||||
});
|
||||
|
||||
// Invert the legacy memory -> skill dependency: memory collects synthetic
|
||||
// entries from registered providers, and skill (the higher layer that
|
||||
// depends on MemoryEntry) registers its registry->memory adapter here.
|
||||
// The shared snapshot holds global skills only; memory retrieval is
|
||||
// process-scoped, so compose the project overlay from the process cwd
|
||||
// (issue #457 keeps session overlays out of the shared registry).
|
||||
crate::memory::register_synthetic_entry_provider(|| {
|
||||
let global = crate::skill::SkillRegistry::shared_snapshot();
|
||||
crate::skill::SkillRegistry::effective_for_working_dir(&global, None)
|
||||
.list()
|
||||
.into_iter()
|
||||
.map(|skill| skill.as_memory_entry())
|
||||
.collect()
|
||||
});
|
||||
|
||||
// Invert the legacy server -> tui dependency: the TUI session picker owns
|
||||
// the session-list cache and registers its invalidator here, so the server
|
||||
// can drop the cache (e.g. after a rename) without referencing tui.
|
||||
crate::session_list_cache::register_invalidator(
|
||||
crate::tui::session_picker::invalidate_session_list_cache,
|
||||
);
|
||||
|
||||
// Invert the legacy tui -> cli dependency for shared-server spawning: the
|
||||
// CLI owns the provider-bootstrap spawn logic and registers it here, so the
|
||||
// TUI reconnect loop can request a replacement server via server_spawn
|
||||
// without referencing cli.
|
||||
crate::server_spawn::register_default_server_spawner(Box::new(|| {
|
||||
Box::pin(async {
|
||||
dispatch::spawn_server(&crate::cli::provider_init::ProviderChoice::Auto, None, None)
|
||||
.await
|
||||
})
|
||||
}));
|
||||
|
||||
crate::tui::keybind::log_keybinding_default_warnings();
|
||||
crate::platform::raise_nofile_limit_best_effort(8_192);
|
||||
startup_profile::mark("nofile_limit");
|
||||
|
||||
storage::harden_user_config_permissions();
|
||||
startup_profile::mark("perm_harden");
|
||||
|
||||
perf::init_background();
|
||||
startup_profile::mark("perf_init");
|
||||
|
||||
telemetry::record_install_if_first_run();
|
||||
telemetry::record_upgrade_if_needed();
|
||||
startup_profile::mark("telemetry_check");
|
||||
|
||||
let args = parse_and_prepare_args()?;
|
||||
spawn_background_update_check(&args);
|
||||
|
||||
if let Err(e) = dispatch::run_main(args).await {
|
||||
report_main_error(&e);
|
||||
return Err(e);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Register provider runtimes that live downstream of `jcode-base` with the
|
||||
/// base crate's external provider registry. Keep every downstream runtime
|
||||
/// registration in this one function so the composition-root wiring stays
|
||||
/// discoverable as more providers move out of the base crate.
|
||||
pub fn register_external_provider_runtimes() {
|
||||
crate::provider::external::register_external_provider(
|
||||
crate::provider::external::GEMINI_RUNTIME,
|
||||
|| std::sync::Arc::new(jcode_provider_gemini_runtime::GeminiProvider::new()),
|
||||
);
|
||||
crate::provider::external::register_external_provider(
|
||||
crate::provider::external::CURSOR_RUNTIME,
|
||||
|| std::sync::Arc::new(jcode_provider_cursor_runtime::CursorCliProvider::new()),
|
||||
);
|
||||
crate::provider::external::register_external_provider(
|
||||
crate::provider::external::ANTIGRAVITY_RUNTIME,
|
||||
|| std::sync::Arc::new(jcode_provider_antigravity_runtime::AntigravityProvider::new()),
|
||||
);
|
||||
crate::provider::external::register_external_provider(
|
||||
crate::provider::external::CLAUDE_CLI_RUNTIME,
|
||||
|| std::sync::Arc::new(jcode_provider_claude_cli_runtime::ClaudeProvider::new()),
|
||||
);
|
||||
crate::provider::external::register_external_provider(
|
||||
crate::provider::external::ANTHROPIC_RUNTIME,
|
||||
|| std::sync::Arc::new(jcode_provider_anthropic_runtime::AnthropicProvider::new()),
|
||||
);
|
||||
// OpenRouter serves several identities (aggregator, pinned API-key
|
||||
// runtime, direct OpenAI-compatible profiles, named config profiles)
|
||||
// through one concrete type, so it registers a parameterized factory.
|
||||
crate::provider::external::register_openrouter_factory(|spec| {
|
||||
use crate::provider::external::OpenRouterRuntimeSpec;
|
||||
use jcode_provider_openrouter_runtime::OpenRouterProvider;
|
||||
let provider: std::sync::Arc<dyn crate::provider::Provider> = match spec {
|
||||
OpenRouterRuntimeSpec::Default => std::sync::Arc::new(OpenRouterProvider::new()?),
|
||||
OpenRouterRuntimeSpec::OpenRouterApiKey => {
|
||||
std::sync::Arc::new(OpenRouterProvider::new_openrouter_api_key_runtime()?)
|
||||
}
|
||||
OpenRouterRuntimeSpec::CompatibleProfile(profile) => std::sync::Arc::new(
|
||||
OpenRouterProvider::new_openai_compatible_profile_runtime(profile)?,
|
||||
),
|
||||
OpenRouterRuntimeSpec::NamedProfile { name, config } => std::sync::Arc::new(
|
||||
OpenRouterProvider::new_named_openai_compatible(&name, &config)?,
|
||||
),
|
||||
};
|
||||
Ok(provider)
|
||||
});
|
||||
crate::provider::external::register_profile_catalog_refresh(
|
||||
jcode_provider_openrouter_runtime::maybe_schedule_openai_compatible_profile_catalog_refresh,
|
||||
);
|
||||
crate::provider::external::register_standard_openrouter_catalog_refresh(
|
||||
jcode_provider_openrouter_runtime::maybe_schedule_standard_openrouter_catalog_refresh,
|
||||
);
|
||||
// OpenAI's constructor needs Codex credentials on hand; absence means the
|
||||
// provider is simply unavailable.
|
||||
crate::provider::external::register_external_provider_fallible(
|
||||
crate::provider::external::OPENAI_RUNTIME,
|
||||
|| {
|
||||
let credentials = crate::auth::codex::load_credentials().ok()?;
|
||||
Some(
|
||||
std::sync::Arc::new(jcode_provider_openai_runtime::OpenAIProvider::new(
|
||||
credentials,
|
||||
)) as std::sync::Arc<dyn crate::provider::Provider>,
|
||||
)
|
||||
},
|
||||
);
|
||||
// Copilot's constructor is fallible (needs a GitHub token) and the runtime
|
||||
// wants tier detection scheduled right after construction, eagerly for
|
||||
// interactive sessions and deferred for non-interactive ones. That policy
|
||||
// lives here in the composition root so base stays provider-agnostic.
|
||||
crate::provider::external::register_external_provider_fallible(
|
||||
crate::provider::external::COPILOT_RUNTIME,
|
||||
|| {
|
||||
let provider = std::sync::Arc::new(
|
||||
jcode_provider_copilot_runtime::CopilotApiProvider::new().ok()?,
|
||||
);
|
||||
let eager_tier_detection = std::env::var("JCODE_NON_INTERACTIVE").is_err();
|
||||
if eager_tier_detection && tokio::runtime::Handle::try_current().is_ok() {
|
||||
let p_clone = std::sync::Arc::clone(&provider);
|
||||
tokio::spawn(async move {
|
||||
p_clone.detect_tier_and_set_default().await;
|
||||
});
|
||||
} else {
|
||||
provider.complete_init_without_tier_detection();
|
||||
}
|
||||
Some(provider as std::sync::Arc<dyn crate::provider::Provider>)
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
fn parse_and_prepare_args() -> Result<Args> {
|
||||
let args = Args::parse();
|
||||
startup_profile::mark("args_parse");
|
||||
|
||||
output::set_quiet_enabled(args.quiet);
|
||||
|
||||
if let Some(cwd) = &args.cwd {
|
||||
std::env::set_current_dir(cwd)?;
|
||||
logging::info(&format!("Changed working directory to: {}", cwd));
|
||||
}
|
||||
|
||||
validate_remote_working_dir(args.remote_working_dir.as_deref())?;
|
||||
|
||||
if args.trace {
|
||||
crate::env::set_var("JCODE_TRACE", "1");
|
||||
}
|
||||
|
||||
if let Some(ref socket) = args.socket {
|
||||
server::set_socket_path(socket);
|
||||
}
|
||||
|
||||
crate::cli::proctitle::set_initial_title(&args);
|
||||
|
||||
Ok(args)
|
||||
}
|
||||
|
||||
fn validate_remote_working_dir(remote_working_dir: Option<&str>) -> Result<()> {
|
||||
if let Some(remote_working_dir) = remote_working_dir
|
||||
&& !remote_working_dir_is_absolute(remote_working_dir)
|
||||
{
|
||||
anyhow::bail!("--remote-working-dir must be an absolute path");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn remote_working_dir_is_absolute(path: &str) -> bool {
|
||||
if path.starts_with('/') || path.starts_with('\\') {
|
||||
return true;
|
||||
}
|
||||
|
||||
let bytes = path.as_bytes();
|
||||
bytes.len() >= 3
|
||||
&& bytes[1] == b':'
|
||||
&& (bytes[2] == b'/' || bytes[2] == b'\\')
|
||||
&& bytes[0].is_ascii_alphabetic()
|
||||
}
|
||||
|
||||
fn spawn_background_update_check(args: &Args) {
|
||||
let check_updates = should_spawn_background_update_check(args);
|
||||
let auto_update = should_auto_install_update(args);
|
||||
|
||||
if !check_updates {
|
||||
return;
|
||||
}
|
||||
|
||||
if update::is_release_build() {
|
||||
std::thread::spawn(move || match update::check_and_maybe_update(auto_update) {
|
||||
update::UpdateCheckResult::UpdateAvailable {
|
||||
current, latest, ..
|
||||
} => {
|
||||
logging::info(&format!("Update available: {} -> {}", current, latest));
|
||||
}
|
||||
update::UpdateCheckResult::UpdateInstalled { version, path } => {
|
||||
logging::info(&format!("Updated to {}. Restarting...", version));
|
||||
std::thread::sleep(std::time::Duration::from_millis(250));
|
||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||
let exec_path = build::client_update_candidate(false)
|
||||
.map(|(p, _)| p)
|
||||
.unwrap_or(path);
|
||||
let err = crate::platform::replace_process(
|
||||
ProcessCommand::new(&exec_path)
|
||||
.args(&args)
|
||||
.arg("--no-update"),
|
||||
);
|
||||
eprintln!("Failed to exec new binary: {}", err);
|
||||
}
|
||||
update::UpdateCheckResult::Error(e) => {
|
||||
logging::info(&format!("Update check failed: {}", e));
|
||||
}
|
||||
update::UpdateCheckResult::NoUpdate => {}
|
||||
});
|
||||
} else {
|
||||
std::thread::spawn(move || {
|
||||
use crate::bus::{Bus, BusEvent, UpdateStatus};
|
||||
|
||||
let start = std::time::Instant::now();
|
||||
Bus::global().publish(BusEvent::UpdateStatus(UpdateStatus::Checking));
|
||||
if let Some(update_available) = hot_exec::check_for_updates()
|
||||
&& update_available
|
||||
{
|
||||
Bus::global().publish(BusEvent::UpdateStatus(UpdateStatus::Available {
|
||||
current: jcode_build_meta::VERSION.to_string(),
|
||||
latest: "latest source".to_string(),
|
||||
}));
|
||||
if auto_update {
|
||||
logging::info("Update available - auto-updating...");
|
||||
Bus::global().publish(BusEvent::UpdateStatus(UpdateStatus::Installing {
|
||||
version: "latest source".to_string(),
|
||||
}));
|
||||
if let Err(e) = hot_exec::run_auto_update() {
|
||||
Bus::global()
|
||||
.publish(BusEvent::UpdateStatus(UpdateStatus::Error(e.to_string())));
|
||||
logging::error(&format!(
|
||||
"Auto-update failed: {}. Continuing with current version.",
|
||||
e
|
||||
));
|
||||
}
|
||||
} else {
|
||||
logging::info("Update available! Run `jcode update` or `/reload` to update.");
|
||||
}
|
||||
} else {
|
||||
Bus::global().publish(BusEvent::UpdateStatus(UpdateStatus::UpToDate));
|
||||
}
|
||||
logging::info(&format!(
|
||||
"[TIMING] background_update_check: auto_update={}, total={}ms",
|
||||
auto_update,
|
||||
start.elapsed().as_millis()
|
||||
));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn should_spawn_background_update_check(args: &Args) -> bool {
|
||||
!args.quiet
|
||||
&& !args.no_update
|
||||
&& !matches!(
|
||||
args.command,
|
||||
Some(Command::Update) | Some(Command::Serve { .. }) | Some(Command::Acp)
|
||||
)
|
||||
&& args.resume.is_none()
|
||||
}
|
||||
|
||||
fn should_auto_install_update(args: &Args) -> bool {
|
||||
args.auto_update
|
||||
}
|
||||
|
||||
fn report_main_error(error: &anyhow::Error) {
|
||||
let error_str = format!("{:?}", error);
|
||||
logging::error(&error_str);
|
||||
|
||||
if let Some(session_id) = terminal::get_current_session() {
|
||||
output::stderr_blank_line();
|
||||
output::stderr_info("\x1b[33mTo restore this session, run:\x1b[0m");
|
||||
output::stderr_info(format!(" jcode --resume {}", session_id));
|
||||
output::stderr_blank_line();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::cli::args::{Args, Command};
|
||||
use clap::Parser;
|
||||
|
||||
fn parse_args(argv: &[&str]) -> Args {
|
||||
Args::parse_from(argv)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_install_allowed_without_live_terminal() {
|
||||
let args = parse_args(&["jcode", "login"]);
|
||||
assert!(should_auto_install_update(&args));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_install_allowed_with_live_terminal_attached() {
|
||||
let args = parse_args(&["jcode", "login"]);
|
||||
assert!(should_auto_install_update(&args));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_install_respects_explicit_disable_even_without_terminal() {
|
||||
let mut args = parse_args(&["jcode", "login"]);
|
||||
args.auto_update = false;
|
||||
assert!(!should_auto_install_update(&args));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remote_working_dir_validation_requires_absolute_path() {
|
||||
assert!(validate_remote_working_dir(Some("/home/agent/project")).is_ok());
|
||||
assert!(validate_remote_working_dir(Some("C:\\Users\\agent\\project")).is_ok());
|
||||
assert!(validate_remote_working_dir(None).is_ok());
|
||||
|
||||
let error = validate_remote_working_dir(Some("relative/project")).unwrap_err();
|
||||
assert!(
|
||||
error
|
||||
.to_string()
|
||||
.contains("--remote-working-dir must be an absolute path")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_command_still_skips_background_check_before_auto_install_logic() {
|
||||
let args = parse_args(&["jcode", "update"]);
|
||||
assert!(matches!(args.command, Some(Command::Update)));
|
||||
assert!(!should_spawn_background_update_check(&args));
|
||||
assert!(should_auto_install_update(&args));
|
||||
}
|
||||
#[test]
|
||||
fn external_provider_runtimes_register_and_instantiate() {
|
||||
register_external_provider_runtimes();
|
||||
for (key, expected_name) in [
|
||||
(crate::provider::external::GEMINI_RUNTIME, "gemini"),
|
||||
(crate::provider::external::CURSOR_RUNTIME, "cursor"),
|
||||
(
|
||||
crate::provider::external::ANTIGRAVITY_RUNTIME,
|
||||
"antigravity",
|
||||
),
|
||||
] {
|
||||
assert!(
|
||||
crate::provider::external::external_provider_registered(key),
|
||||
"{key} runtime should be registered"
|
||||
);
|
||||
let provider = crate::provider::external::instantiate_external_provider(key)
|
||||
.unwrap_or_else(|| panic!("{key} runtime factory should instantiate"));
|
||||
assert_eq!(provider.name(), expected_name);
|
||||
assert!(!provider.model().is_empty());
|
||||
}
|
||||
|
||||
// Copilot's factory is fallible (requires a GitHub token), so only
|
||||
// assert registration; instantiation legitimately returns None when no
|
||||
// Copilot credentials exist on the machine running the tests.
|
||||
assert!(crate::provider::external::external_provider_registered(
|
||||
crate::provider::external::COPILOT_RUNTIME
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,658 @@
|
||||
use anyhow::Result;
|
||||
use std::io::{self, IsTerminal, Write};
|
||||
use std::panic;
|
||||
|
||||
use crate::{id, session, telemetry, tui};
|
||||
|
||||
pub struct TuiRuntimeState {
|
||||
mouse_capture: bool,
|
||||
keyboard_enhanced: bool,
|
||||
focus_change: bool,
|
||||
}
|
||||
|
||||
const INHERITED_MODES_ENV: &str = "JCODE_TUI_INHERITED_MODES";
|
||||
const INHERITED_THEME_ENV: &str = "JCODE_TUI_INHERITED_THEME";
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
struct InheritedTerminalModes {
|
||||
mouse_capture: bool,
|
||||
keyboard_enhanced: bool,
|
||||
focus_change: bool,
|
||||
}
|
||||
|
||||
impl InheritedTerminalModes {
|
||||
fn encode(self) -> String {
|
||||
format!(
|
||||
"mouse={},keyboard={},focus={}",
|
||||
u8::from(self.mouse_capture),
|
||||
u8::from(self.keyboard_enhanced),
|
||||
u8::from(self.focus_change)
|
||||
)
|
||||
}
|
||||
|
||||
fn decode(value: &str) -> Option<Self> {
|
||||
let mut modes = Self {
|
||||
mouse_capture: false,
|
||||
keyboard_enhanced: false,
|
||||
focus_change: false,
|
||||
};
|
||||
let mut seen = 0u8;
|
||||
for field in value.split(',') {
|
||||
let (name, raw) = field.split_once('=')?;
|
||||
let enabled = match raw {
|
||||
"0" => false,
|
||||
"1" => true,
|
||||
_ => return None,
|
||||
};
|
||||
match name {
|
||||
"mouse" => {
|
||||
modes.mouse_capture = enabled;
|
||||
seen |= 1;
|
||||
}
|
||||
"keyboard" => {
|
||||
modes.keyboard_enhanced = enabled;
|
||||
seen |= 2;
|
||||
}
|
||||
"focus" => {
|
||||
modes.focus_change = enabled;
|
||||
seen |= 4;
|
||||
}
|
||||
_ => return None,
|
||||
}
|
||||
}
|
||||
(seen == 7).then_some(modes)
|
||||
}
|
||||
}
|
||||
|
||||
fn has_terminal_exec_handoff(
|
||||
is_resuming: bool,
|
||||
inherited_modes: Option<InheritedTerminalModes>,
|
||||
) -> bool {
|
||||
is_resuming && inherited_modes.is_some()
|
||||
}
|
||||
|
||||
/// RAII guard that guarantees the terminal is restored to a sane state when the
|
||||
/// TUI runtime ends, even if the run loop returns an error or unwinds via panic.
|
||||
///
|
||||
/// Without this guard, an error propagated by `?` (e.g. an I/O error from a
|
||||
/// `terminal.draw` call, or any other fallible step in the event loop) would
|
||||
/// skip the explicit `cleanup_tui_runtime` call and leave the terminal in raw
|
||||
/// mode / alternate screen. That manifests as a corrupted terminal after exit:
|
||||
/// typed input is invisible because echo and cooked mode were never restored
|
||||
/// (see issue #214).
|
||||
///
|
||||
/// The normal teardown path should call [`TuiRuntimeGuard::finish`] (or
|
||||
/// [`TuiRuntimeGuard::finish_for_run_result`]) which performs the restore and
|
||||
/// disarms the guard. If neither is called (error/panic path), `Drop` performs
|
||||
/// a best-effort full restore.
|
||||
pub struct TuiRuntimeGuard {
|
||||
state: TuiRuntimeState,
|
||||
armed: bool,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
thread_local! {
|
||||
/// Counts how many times the guard's `Drop` performed an emergency restore.
|
||||
/// Used by tests to verify the error/panic safety net fires exactly once.
|
||||
static GUARD_DROP_RESTORES: std::cell::Cell<u32> = const { std::cell::Cell::new(0) };
|
||||
}
|
||||
|
||||
impl TuiRuntimeGuard {
|
||||
fn new(state: TuiRuntimeState) -> Self {
|
||||
Self { state, armed: true }
|
||||
}
|
||||
|
||||
/// Normal teardown for the simple case: restore the terminal and disarm.
|
||||
pub fn finish(mut self, restore_terminal: bool) {
|
||||
cleanup_tui_runtime(&self.state, restore_terminal);
|
||||
self.armed = false;
|
||||
}
|
||||
|
||||
/// Normal teardown for the interactive client: restore unless we are about
|
||||
/// to exec a follow-up process (reload/rebuild/update), in which case the
|
||||
/// next process inherits the terminal modes.
|
||||
pub fn finish_for_run_result(mut self, run_result: &crate::tui::RunResult, extra_exec: bool) {
|
||||
if run_result_will_exec(run_result, extra_exec) {
|
||||
export_tui_exec_handoff(&self.state);
|
||||
}
|
||||
cleanup_tui_runtime_for_run_result(&self.state, run_result, extra_exec);
|
||||
self.armed = false;
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TuiRuntimeGuard {
|
||||
fn drop(&mut self) {
|
||||
if self.armed {
|
||||
// Reached only on an error/panic path that skipped explicit
|
||||
// teardown. Always perform a full restore so the user's terminal is
|
||||
// not left corrupted.
|
||||
cleanup_tui_runtime(&self.state, true);
|
||||
self.armed = false;
|
||||
#[cfg(test)]
|
||||
GUARD_DROP_RESTORES.with(|c| c.set(c.get() + 1));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_current_session(session_id: &str) {
|
||||
crate::set_current_session(session_id);
|
||||
}
|
||||
|
||||
pub fn get_current_session() -> Option<String> {
|
||||
crate::get_current_session()
|
||||
}
|
||||
|
||||
pub fn install_panic_hook() {
|
||||
let default_hook = panic::take_hook();
|
||||
panic::set_hook(Box::new(move |info| {
|
||||
default_hook(info);
|
||||
|
||||
if let Some(session_id) = get_current_session() {
|
||||
print_session_resume_hint(&session_id);
|
||||
|
||||
if let Some((provider, model)) = telemetry::current_provider_model() {
|
||||
telemetry::record_crash(&provider, &model, telemetry::SessionEndReason::Panic);
|
||||
}
|
||||
|
||||
if let Ok(mut session) = session::Session::load(&session_id) {
|
||||
session.mark_crashed(Some(format!("Panic: {}", info)));
|
||||
let _ = session.save();
|
||||
}
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
pub fn mark_current_session_crashed(message: String) {
|
||||
if let Some(session_id) = get_current_session() {
|
||||
if let Some((provider, model)) = telemetry::current_provider_model() {
|
||||
telemetry::record_crash(&provider, &model, telemetry::SessionEndReason::Signal);
|
||||
}
|
||||
if let Ok(mut session) = session::Session::load(&session_id)
|
||||
&& matches!(session.status, session::SessionStatus::Active)
|
||||
{
|
||||
session.mark_crashed(Some(message));
|
||||
let _ = session.save();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn panic_payload_to_string(payload: &(dyn std::any::Any + Send)) -> String {
|
||||
if let Some(s) = payload.downcast_ref::<&str>() {
|
||||
(*s).to_string()
|
||||
} else if let Some(s) = payload.downcast_ref::<String>() {
|
||||
s.clone()
|
||||
} else {
|
||||
"unknown panic payload".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn show_crash_resume_hint() {
|
||||
let crashed = session::find_recent_crashed_sessions();
|
||||
if crashed.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let (id, name) = &crashed[0];
|
||||
let session_label = id::extract_session_name(id).unwrap_or(name.as_str());
|
||||
|
||||
if crashed.len() == 1 {
|
||||
eprintln!(
|
||||
"\x1b[33m💥 Session \x1b[1m{}\x1b[0m\x1b[33m crashed. Resume with:\x1b[0m jcode --resume {}",
|
||||
session_label, id
|
||||
);
|
||||
} else {
|
||||
eprintln!(
|
||||
"\x1b[33m💥 {} sessions crashed recently. Most recent: \x1b[1m{}\x1b[0m",
|
||||
crashed.len(),
|
||||
session_label
|
||||
);
|
||||
eprintln!("\x1b[33m Resume with:\x1b[0m jcode --resume {}", id);
|
||||
eprintln!("\x1b[33m List all:\x1b[0m jcode --resume");
|
||||
}
|
||||
eprintln!();
|
||||
}
|
||||
|
||||
fn init_tui_terminal(inherited_terminal: bool) -> Result<ratatui::DefaultTerminal> {
|
||||
if !io::stdin().is_terminal() || !io::stdout().is_terminal() {
|
||||
anyhow::bail!("jcode TUI requires an interactive terminal (stdin/stdout must be a TTY)");
|
||||
}
|
||||
if inherited_terminal {
|
||||
init_tui_terminal_resume()
|
||||
} else {
|
||||
std::panic::catch_unwind(std::panic::AssertUnwindSafe(ratatui::init)).map_err(|payload| {
|
||||
anyhow::anyhow!(
|
||||
"failed to initialize terminal: {}",
|
||||
panic_payload_to_string(payload.as_ref())
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub fn init_tui_runtime() -> Result<(ratatui::DefaultTerminal, TuiRuntimeGuard)> {
|
||||
let is_resuming = std::env::var_os("JCODE_RESUMING").is_some();
|
||||
let inherited_theme = std::env::var(INHERITED_THEME_ENV).ok();
|
||||
let inherited_modes_raw = std::env::var(INHERITED_MODES_ENV).ok();
|
||||
let inherited_modes = inherited_modes_raw
|
||||
.as_deref()
|
||||
.and_then(InheritedTerminalModes::decode);
|
||||
// JCODE_RESUMING describes the session lifecycle, but only a valid modes
|
||||
// handoff proves the previous process deliberately left the terminal live
|
||||
// across exec. A restart used to restore the terminal before exec while the
|
||||
// new process still took the resume path, leaving it on the primary screen
|
||||
// without mouse capture.
|
||||
let inherited_terminal = has_terminal_exec_handoff(is_resuming, inherited_modes);
|
||||
if inherited_terminal {
|
||||
// OSC terminal queries are unsafe here because the previous process
|
||||
// deliberately exec'd without leaving raw mode or the alternate screen.
|
||||
crate::tui::theme_detect::init_theme_mode_for_resume(inherited_theme.as_deref());
|
||||
} else {
|
||||
// The OSC 11 query needs the cooked terminal and must happen before init.
|
||||
crate::tui::theme_detect::init_theme_mode();
|
||||
}
|
||||
let terminal = init_tui_terminal(inherited_terminal)?;
|
||||
crate::tui::mermaid::install_jcode_mermaid_hooks();
|
||||
crate::tui::markdown::install_jcode_markdown_hooks();
|
||||
crate::tui::mermaid::init_picker();
|
||||
|
||||
let perf_policy = crate::perf::tui_policy();
|
||||
// These private handoff values apply only to this exec boundary. Avoid
|
||||
// leaking them into tools or unrelated child jcode processes.
|
||||
crate::env::remove_var(INHERITED_MODES_ENV);
|
||||
crate::env::remove_var(INHERITED_THEME_ENV);
|
||||
|
||||
let fallback_modes = InheritedTerminalModes {
|
||||
mouse_capture: perf_policy.enable_mouse_capture,
|
||||
keyboard_enhanced: perf_policy.enable_keyboard_enhancement,
|
||||
focus_change: perf_policy.enable_focus_change,
|
||||
};
|
||||
let modes = if inherited_terminal {
|
||||
// The previous process intentionally preserved these modes across exec.
|
||||
// Reassert idempotent modes because terminals, multiplexers, or an older
|
||||
// process may have cleared them during the handoff. Do not push Kitty's
|
||||
// stack-based keyboard enhancement flags again. A later normal exit must
|
||||
// still disable every inherited mode, so retain them in the guard.
|
||||
let modes = inherited_modes.unwrap_or(fallback_modes);
|
||||
crossterm::execute!(std::io::stdout(), crossterm::event::EnableBracketedPaste)?;
|
||||
if modes.focus_change {
|
||||
crossterm::execute!(std::io::stdout(), crossterm::event::EnableFocusChange)?;
|
||||
}
|
||||
if modes.mouse_capture {
|
||||
crossterm::execute!(std::io::stdout(), crossterm::event::EnableMouseCapture)?;
|
||||
}
|
||||
modes
|
||||
} else {
|
||||
let keyboard_enhanced = if perf_policy.enable_keyboard_enhancement {
|
||||
tui::enable_keyboard_enhancement()
|
||||
} else {
|
||||
false
|
||||
};
|
||||
let modes = InheritedTerminalModes {
|
||||
mouse_capture: perf_policy.enable_mouse_capture,
|
||||
keyboard_enhanced,
|
||||
focus_change: perf_policy.enable_focus_change,
|
||||
};
|
||||
crossterm::execute!(std::io::stdout(), crossterm::event::EnableBracketedPaste)?;
|
||||
if modes.focus_change {
|
||||
crossterm::execute!(std::io::stdout(), crossterm::event::EnableFocusChange)?;
|
||||
}
|
||||
if modes.mouse_capture {
|
||||
crossterm::execute!(std::io::stdout(), crossterm::event::EnableMouseCapture)?;
|
||||
}
|
||||
modes
|
||||
};
|
||||
|
||||
crate::logging::info(&format!(
|
||||
"EVENT event=TUI_TERMINAL_MODES phase=initialized pid={} resuming={} handoff={} handoff_raw={} raw_mode={} mouse_capture={} keyboard_enhanced={} focus_change={} idempotent_modes_reasserted={}",
|
||||
std::process::id(),
|
||||
is_resuming,
|
||||
inherited_terminal,
|
||||
inherited_modes_raw.as_deref().unwrap_or("none"),
|
||||
crossterm::terminal::is_raw_mode_enabled().unwrap_or(false),
|
||||
modes.mouse_capture,
|
||||
modes.keyboard_enhanced,
|
||||
modes.focus_change,
|
||||
inherited_terminal,
|
||||
));
|
||||
|
||||
Ok((
|
||||
terminal,
|
||||
TuiRuntimeGuard::new(TuiRuntimeState {
|
||||
mouse_capture: modes.mouse_capture,
|
||||
keyboard_enhanced: modes.keyboard_enhanced,
|
||||
focus_change: modes.focus_change,
|
||||
}),
|
||||
))
|
||||
}
|
||||
|
||||
fn cleanup_tui_runtime(state: &TuiRuntimeState, restore_terminal: bool) {
|
||||
crate::logging::info(&format!(
|
||||
"EVENT event=TUI_TERMINAL_MODES phase=cleanup pid={} restore_terminal={} raw_mode={} mouse_capture={} keyboard_enhanced={} focus_change={}",
|
||||
std::process::id(),
|
||||
restore_terminal,
|
||||
crossterm::terminal::is_raw_mode_enabled().unwrap_or(false),
|
||||
state.mouse_capture,
|
||||
state.keyboard_enhanced,
|
||||
state.focus_change,
|
||||
));
|
||||
if restore_terminal {
|
||||
let _ = crossterm::execute!(std::io::stdout(), crossterm::event::DisableBracketedPaste);
|
||||
if state.focus_change {
|
||||
let _ = crossterm::execute!(std::io::stdout(), crossterm::event::DisableFocusChange);
|
||||
}
|
||||
if state.mouse_capture {
|
||||
let _ = crossterm::execute!(std::io::stdout(), crossterm::event::DisableMouseCapture);
|
||||
}
|
||||
if state.keyboard_enhanced {
|
||||
tui::disable_keyboard_enhancement();
|
||||
}
|
||||
ratatui::restore();
|
||||
}
|
||||
|
||||
crate::tui::mermaid::clear_image_state();
|
||||
}
|
||||
|
||||
fn cleanup_tui_runtime_for_run_result(
|
||||
state: &TuiRuntimeState,
|
||||
run_result: &crate::tui::RunResult,
|
||||
extra_exec: bool,
|
||||
) {
|
||||
cleanup_tui_runtime(state, !run_result_will_exec(run_result, extra_exec));
|
||||
}
|
||||
|
||||
fn run_result_will_exec(run_result: &crate::tui::RunResult, extra_exec: bool) -> bool {
|
||||
extra_exec
|
||||
|| run_result.reload_session.is_some()
|
||||
|| run_result.rebuild_session.is_some()
|
||||
|| run_result.update_session.is_some()
|
||||
|| run_result.restart_session.is_some()
|
||||
}
|
||||
|
||||
fn export_tui_exec_handoff(state: &TuiRuntimeState) {
|
||||
let modes = InheritedTerminalModes {
|
||||
mouse_capture: state.mouse_capture,
|
||||
keyboard_enhanced: state.keyboard_enhanced,
|
||||
focus_change: state.focus_change,
|
||||
};
|
||||
crate::env::set_var(INHERITED_MODES_ENV, modes.encode());
|
||||
let theme = crate::tui::theme_detect::current_theme_label();
|
||||
crate::env::set_var(INHERITED_THEME_ENV, theme);
|
||||
crate::logging::info(&format!(
|
||||
"EVENT event=TUI_TERMINAL_MODES phase=exec_handoff pid={} raw_mode={} modes={} theme={}",
|
||||
std::process::id(),
|
||||
crossterm::terminal::is_raw_mode_enabled().unwrap_or(false),
|
||||
modes.encode(),
|
||||
theme,
|
||||
));
|
||||
}
|
||||
|
||||
pub fn print_session_resume_hint(session_id: &str) {
|
||||
let _ = write_session_resume_hint(io::stderr().lock(), session_id);
|
||||
}
|
||||
|
||||
fn write_session_resume_hint(mut writer: impl Write, session_id: &str) -> io::Result<()> {
|
||||
let session_name = id::extract_session_name(session_id).unwrap_or(session_id);
|
||||
writeln!(writer)?;
|
||||
writeln!(
|
||||
writer,
|
||||
"\x1b[33mSession \x1b[1m{}\x1b[0m\x1b[33m - to resume:\x1b[0m",
|
||||
session_name
|
||||
)?;
|
||||
writeln!(writer, " jcode --resume {}", session_id)?;
|
||||
writeln!(writer)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn init_tui_terminal_resume() -> Result<ratatui::DefaultTerminal> {
|
||||
use ratatui::{Terminal, backend::CrosstermBackend};
|
||||
|
||||
crossterm::terminal::enable_raw_mode()
|
||||
.map_err(|e| anyhow::anyhow!("failed to enable raw mode on resume: {}", e))?;
|
||||
|
||||
let backend = CrosstermBackend::new(io::stdout());
|
||||
let mut terminal = Terminal::new(backend)
|
||||
.map_err(|e| anyhow::anyhow!("failed to create terminal on resume: {}", e))?;
|
||||
|
||||
terminal
|
||||
.clear()
|
||||
.map_err(|e| anyhow::anyhow!("failed to clear terminal on resume: {}", e))?;
|
||||
|
||||
Ok(terminal)
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
pub fn signal_name(sig: i32) -> &'static str {
|
||||
match sig {
|
||||
1 => "SIGHUP",
|
||||
2 => "SIGINT",
|
||||
3 => "SIGQUIT",
|
||||
4 => "SIGILL",
|
||||
6 => "SIGABRT",
|
||||
9 => "SIGKILL",
|
||||
11 => "SIGSEGV",
|
||||
13 => "SIGPIPE",
|
||||
14 => "SIGALRM",
|
||||
15 => "SIGTERM",
|
||||
_ => "unknown",
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
pub fn signal_name(_sig: i32) -> &'static str {
|
||||
"unknown"
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn signal_crash_reason(sig: i32) -> String {
|
||||
match sig {
|
||||
libc::SIGHUP => "Terminal or window closed (SIGHUP)".to_string(),
|
||||
libc::SIGTERM => "Terminated (SIGTERM)".to_string(),
|
||||
libc::SIGINT => "Interrupted (SIGINT)".to_string(),
|
||||
libc::SIGQUIT => "Quit signal (SIGQUIT)".to_string(),
|
||||
_ => format!("Terminated by signal {} ({})", signal_name(sig), sig),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn handle_termination_signal(sig: i32) -> ! {
|
||||
mark_current_session_crashed(signal_crash_reason(sig));
|
||||
|
||||
let _ = crossterm::terminal::disable_raw_mode();
|
||||
let _ = crossterm::execute!(
|
||||
std::io::stderr(),
|
||||
crossterm::terminal::LeaveAlternateScreen,
|
||||
crossterm::cursor::Show
|
||||
);
|
||||
|
||||
if let Some(session_id) = get_current_session() {
|
||||
print_session_resume_hint(&session_id);
|
||||
}
|
||||
|
||||
std::process::exit(128 + sig);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
pub fn spawn_session_signal_watchers() {
|
||||
use tokio::signal::unix::{SignalKind, signal};
|
||||
|
||||
fn spawn_one(sig: i32, kind: SignalKind) {
|
||||
tokio::spawn(async move {
|
||||
let mut stream = match signal(kind) {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
crate::logging::error(&format!(
|
||||
"Failed to install {} handler: {}",
|
||||
signal_name(sig),
|
||||
e
|
||||
));
|
||||
return;
|
||||
}
|
||||
};
|
||||
if stream.recv().await.is_some() {
|
||||
crate::logging::info(&format!("Received {} in TUI process", signal_name(sig)));
|
||||
handle_termination_signal(sig);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
spawn_one(libc::SIGHUP, SignalKind::hangup());
|
||||
spawn_one(libc::SIGTERM, SignalKind::terminate());
|
||||
spawn_one(libc::SIGINT, SignalKind::interrupt());
|
||||
spawn_one(libc::SIGQUIT, SignalKind::quit());
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
pub fn spawn_session_signal_watchers() {}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::Mutex;
|
||||
|
||||
static TEST_SESSION_LOCK: Mutex<()> = Mutex::new(());
|
||||
|
||||
fn test_guard() -> TuiRuntimeGuard {
|
||||
// All terminal-mode flags disabled so teardown only performs the minimal
|
||||
// (and TTY-safe) restore path during tests.
|
||||
TuiRuntimeGuard::new(TuiRuntimeState {
|
||||
mouse_capture: false,
|
||||
keyboard_enhanced: false,
|
||||
focus_change: false,
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inherited_terminal_modes_roundtrip() {
|
||||
let modes = InheritedTerminalModes {
|
||||
mouse_capture: true,
|
||||
keyboard_enhanced: false,
|
||||
focus_change: true,
|
||||
};
|
||||
assert_eq!(InheritedTerminalModes::decode(&modes.encode()), Some(modes));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inherited_terminal_modes_reject_malformed_values() {
|
||||
assert_eq!(InheritedTerminalModes::decode("mouse=1,keyboard=1"), None);
|
||||
assert_eq!(
|
||||
InheritedTerminalModes::decode("mouse=yes,keyboard=1,focus=1"),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resume_requires_valid_terminal_handoff_metadata() {
|
||||
let modes = InheritedTerminalModes {
|
||||
mouse_capture: true,
|
||||
keyboard_enhanced: true,
|
||||
focus_change: true,
|
||||
};
|
||||
assert!(has_terminal_exec_handoff(true, Some(modes)));
|
||||
assert!(!has_terminal_exec_handoff(true, None));
|
||||
assert!(!has_terminal_exec_handoff(false, Some(modes)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_exec_action_preserves_terminal_modes() {
|
||||
let with = |field: &str| {
|
||||
let mut result = crate::tui::RunResult::default();
|
||||
match field {
|
||||
"reload" => result.reload_session = Some("session_test".into()),
|
||||
"rebuild" => result.rebuild_session = Some("session_test".into()),
|
||||
"update" => result.update_session = Some("session_test".into()),
|
||||
"restart" => result.restart_session = Some("session_test".into()),
|
||||
_ => unreachable!(),
|
||||
}
|
||||
result
|
||||
};
|
||||
|
||||
for field in ["reload", "rebuild", "update", "restart"] {
|
||||
assert!(
|
||||
run_result_will_exec(&with(field), false),
|
||||
"{field} must preserve terminal modes across exec"
|
||||
);
|
||||
}
|
||||
assert!(run_result_will_exec(
|
||||
&crate::tui::RunResult::default(),
|
||||
true
|
||||
));
|
||||
assert!(!run_result_will_exec(
|
||||
&crate::tui::RunResult::default(),
|
||||
false
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn guard_drop_restores_terminal_when_not_finished() {
|
||||
// Simulates the error/panic path where explicit teardown is skipped:
|
||||
// the guard must restore the terminal exactly once on drop (issue #214).
|
||||
GUARD_DROP_RESTORES.with(|c| c.set(0));
|
||||
{
|
||||
let _guard = test_guard();
|
||||
}
|
||||
let restores = GUARD_DROP_RESTORES.with(|c| c.get());
|
||||
assert_eq!(
|
||||
restores, 1,
|
||||
"dropping an un-finished guard must restore the terminal once"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn guard_finish_disarms_drop_restore() {
|
||||
// The happy path calls finish(); the drop safety net must NOT fire again.
|
||||
GUARD_DROP_RESTORES.with(|c| c.set(0));
|
||||
let guard = test_guard();
|
||||
guard.finish(true);
|
||||
let restores = GUARD_DROP_RESTORES.with(|c| c.get());
|
||||
assert_eq!(
|
||||
restores, 0,
|
||||
"finish() should disarm the guard so drop does not double-restore"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_session_recovery_tracking() {
|
||||
let _guard = TEST_SESSION_LOCK.lock().unwrap();
|
||||
set_current_session("test_session_123");
|
||||
|
||||
let stored = get_current_session();
|
||||
assert_eq!(stored.as_deref(), Some("test_session_123"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_session_recovery_message_format() {
|
||||
let _guard = TEST_SESSION_LOCK.lock().unwrap();
|
||||
let test_session = "session_format_test_12345";
|
||||
set_current_session(test_session);
|
||||
|
||||
if let Some(session_id) = get_current_session() {
|
||||
let mut output = Vec::new();
|
||||
write_session_resume_hint(&mut output, &session_id).unwrap();
|
||||
let output = String::from_utf8(output).unwrap();
|
||||
let expected_cmd = format!("jcode --resume {}", session_id);
|
||||
assert!(output.contains(&expected_cmd));
|
||||
assert!(output.contains("to resume"));
|
||||
assert!(!session_id.is_empty());
|
||||
} else {
|
||||
panic!("Session ID should be set");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_resume_hint_writer_reports_closed_stderr_without_panicking() {
|
||||
struct ClosedWriter;
|
||||
|
||||
impl Write for ClosedWriter {
|
||||
fn write(&mut self, _buf: &[u8]) -> io::Result<usize> {
|
||||
Err(io::Error::new(io::ErrorKind::BrokenPipe, "stderr closed"))
|
||||
}
|
||||
|
||||
fn flush(&mut self) -> io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
let error = write_session_resume_hint(ClosedWriter, "session_closed_pipe")
|
||||
.expect_err("closed stderr should be reported as an I/O error");
|
||||
assert_eq!(error.kind(), io::ErrorKind::BrokenPipe);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,711 @@
|
||||
#![cfg_attr(test, allow(clippy::await_holding_lock))]
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
|
||||
const MAX_INTERACTIVE_SWARM_REPLAY_PANES: usize = 16;
|
||||
use std::io::{self, Write};
|
||||
use std::process::Command as ProcessCommand;
|
||||
|
||||
use crate::{
|
||||
id, logging, replay, server, session, setup_hints, startup_profile, tui, video_export,
|
||||
};
|
||||
|
||||
use super::hot_exec::{execute_requested_action, has_requested_action};
|
||||
|
||||
use super::terminal::{
|
||||
init_tui_runtime, print_session_resume_hint, set_current_session, spawn_session_signal_watchers,
|
||||
};
|
||||
|
||||
pub(crate) use crate::session_launch::resumed_window_title;
|
||||
|
||||
pub async fn run_client() -> Result<()> {
|
||||
let mut client = server::Client::connect().await?;
|
||||
|
||||
if !client.ping().await? {
|
||||
anyhow::bail!("Failed to ping server");
|
||||
}
|
||||
|
||||
println!("Connected to J-Code server");
|
||||
println!("Type your message, or 'quit' to exit.\n");
|
||||
|
||||
loop {
|
||||
print!("> ");
|
||||
io::stdout().flush()?;
|
||||
|
||||
let mut input = String::new();
|
||||
io::stdin().read_line(&mut input)?;
|
||||
|
||||
let input = input.trim();
|
||||
if input.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
if input == "quit" || input == "exit" {
|
||||
break;
|
||||
}
|
||||
|
||||
match client.send_message(input).await {
|
||||
Ok(msg_id) => loop {
|
||||
match client.read_event().await {
|
||||
Ok(event) => {
|
||||
use crate::protocol::ServerEvent;
|
||||
match event {
|
||||
ServerEvent::TextDelta { text } => {
|
||||
print!("{}", text);
|
||||
std::io::stdout().flush()?;
|
||||
}
|
||||
ServerEvent::Done { id } if id == msg_id => {
|
||||
break;
|
||||
}
|
||||
ServerEvent::Error { message, .. } => {
|
||||
eprintln!("Error: {}", message);
|
||||
break;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Event error: {}", e);
|
||||
break;
|
||||
}
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
eprintln!("Error: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
println!();
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn run_tui_client(
|
||||
resume_session: Option<String>,
|
||||
startup_hints: Option<setup_hints::StartupHints>,
|
||||
server_spawning: bool,
|
||||
fresh_spawn: bool,
|
||||
remote_working_dir: Option<String>,
|
||||
) -> Result<()> {
|
||||
startup_profile::mark("tui_client_enter");
|
||||
let (terminal, tui_runtime) = init_tui_runtime()?;
|
||||
startup_profile::mark("tui_terminal_init");
|
||||
startup_profile::mark("mermaid_picker");
|
||||
startup_profile::mark("config_load");
|
||||
startup_profile::mark("keyboard_enhancement");
|
||||
startup_profile::mark("terminal_modes");
|
||||
|
||||
if let Some(ref session_id) = resume_session {
|
||||
set_current_session(session_id);
|
||||
}
|
||||
spawn_session_signal_watchers();
|
||||
|
||||
if let Some(ref session_id) = resume_session {
|
||||
let session_name = id::extract_session_name(session_id)
|
||||
.map(|s| s.to_string())
|
||||
.unwrap_or_else(|| session_id.clone());
|
||||
let is_selfdev = super::selfdev::client_selfdev_requested();
|
||||
if let Some(server_info) =
|
||||
crate::registry::find_server_by_socket_sync(&server::socket_path())
|
||||
{
|
||||
crate::process_title::set_client_remote_display_title(
|
||||
&server_info.name,
|
||||
&session_name,
|
||||
is_selfdev,
|
||||
);
|
||||
} else {
|
||||
crate::process_title::set_client_display_title(&session_name, is_selfdev);
|
||||
}
|
||||
let _ = crossterm::execute!(
|
||||
std::io::stdout(),
|
||||
crossterm::terminal::SetTitle(resumed_window_title(session_id))
|
||||
);
|
||||
} else {
|
||||
crate::process_title::set_client_generic_title(super::selfdev::client_selfdev_requested());
|
||||
let _ = crossterm::execute!(std::io::stdout(), crossterm::terminal::SetTitle("jcode"));
|
||||
}
|
||||
startup_profile::mark("terminal_title");
|
||||
|
||||
let mut app = tui::App::new_for_remote_with_options(resume_session.clone(), fresh_spawn);
|
||||
if should_show_server_spawning(server_spawning).await {
|
||||
app.set_server_spawning();
|
||||
}
|
||||
startup_profile::mark("app_new_for_remote");
|
||||
if resume_session.is_none()
|
||||
&& let Some(hints) = startup_hints
|
||||
{
|
||||
apply_startup_hints(&mut app, hints);
|
||||
}
|
||||
|
||||
startup_profile::mark("pre_run_remote");
|
||||
startup_profile::report_to_log();
|
||||
|
||||
let result = app.run_remote(terminal, remote_working_dir).await;
|
||||
|
||||
// On the error path, `?` returns here while `tui_runtime` is still alive, so
|
||||
// its `Drop` guarantees the terminal is restored (issue #214). On the happy
|
||||
// path we hand the run result to the guard so it can skip the restore when
|
||||
// we are about to exec a follow-up process.
|
||||
let run_result = result?;
|
||||
|
||||
tui_runtime.finish_for_run_result(&run_result, false);
|
||||
|
||||
if let Some(code) = run_result.exit_code {
|
||||
std::process::exit(code);
|
||||
}
|
||||
|
||||
execute_requested_action(&run_result)?;
|
||||
|
||||
if !has_requested_action(&run_result)
|
||||
&& let Some(ref session_id) = run_result.session_id
|
||||
{
|
||||
print_session_resume_hint(session_id);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn should_show_server_spawning(server_spawning: bool) -> bool {
|
||||
if !server_spawning {
|
||||
return false;
|
||||
}
|
||||
|
||||
let socket_path = server::socket_path();
|
||||
if server::has_live_listener(&socket_path).await {
|
||||
logging::info(&format!(
|
||||
"Skipping stale startup phase: server already listening at {}",
|
||||
socket_path.display()
|
||||
));
|
||||
return false;
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
fn apply_startup_hints(app: &mut tui::App, hints: setup_hints::StartupHints) {
|
||||
if let Some(status_notice) = hints.status_notice {
|
||||
app.set_status_notice(status_notice);
|
||||
}
|
||||
if let Some((title, message)) = hints.display_message {
|
||||
// Stash the card so it survives the remote History bootstrap, which
|
||||
// clears the transcript for a brand-new session and would otherwise make
|
||||
// the hint flash for a moment and then disappear on the idle screen.
|
||||
app.set_pending_startup_notice(title, message);
|
||||
}
|
||||
if let Some(message) = hints.auto_send_message {
|
||||
app.queue_startup_message(message);
|
||||
}
|
||||
}
|
||||
|
||||
#[expect(
|
||||
clippy::too_many_arguments,
|
||||
reason = "Replay command maps directly from CLI flags and transport options"
|
||||
)]
|
||||
pub async fn run_replay_command(
|
||||
session_id_or_path: &str,
|
||||
swarm: bool,
|
||||
export: bool,
|
||||
auto_edit: bool,
|
||||
speed: f64,
|
||||
timeline_path: Option<&str>,
|
||||
video_output: Option<&str>,
|
||||
cols: u16,
|
||||
rows: u16,
|
||||
fps: u32,
|
||||
centered_override: Option<bool>,
|
||||
) -> Result<()> {
|
||||
if swarm {
|
||||
let swarm_sessions = replay::load_swarm_sessions(session_id_or_path, auto_edit)?;
|
||||
if export {
|
||||
let timelines: Vec<_> = swarm_sessions
|
||||
.iter()
|
||||
.map(|pane| {
|
||||
serde_json::json!({
|
||||
"session_id": pane.session.id,
|
||||
"session_name": pane.session.short_name,
|
||||
"timeline": pane.timeline,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
println!("{}", serde_json::to_string_pretty(&timelines)?);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if let Some(output) = video_output {
|
||||
let output_path = if output == "auto" {
|
||||
let date = chrono::Local::now().format("%Y%m%d_%H%M%S");
|
||||
let safe_name = session_id_or_path
|
||||
.chars()
|
||||
.map(|c| {
|
||||
if c.is_alphanumeric() || c == '-' || c == '_' {
|
||||
c
|
||||
} else {
|
||||
'_'
|
||||
}
|
||||
})
|
||||
.collect::<String>();
|
||||
std::path::PathBuf::from(format!("jcode_swarm_replay_{}_{}.mp4", safe_name, date))
|
||||
} else {
|
||||
std::path::PathBuf::from(output)
|
||||
};
|
||||
let panes: Vec<_> = swarm_sessions
|
||||
.into_iter()
|
||||
.map(|pane| replay::PaneReplayInput {
|
||||
session: pane.session,
|
||||
timeline: pane.timeline,
|
||||
})
|
||||
.collect();
|
||||
eprintln!(
|
||||
"🐝 Exporting swarm replay from seed {} ({} panes)",
|
||||
session_id_or_path,
|
||||
panes.len()
|
||||
);
|
||||
video_export::export_swarm_video(
|
||||
&panes,
|
||||
speed,
|
||||
&output_path,
|
||||
cols,
|
||||
rows,
|
||||
fps,
|
||||
centered_override,
|
||||
)
|
||||
.await?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let mut replayable_panes: Vec<_> = swarm_sessions
|
||||
.into_iter()
|
||||
.filter(|pane| !pane.timeline.is_empty())
|
||||
.map(|pane| replay::PaneReplayInput {
|
||||
session: pane.session,
|
||||
timeline: pane.timeline,
|
||||
})
|
||||
.collect();
|
||||
|
||||
if replayable_panes.is_empty() {
|
||||
eprintln!("Swarm has no messages to replay.");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let total_panes = replayable_panes.len();
|
||||
if replayable_panes.len() > MAX_INTERACTIVE_SWARM_REPLAY_PANES {
|
||||
replayable_panes.truncate(MAX_INTERACTIVE_SWARM_REPLAY_PANES);
|
||||
eprintln!(
|
||||
" Limiting interactive swarm replay to {} panes ({} discovered). Use --export/--video for the full set.",
|
||||
replayable_panes.len(),
|
||||
total_panes,
|
||||
);
|
||||
}
|
||||
|
||||
let pane_count = replayable_panes.len();
|
||||
eprintln!(
|
||||
"🐝 Replaying swarm: {} ({} panes, {:.1}x speed)",
|
||||
session_id_or_path, pane_count, speed
|
||||
);
|
||||
eprintln!(" Controls: Space=pause +/-=speed q=quit\n");
|
||||
|
||||
let (terminal, tui_runtime) = init_tui_runtime()?;
|
||||
let _ = crossterm::execute!(
|
||||
std::io::stdout(),
|
||||
crossterm::terminal::SetTitle(format!("🐝 swarm replay: {}", session_id_or_path))
|
||||
);
|
||||
|
||||
let result =
|
||||
tui::App::run_swarm_replay(terminal, replayable_panes, speed, centered_override).await;
|
||||
|
||||
tui_runtime.finish(true);
|
||||
result?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let session = replay::load_session(session_id_or_path)?;
|
||||
|
||||
let mut timeline = if let Some(path) = timeline_path {
|
||||
let data = std::fs::read_to_string(path)
|
||||
.with_context(|| format!("Failed to read timeline file: {}", path))?;
|
||||
serde_json::from_str::<Vec<replay::TimelineEvent>>(&data)
|
||||
.with_context(|| format!("Failed to parse timeline JSON: {}", path))?
|
||||
} else {
|
||||
replay::export_timeline(&session)
|
||||
};
|
||||
|
||||
if auto_edit {
|
||||
timeline = replay::auto_edit_timeline(&timeline, &replay::AutoEditOpts::default());
|
||||
}
|
||||
|
||||
if export {
|
||||
let json = serde_json::to_string_pretty(&timeline)?;
|
||||
println!("{}", json);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if timeline.is_empty() {
|
||||
eprintln!("Session has no messages to replay.");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let session_name = session.short_name.as_deref().unwrap_or(&session.id);
|
||||
let icon = id::session_icon(session_name);
|
||||
|
||||
if let Some(output) = video_output {
|
||||
let output_path = if output == "auto" {
|
||||
let date = chrono::Local::now().format("%Y%m%d_%H%M%S");
|
||||
let safe_name = session_name
|
||||
.chars()
|
||||
.map(|c| {
|
||||
if c.is_alphanumeric() || c == '-' || c == '_' {
|
||||
c
|
||||
} else {
|
||||
'_'
|
||||
}
|
||||
})
|
||||
.collect::<String>();
|
||||
std::path::PathBuf::from(format!("jcode_replay_{}_{}.mp4", safe_name, date))
|
||||
} else {
|
||||
std::path::PathBuf::from(output)
|
||||
};
|
||||
eprintln!(
|
||||
"{} Exporting session: {} ({} events)",
|
||||
icon,
|
||||
session_name,
|
||||
timeline.len()
|
||||
);
|
||||
video_export::export_video(
|
||||
&session,
|
||||
&timeline,
|
||||
speed,
|
||||
&output_path,
|
||||
cols,
|
||||
rows,
|
||||
fps,
|
||||
centered_override,
|
||||
)
|
||||
.await?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
eprintln!(
|
||||
"{} Replaying session: {} ({} events, {:.1}x speed)",
|
||||
icon,
|
||||
session_name,
|
||||
timeline.len(),
|
||||
speed
|
||||
);
|
||||
eprintln!(" Controls: Space=pause +/-=speed q=quit\n");
|
||||
|
||||
let (terminal, tui_runtime) = init_tui_runtime()?;
|
||||
|
||||
let _ = crossterm::execute!(
|
||||
std::io::stdout(),
|
||||
crossterm::terminal::SetTitle(format!("{} replay: {}", icon, session_name))
|
||||
);
|
||||
|
||||
let mut app = tui::App::new_for_replay(session);
|
||||
if let Some(centered) = centered_override {
|
||||
app.set_centered(centered);
|
||||
}
|
||||
let result = app.run_replay(terminal, timeline, speed).await;
|
||||
|
||||
tui_runtime.finish(true);
|
||||
|
||||
result?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Session-launching helpers live in the core `session_launch` module so that
|
||||
// lower layers (server, restart_snapshot, tool) can relaunch sessions without
|
||||
// depending on `cli`. Re-exported here for the CLI's own callers.
|
||||
pub use crate::session_launch::{
|
||||
spawn_resume_in_new_terminal, spawn_resume_in_new_terminal_with_provider,
|
||||
spawn_selfdev_in_new_terminal, spawn_selfdev_in_new_terminal_with_provider,
|
||||
};
|
||||
|
||||
pub fn list_sessions() -> Result<()> {
|
||||
fn build_resume_target_command(
|
||||
exe: &std::path::Path,
|
||||
target: &jcode_tui_session_picker::ResumeTarget,
|
||||
) -> (std::path::PathBuf, Vec<String>) {
|
||||
match target {
|
||||
jcode_tui_session_picker::ResumeTarget::JcodeSession { session_id } => (
|
||||
exe.to_path_buf(),
|
||||
vec!["--resume".to_string(), session_id.clone()],
|
||||
),
|
||||
jcode_tui_session_picker::ResumeTarget::ClaudeCodeSession { session_id, .. } => (
|
||||
exe.to_path_buf(),
|
||||
vec![
|
||||
"--resume".to_string(),
|
||||
crate::import::imported_claude_code_session_id(session_id),
|
||||
],
|
||||
),
|
||||
jcode_tui_session_picker::ResumeTarget::CodexSession { session_id, .. } => (
|
||||
exe.to_path_buf(),
|
||||
vec![
|
||||
"--resume".to_string(),
|
||||
crate::import::imported_codex_session_id(session_id),
|
||||
],
|
||||
),
|
||||
jcode_tui_session_picker::ResumeTarget::PiSession { session_path } => (
|
||||
exe.to_path_buf(),
|
||||
vec![
|
||||
"--resume".to_string(),
|
||||
crate::import::imported_pi_session_id(session_path),
|
||||
],
|
||||
),
|
||||
jcode_tui_session_picker::ResumeTarget::OpenCodeSession { session_id, .. } => (
|
||||
exe.to_path_buf(),
|
||||
vec![
|
||||
"--resume".to_string(),
|
||||
crate::import::imported_opencode_session_id(session_id),
|
||||
],
|
||||
),
|
||||
jcode_tui_session_picker::ResumeTarget::CursorSession { session_id, .. } => (
|
||||
exe.to_path_buf(),
|
||||
vec![
|
||||
"--resume".to_string(),
|
||||
crate::import::imported_cursor_session_id(session_id),
|
||||
],
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn command_display(program: &std::path::Path, args: &[String]) -> String {
|
||||
std::iter::once(program.to_string_lossy().to_string())
|
||||
.chain(args.iter().cloned())
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
}
|
||||
|
||||
fn spawn_target_in_new_terminal(
|
||||
target: &jcode_tui_session_picker::ResumeTarget,
|
||||
exe: &std::path::Path,
|
||||
cwd: &std::path::Path,
|
||||
) -> Result<bool> {
|
||||
let (program, args) = build_resume_target_command(exe, target);
|
||||
let title = match target {
|
||||
jcode_tui_session_picker::ResumeTarget::JcodeSession { session_id } => {
|
||||
resumed_window_title(session_id)
|
||||
}
|
||||
jcode_tui_session_picker::ResumeTarget::ClaudeCodeSession { session_id, .. } => {
|
||||
format!("🧵 Claude Code {}", &session_id[..session_id.len().min(8)])
|
||||
}
|
||||
jcode_tui_session_picker::ResumeTarget::CodexSession { session_id, .. } => {
|
||||
format!("🧠 Codex {}", &session_id[..session_id.len().min(8)])
|
||||
}
|
||||
jcode_tui_session_picker::ResumeTarget::PiSession { session_path } => {
|
||||
format!(
|
||||
"π Pi {}",
|
||||
std::path::Path::new(session_path)
|
||||
.file_stem()
|
||||
.and_then(|s| s.to_str())
|
||||
.unwrap_or("session")
|
||||
)
|
||||
}
|
||||
jcode_tui_session_picker::ResumeTarget::OpenCodeSession { session_id, .. } => {
|
||||
format!("◌ OpenCode {}", &session_id[..session_id.len().min(8)])
|
||||
}
|
||||
jcode_tui_session_picker::ResumeTarget::CursorSession { session_id, .. } => {
|
||||
format!("▮ Cursor {}", &session_id[..session_id.len().min(8)])
|
||||
}
|
||||
};
|
||||
let command = crate::terminal_launch::TerminalCommand::new(program, args).title(title);
|
||||
crate::terminal_launch::spawn_command_in_new_terminal(&command, cwd)
|
||||
}
|
||||
|
||||
match tui::session_picker::pick_session()? {
|
||||
Some(
|
||||
tui::session_picker::PickerResult::Selected(targets)
|
||||
| tui::session_picker::PickerResult::SelectedInCurrentTerminal(targets),
|
||||
) => {
|
||||
let exe = std::env::current_exe()?;
|
||||
let cwd = std::env::current_dir()?;
|
||||
|
||||
if targets.len() == 1 {
|
||||
let target = &targets[0];
|
||||
let resolved_target = crate::import::resolve_resume_target_to_jcode(target)?;
|
||||
let mut session_cwd = cwd.clone();
|
||||
if let jcode_tui_session_picker::ResumeTarget::JcodeSession { session_id } =
|
||||
&resolved_target
|
||||
&& let Ok(sess) = session::Session::load(session_id)
|
||||
&& let Some(dir) = sess.working_dir.as_deref()
|
||||
&& std::path::Path::new(dir).is_dir()
|
||||
{
|
||||
session_cwd = std::path::PathBuf::from(dir);
|
||||
}
|
||||
let (program, args) = build_resume_target_command(&exe, &resolved_target);
|
||||
let err = crate::platform::replace_process(
|
||||
ProcessCommand::new(&program)
|
||||
.args(&args)
|
||||
.current_dir(session_cwd),
|
||||
);
|
||||
|
||||
Err(anyhow::anyhow!("Failed to exec {:?}: {}", program, err))
|
||||
} else {
|
||||
let mut spawned = 0usize;
|
||||
let mut warned_no_terminal = false;
|
||||
|
||||
for target in targets {
|
||||
let resolved_target =
|
||||
match crate::import::resolve_resume_target_to_jcode(&target) {
|
||||
Ok(target) => target,
|
||||
Err(e) => {
|
||||
eprintln!("Failed to import selected session: {}", e);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let mut session_cwd = cwd.clone();
|
||||
if let jcode_tui_session_picker::ResumeTarget::JcodeSession { session_id } =
|
||||
&resolved_target
|
||||
&& let Ok(sess) = session::Session::load(session_id)
|
||||
&& let Some(dir) = sess.working_dir.as_deref()
|
||||
&& std::path::Path::new(dir).is_dir()
|
||||
{
|
||||
session_cwd = std::path::PathBuf::from(dir);
|
||||
}
|
||||
|
||||
match spawn_target_in_new_terminal(&resolved_target, &exe, &session_cwd) {
|
||||
Ok(true) => spawned += 1,
|
||||
Ok(false) => {
|
||||
if !warned_no_terminal {
|
||||
eprintln!(
|
||||
"No supported terminal emulator found. Run these commands manually:"
|
||||
);
|
||||
warned_no_terminal = true;
|
||||
}
|
||||
let (program, args) =
|
||||
build_resume_target_command(&exe, &resolved_target);
|
||||
eprintln!(" {}", command_display(&program, &args));
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Failed to spawn selected session: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if spawned == 0 && warned_no_terminal {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if spawned == 0 {
|
||||
anyhow::bail!("Failed to spawn any selected sessions");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
Some(tui::session_picker::PickerResult::SelectedInNewTerminal(targets)) => {
|
||||
let exe = std::env::current_exe()?;
|
||||
let cwd = std::env::current_dir()?;
|
||||
let mut spawned = 0usize;
|
||||
let mut warned_no_terminal = false;
|
||||
|
||||
for target in targets {
|
||||
let resolved_target = match crate::import::resolve_resume_target_to_jcode(&target) {
|
||||
Ok(target) => target,
|
||||
Err(e) => {
|
||||
eprintln!("Failed to import selected session: {}", e);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let mut session_cwd = cwd.clone();
|
||||
if let jcode_tui_session_picker::ResumeTarget::JcodeSession { session_id } =
|
||||
&resolved_target
|
||||
&& let Ok(sess) = session::Session::load(session_id)
|
||||
&& let Some(dir) = sess.working_dir.as_deref()
|
||||
&& std::path::Path::new(dir).is_dir()
|
||||
{
|
||||
session_cwd = std::path::PathBuf::from(dir);
|
||||
}
|
||||
|
||||
match spawn_target_in_new_terminal(&resolved_target, &exe, &session_cwd) {
|
||||
Ok(true) => spawned += 1,
|
||||
Ok(false) => {
|
||||
if !warned_no_terminal {
|
||||
eprintln!(
|
||||
"No supported terminal emulator found. Run these commands manually:"
|
||||
);
|
||||
warned_no_terminal = true;
|
||||
}
|
||||
let (program, args) = build_resume_target_command(&exe, &resolved_target);
|
||||
eprintln!(" {}", command_display(&program, &args));
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Failed to spawn selected session: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if spawned == 0 && warned_no_terminal {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if spawned == 0 {
|
||||
anyhow::bail!("Failed to spawn any selected sessions");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
Some(tui::session_picker::PickerResult::RestoreCrashedGroup(session_ids)) => {
|
||||
let recovered = session::recover_crashed_sessions_by_ids(&session_ids)?;
|
||||
if recovered.is_empty() {
|
||||
eprintln!("No crashed sessions found in the selected restore group.");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
eprintln!(
|
||||
"Recovered {} crashed session(s) from the selected restore group.",
|
||||
recovered.len()
|
||||
);
|
||||
|
||||
let exe = std::env::current_exe()?;
|
||||
let cwd = std::env::current_dir()?;
|
||||
let mut spawned = 0usize;
|
||||
let mut warned_no_terminal = false;
|
||||
|
||||
for session_id in recovered {
|
||||
let mut session_cwd = cwd.clone();
|
||||
if let Ok(sess) = session::Session::load(&session_id)
|
||||
&& let Some(dir) = sess.working_dir.as_deref()
|
||||
&& std::path::Path::new(dir).is_dir()
|
||||
{
|
||||
session_cwd = std::path::PathBuf::from(dir);
|
||||
}
|
||||
|
||||
match spawn_resume_in_new_terminal(&exe, &session_id, &session_cwd) {
|
||||
Ok(true) => {
|
||||
spawned += 1;
|
||||
}
|
||||
Ok(false) => {
|
||||
if !warned_no_terminal {
|
||||
eprintln!(
|
||||
"No supported terminal emulator found. Run these commands manually:"
|
||||
);
|
||||
warned_no_terminal = true;
|
||||
}
|
||||
eprintln!(" jcode --resume {}", session_id);
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Failed to spawn session {}: {}", session_id, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if spawned == 0 && warned_no_terminal {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if spawned == 0 {
|
||||
anyhow::bail!("Failed to spawn any recovered sessions");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
None | Some(tui::session_picker::PickerResult::StartNewSession) => {
|
||||
eprintln!("No session selected.");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,222 @@
|
||||
#[cfg(unix)]
|
||||
use super::{
|
||||
resumed_window_title, should_show_server_spawning, spawn_resume_in_new_terminal,
|
||||
spawn_selfdev_in_new_terminal,
|
||||
};
|
||||
#[cfg(unix)]
|
||||
use crate::platform::set_permissions_executable;
|
||||
#[cfg(unix)]
|
||||
use crate::transport::Listener;
|
||||
#[cfg(unix)]
|
||||
use std::ffi::OsString;
|
||||
#[cfg(unix)]
|
||||
use std::fs;
|
||||
#[cfg(unix)]
|
||||
use std::path::Path;
|
||||
#[cfg(unix)]
|
||||
use std::sync::Mutex;
|
||||
#[cfg(unix)]
|
||||
use std::thread;
|
||||
#[cfg(unix)]
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
#[cfg(unix)]
|
||||
static ENV_LOCK: Mutex<()> = Mutex::new(());
|
||||
|
||||
#[cfg(unix)]
|
||||
struct EnvVarGuard {
|
||||
key: &'static str,
|
||||
prev: Option<OsString>,
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
impl EnvVarGuard {
|
||||
fn set_path(key: &'static str, value: &Path) -> Self {
|
||||
let prev = std::env::var_os(key);
|
||||
crate::env::set_var(key, value);
|
||||
Self { key, prev }
|
||||
}
|
||||
|
||||
fn set_value(key: &'static str, value: &str) -> Self {
|
||||
let prev = std::env::var_os(key);
|
||||
crate::env::set_var(key, value);
|
||||
Self { key, prev }
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
impl Drop for EnvVarGuard {
|
||||
fn drop(&mut self) {
|
||||
if let Some(prev) = self.prev.take() {
|
||||
crate::env::set_var(self.key, prev);
|
||||
} else {
|
||||
crate::env::remove_var(self.key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn write_fake_handterm(temp: &tempfile::TempDir, output_path: &Path) {
|
||||
let script_path = temp.path().join("handterm");
|
||||
let script = format!(
|
||||
"#!/bin/sh\nprintf '%s\\n' \"$PWD\" > {}\nprintf '%s\\n' \"$@\" >> {}\n",
|
||||
output_path.display(),
|
||||
output_path.display()
|
||||
);
|
||||
fs::write(&script_path, script).expect("write fake handterm script");
|
||||
set_permissions_executable(&script_path).expect("make fake handterm executable");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn wait_for_lines(path: &Path, min_lines: usize) -> Vec<String> {
|
||||
let deadline = Instant::now() + Duration::from_secs(3);
|
||||
while Instant::now() < deadline {
|
||||
if let Ok(content) = fs::read_to_string(path) {
|
||||
let lines: Vec<String> = content.lines().map(|line| line.to_string()).collect();
|
||||
if lines.len() >= min_lines {
|
||||
return lines;
|
||||
}
|
||||
}
|
||||
thread::sleep(Duration::from_millis(20));
|
||||
}
|
||||
panic!(
|
||||
"timed out waiting for launcher output at {}",
|
||||
path.display()
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn spawn_resume_in_new_terminal_uses_handterm_exec_mode() {
|
||||
let _env_lock = ENV_LOCK.lock().expect("env lock");
|
||||
let temp = tempfile::tempdir().expect("temp dir");
|
||||
let output_path = temp.path().join("resume-launch.txt");
|
||||
write_fake_handterm(&temp, &output_path);
|
||||
let path = format!(
|
||||
"{}:{}",
|
||||
temp.path().display(),
|
||||
std::env::var("PATH").unwrap_or_default()
|
||||
);
|
||||
let _path_guard = EnvVarGuard::set_value("PATH", &path);
|
||||
let _term_guard = EnvVarGuard::set_value("JCODE_TERMINAL", "handterm");
|
||||
|
||||
let exe = temp.path().join("jcode-bin");
|
||||
let cwd = temp.path().join("cwd");
|
||||
fs::create_dir_all(&cwd).expect("create cwd");
|
||||
|
||||
let launched =
|
||||
spawn_resume_in_new_terminal(&exe, "ses_test_123", &cwd).expect("spawn should work");
|
||||
assert!(launched);
|
||||
|
||||
let lines = wait_for_lines(&output_path, 5);
|
||||
assert_eq!(lines[0], cwd.to_string_lossy());
|
||||
assert_eq!(lines[1], "--backend");
|
||||
assert_eq!(lines[2], "gpu");
|
||||
assert_eq!(lines[3], "--exec");
|
||||
assert!(lines[4].contains("--resume"));
|
||||
assert!(lines[4].contains("ses_test_123"));
|
||||
assert!(lines[4].contains(exe.to_string_lossy().as_ref()));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn resumed_window_title_includes_server_name_when_registry_matches_socket() {
|
||||
let _guard = crate::storage::lock_test_env();
|
||||
let temp_home = tempfile::tempdir().expect("temp home");
|
||||
let temp_runtime = tempfile::tempdir().expect("temp runtime");
|
||||
let socket_path = temp_runtime.path().join("jcode.sock");
|
||||
let _home_guard = EnvVarGuard::set_path("JCODE_HOME", temp_home.path());
|
||||
let _socket_guard = EnvVarGuard::set_path("JCODE_SOCKET", &socket_path);
|
||||
|
||||
let mut registry = crate::registry::ServerRegistry::default();
|
||||
registry.register(crate::registry::ServerInfo {
|
||||
id: "server_blazing_123".to_string(),
|
||||
name: "blazing".to_string(),
|
||||
icon: "🔥".to_string(),
|
||||
socket: socket_path,
|
||||
debug_socket: temp_runtime.path().join("jcode-debug.sock"),
|
||||
git_hash: "abc1234".to_string(),
|
||||
version: "v0.1.0".to_string(),
|
||||
pid: std::process::id(),
|
||||
started_at: "2026-01-01T00:00:00Z".to_string(),
|
||||
sessions: Vec::new(),
|
||||
});
|
||||
std::fs::create_dir_all(temp_home.path()).expect("create temp home");
|
||||
std::fs::write(
|
||||
crate::registry::registry_path().expect("registry path"),
|
||||
serde_json::to_string(®istry).expect("serialize registry"),
|
||||
)
|
||||
.expect("write registry");
|
||||
|
||||
assert_eq!(
|
||||
resumed_window_title("session_parrot_123"),
|
||||
"🦜 jcode/blazing parrot"
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn spawn_selfdev_in_new_terminal_uses_handterm_exec_mode() {
|
||||
let _env_lock = ENV_LOCK.lock().expect("env lock");
|
||||
let temp = tempfile::tempdir().expect("temp dir");
|
||||
let output_path = temp.path().join("selfdev-launch.txt");
|
||||
write_fake_handterm(&temp, &output_path);
|
||||
let path = format!(
|
||||
"{}:{}",
|
||||
temp.path().display(),
|
||||
std::env::var("PATH").unwrap_or_default()
|
||||
);
|
||||
let _path_guard = EnvVarGuard::set_value("PATH", &path);
|
||||
let _term_guard = EnvVarGuard::set_value("JCODE_TERMINAL", "handterm");
|
||||
|
||||
let exe = temp.path().join("jcode-bin");
|
||||
let cwd = temp.path().join("cwd");
|
||||
fs::create_dir_all(&cwd).expect("create cwd");
|
||||
|
||||
let launched =
|
||||
spawn_selfdev_in_new_terminal(&exe, "ses_selfdev_123", &cwd).expect("spawn should work");
|
||||
assert!(launched);
|
||||
|
||||
let lines = wait_for_lines(&output_path, 5);
|
||||
assert_eq!(lines[0], cwd.to_string_lossy());
|
||||
assert_eq!(lines[1], "--backend");
|
||||
assert_eq!(lines[2], "gpu");
|
||||
assert_eq!(lines[3], "--exec");
|
||||
assert!(lines[4].contains("--resume"));
|
||||
assert!(lines[4].contains("ses_selfdev_123"));
|
||||
assert!(lines[4].contains("self-dev"));
|
||||
assert!(lines[4].contains(exe.to_string_lossy().as_ref()));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn suppresses_stale_server_spawning_phase_when_listener_is_already_live() {
|
||||
let _guard = crate::storage::lock_test_env();
|
||||
let temp = tempfile::tempdir().expect("temp dir");
|
||||
let socket_path = temp.path().join("jcode.sock");
|
||||
let _socket_guard = EnvVarGuard::set_path("JCODE_SOCKET", &socket_path);
|
||||
let _listener = Listener::bind(&socket_path).expect("bind listener");
|
||||
|
||||
assert!(
|
||||
!should_show_server_spawning(true).await,
|
||||
"server startup banner should not linger once the listener is already live"
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn keeps_server_spawning_phase_while_listener_is_not_live() {
|
||||
let _guard = crate::storage::lock_test_env();
|
||||
let temp = tempfile::tempdir().expect("temp dir");
|
||||
let socket_path = temp.path().join("jcode.sock");
|
||||
let _socket_guard = EnvVarGuard::set_path("JCODE_SOCKET", &socket_path);
|
||||
|
||||
assert!(
|
||||
should_show_server_spawning(true).await,
|
||||
"server startup banner should still show before a listener exists"
|
||||
);
|
||||
assert!(
|
||||
!should_show_server_spawning(false).await,
|
||||
"server startup banner should stay hidden when client did not request it"
|
||||
);
|
||||
}
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
#![allow(
|
||||
unknown_lints,
|
||||
clippy::collapsible_match,
|
||||
clippy::manual_checked_ops,
|
||||
clippy::unnecessary_sort_by,
|
||||
clippy::useless_conversion
|
||||
)]
|
||||
|
||||
//! Root `jcode` crate: the entrypoint + cli layer on top of the `jcode-tui`
|
||||
//! presentation crate (which in turn re-exports `jcode-app-core` and
|
||||
//! `jcode-base`).
|
||||
//!
|
||||
//! The presentation modules (`tui`, `video_export`) live in `jcode-tui` and the
|
||||
//! non-presentation modules live in `jcode-app-core`; both are re-exported here
|
||||
//! via `pub use jcode_tui::*`, so existing `crate::<module>` paths (e.g.
|
||||
//! `crate::config`, `crate::server`, `crate::tui`) keep resolving unchanged
|
||||
//! across the cli code that was not moved.
|
||||
|
||||
// Re-export the presentation layer (and, transitively, the application core)
|
||||
// so `crate::tui`, `crate::video_export`, and `crate::<app-core module>` paths
|
||||
// resolve.
|
||||
pub use jcode_tui::*;
|
||||
|
||||
// Cli + entrypoint layer (kept in the root crate).
|
||||
pub mod cli;
|
||||
|
||||
use anyhow::Result;
|
||||
|
||||
pub async fn run() -> Result<()> {
|
||||
cli::startup::run().await
|
||||
}
|
||||
+182
@@ -0,0 +1,182 @@
|
||||
#[cfg(feature = "jemalloc")]
|
||||
#[global_allocator]
|
||||
static GLOBAL: tikv_jemallocator::Jemalloc = tikv_jemallocator::Jemalloc;
|
||||
|
||||
// Tune jemalloc for a long-running server with bursty allocations (e.g. loading
|
||||
// and unloading an ~87 MB ONNX embedding model). The defaults (muzzy_decay_ms:0,
|
||||
// retain:true, narenas:8*ncpu) caused 1.4 GB RSS in previous testing.
|
||||
//
|
||||
// dirty_decay_ms:1000 — return dirty pages to OS after 1 s idle
|
||||
// muzzy_decay_ms:1000 — release muzzy pages after 1 s
|
||||
// narenas:4 — limit arena count (17 threads don't need 64 arenas)
|
||||
// prof:true — enable profiling support in jemalloc-prof builds
|
||||
// prof_active:false — keep sampling disabled until explicitly enabled at runtime
|
||||
#[cfg(all(feature = "jemalloc", not(feature = "jemalloc-prof")))]
|
||||
// jemalloc reads this exact exported symbol name at startup.
|
||||
#[allow(non_upper_case_globals)]
|
||||
#[unsafe(no_mangle)]
|
||||
pub static malloc_conf: Option<&'static [u8; 50]> =
|
||||
Some(b"dirty_decay_ms:1000,muzzy_decay_ms:1000,narenas:4\0");
|
||||
|
||||
#[cfg(feature = "jemalloc-prof")]
|
||||
// jemalloc reads this exact exported symbol name at startup.
|
||||
#[allow(non_upper_case_globals)]
|
||||
#[unsafe(no_mangle)]
|
||||
pub static malloc_conf: Option<&'static [u8; 78]> =
|
||||
Some(b"dirty_decay_ms:1000,muzzy_decay_ms:1000,narenas:4,prof:true,prof_active:false\0");
|
||||
|
||||
use anyhow::Result;
|
||||
|
||||
#[cfg(all(target_os = "linux", not(feature = "jemalloc")))]
|
||||
fn configure_system_allocator() {
|
||||
unsafe extern "C" {
|
||||
fn mallopt(param: i32, value: i32) -> i32;
|
||||
}
|
||||
|
||||
const M_ARENA_MAX: i32 = -8;
|
||||
const M_MMAP_THRESHOLD: i32 = -3;
|
||||
|
||||
let arena_max = parse_alloc_tuning_env("JCODE_GLIBC_ARENA_MAX", 4);
|
||||
let _ = unsafe { mallopt(M_ARENA_MAX, arena_max) };
|
||||
|
||||
// Pin the mmap threshold so large transient allocations (history JSON,
|
||||
// provider payloads) are served by mmap and returned to the OS
|
||||
// immediately on free, instead of landing in sbrk arenas where freed
|
||||
// blocks below the top chunk become permanent RSS retention.
|
||||
//
|
||||
// Tradeoff: setting M_MMAP_THRESHOLD via mallopt disables glibc's
|
||||
// dynamic threshold growth (normally the threshold rises toward 32 MiB
|
||||
// as large blocks are freed, keeping hot large buffers in the arena for
|
||||
// cheap reuse). Pinning trades some throughput on repeated large
|
||||
// alloc/free cycles (mmap/munmap syscalls + page faults each time) for
|
||||
// predictable, immediate memory return. For a long-running interactive
|
||||
// agent, lower steady-state RSS wins.
|
||||
let mmap_threshold = parse_alloc_tuning_env("JCODE_GLIBC_MMAP_THRESHOLD", 256 * 1024);
|
||||
let _ = unsafe { mallopt(M_MMAP_THRESHOLD, mmap_threshold) };
|
||||
}
|
||||
|
||||
/// Parse a positive i32 allocator tuning knob from an env var, falling back
|
||||
/// to `default` when unset, unparsable, or non-positive.
|
||||
#[cfg(all(target_os = "linux", not(feature = "jemalloc")))]
|
||||
fn parse_alloc_tuning_env(var: &str, default: i32) -> i32 {
|
||||
parse_alloc_tuning(std::env::var(var).ok().as_deref(), default)
|
||||
}
|
||||
|
||||
/// Pure parsing core of [`parse_alloc_tuning_env`], separated for unit tests.
|
||||
#[cfg(any(test, all(target_os = "linux", not(feature = "jemalloc"))))]
|
||||
fn parse_alloc_tuning(value: Option<&str>, default: i32) -> i32 {
|
||||
value
|
||||
.and_then(|value| value.trim().parse::<i32>().ok())
|
||||
.filter(|value| *value > 0)
|
||||
.unwrap_or(default)
|
||||
}
|
||||
|
||||
#[cfg(not(all(target_os = "linux", not(feature = "jemalloc"))))]
|
||||
fn configure_system_allocator() {}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn main() -> Result<()> {
|
||||
// Windows executables default to a much smaller main-thread stack than the
|
||||
// Unix environments where most development happens. The CLI/provider setup
|
||||
// path can exceed that reserve before Tokio takes over, producing an
|
||||
// unrecoverable STATUS_STACK_OVERFLOW. Keep the linker defaults unchanged
|
||||
// for every auxiliary binary and run the Jcode entry point on a deliberately
|
||||
// sized stack instead.
|
||||
const WINDOWS_MAIN_STACK_SIZE: usize = 8 * 1024 * 1024;
|
||||
match std::thread::Builder::new()
|
||||
.name("jcode-main".to_string())
|
||||
.stack_size(WINDOWS_MAIN_STACK_SIZE)
|
||||
.spawn(run_main)?
|
||||
.join()
|
||||
{
|
||||
Ok(result) => result,
|
||||
Err(panic) => std::panic::resume_unwind(panic),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
fn main() -> Result<()> {
|
||||
run_main()
|
||||
}
|
||||
|
||||
fn run_main() -> Result<()> {
|
||||
configure_system_allocator();
|
||||
let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
|
||||
|
||||
// The macOS global-hotkey listener must run on the real main thread with a
|
||||
// Core Foundation run loop (Carbon `RegisterEventHotKey` delivers events
|
||||
// there). Intercept it before building the tokio runtime, which would
|
||||
// otherwise move execution onto a worker thread with no run loop and leave
|
||||
// the Cmd+; hotkey silently dead.
|
||||
if is_macos_hotkey_listener_invocation() {
|
||||
return jcode::setup_hints::run_macos_hotkey_listener_main_thread();
|
||||
}
|
||||
|
||||
let runtime = tokio::runtime::Builder::new_multi_thread()
|
||||
.enable_all()
|
||||
.build()?;
|
||||
|
||||
runtime.block_on(async { jcode::run().await })
|
||||
}
|
||||
|
||||
/// True when invoked as `jcode setup-hotkey --listen-macos-hotkey`.
|
||||
fn is_macos_hotkey_listener_invocation() -> bool {
|
||||
args_are_macos_hotkey_listener(std::env::args().skip(1))
|
||||
}
|
||||
|
||||
fn args_are_macos_hotkey_listener(args: impl IntoIterator<Item = String>) -> bool {
|
||||
let args: Vec<String> = args.into_iter().collect();
|
||||
args.first().map(String::as_str) == Some("setup-hotkey")
|
||||
&& args.iter().any(|a| a == "--listen-macos-hotkey")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::args_are_macos_hotkey_listener;
|
||||
use super::parse_alloc_tuning;
|
||||
|
||||
#[test]
|
||||
fn alloc_tuning_uses_default_when_unset() {
|
||||
assert_eq!(parse_alloc_tuning(None, 262_144), 262_144);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alloc_tuning_parses_positive_value_with_whitespace() {
|
||||
assert_eq!(parse_alloc_tuning(Some(" 131072 "), 262_144), 131_072);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alloc_tuning_rejects_garbage_zero_and_negative() {
|
||||
assert_eq!(parse_alloc_tuning(Some("not-a-number"), 4), 4);
|
||||
assert_eq!(parse_alloc_tuning(Some("0"), 4), 4);
|
||||
assert_eq!(parse_alloc_tuning(Some("-1"), 4), 4);
|
||||
// i32 overflow falls back to default rather than wrapping.
|
||||
assert_eq!(parse_alloc_tuning(Some("4294967296"), 4), 4);
|
||||
}
|
||||
|
||||
fn argv(args: &[&str]) -> Vec<String> {
|
||||
args.iter().map(|s| s.to_string()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_listener_invocation() {
|
||||
assert!(args_are_macos_hotkey_listener(argv(&[
|
||||
"setup-hotkey",
|
||||
"--listen-macos-hotkey"
|
||||
])));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_plain_setup_hotkey() {
|
||||
assert!(!args_are_macos_hotkey_listener(argv(&["setup-hotkey"])));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_other_commands() {
|
||||
assert!(!args_are_macos_hotkey_listener(argv(&[
|
||||
"serve",
|
||||
"--listen-macos-hotkey"
|
||||
])));
|
||||
assert!(!args_are_macos_hotkey_listener(argv(&[])));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user