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

This commit is contained in:
wehub-resource-sync
2026-07-13 13:10:34 +08:00
commit a789495a98
1551 changed files with 718128 additions and 0 deletions
+201
View File
@@ -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
+64
View File
@@ -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(())
}
+249
View File
@@ -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
}
+37
View File
@@ -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(())
}
+1747
View File
File diff suppressed because it is too large Load Diff
+122
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+991
View File
@@ -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;
+703
View File
@@ -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");
}
+16
View File
@@ -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");
+591
View File
@@ -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}");
}
}
+289
View File
@@ -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(),
);
}
+853
View File
@@ -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),
)
}
+356
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+812
View File
@@ -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,
));
}
}
+735
View File
@@ -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"));
}
}
+768
View File
@@ -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" })
);
}
}
+258
View File
@@ -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;
+107
View File
@@ -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
+294
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+274
View File
@@ -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"
);
}
+465
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+379
View File
@@ -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;
+279
View File
@@ -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
);
}
+751
View File
@@ -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(())
}
+140
View File
@@ -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);
}
+16
View File
@@ -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;
+27
View File
@@ -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!();
}
}
+119
View File
@@ -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");
});
}
}
+233
View File
@@ -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
+952
View File
@@ -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");
}
}
+233
View File
@@ -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;
+352
View File
@@ -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")
);
}
+437
View File
@@ -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
));
}
}
+658
View File
@@ -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);
}
}
+711
View File
@@ -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;
+222
View File
@@ -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(&registry).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
View File
@@ -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
View File
@@ -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(&[])));
}
}