feat: 优化命令
This commit is contained in:
@@ -9,9 +9,9 @@
|
|||||||
Codex 的认证和会话状态都来自 `CODEX_HOME`。`cdxs` 做的是在这个目录外面加一层可操作的管理能力:
|
Codex 的认证和会话状态都来自 `CODEX_HOME`。`cdxs` 做的是在这个目录外面加一层可操作的管理能力:
|
||||||
|
|
||||||
- 账号管理:导入已有 `auth.json`,通过 OAuth 或 API Key 登录,切换当前账号,并在需要时刷新 OAuth token。
|
- 账号管理:导入已有 `auth.json`,通过 OAuth 或 API Key 登录,切换当前账号,并在需要时刷新 OAuth token。
|
||||||
- 配额查看:查询 OAuth 账号的 Codex 使用配额,并缓存到本地。
|
- 配额缓存:列出账号时自动刷新过期的 OAuth 配额缓存。
|
||||||
- Home 管理:创建命名的 `CODEX_HOME`,并绑定到指定账号。
|
- Home 管理:创建命名的 `CODEX_HOME`,并绑定到指定账号。
|
||||||
- 命令运行:用指定账号或 home 启动子进程,只为该进程设置 `CODEX_HOME`。
|
- 命令运行:用指定账号或 home 运行 `codex exec`,只为该进程设置 `CODEX_HOME`。
|
||||||
- 会话管理:查看会话、统计 token 和文件信息、移入可恢复垃圾箱、恢复会话、修复缺失的会话索引。
|
- 会话管理:查看会话、统计 token 和文件信息、移入可恢复垃圾箱、恢复会话、修复缺失的会话索引。
|
||||||
- 线程同步:在多个受管理 home 之间补齐缺失的会话线程。
|
- 线程同步:在多个受管理 home 之间补齐缺失的会话线程。
|
||||||
- 状态同步:运行轻量 HTTP 服务,在多台机器之间推送或拉取账号状态。
|
- 状态同步:运行轻量 HTTP 服务,在多台机器之间推送或拉取账号状态。
|
||||||
@@ -39,16 +39,17 @@ Codex home 的解析顺序:
|
|||||||
|
|
||||||
## 常用快捷命令
|
## 常用快捷命令
|
||||||
|
|
||||||
- `cdxs list`:列出账号,并自动刷新过期配额缓存。
|
- `cdxs`:列出账号,并自动刷新过期配额缓存。
|
||||||
- `cdxs show`:列出账号,但不请求配额接口。
|
- `cdxs -f`:强制刷新账号配额后再列出。
|
||||||
|
- `cdxs show <账号>`:显示单个账号详情,等价于 `cdxs account show <账号>`。
|
||||||
- `cdxs alias set <别名> <账号>`:给任意 OAuth 或 API Key 账号设置别名。
|
- `cdxs alias set <别名> <账号>`:给任意 OAuth 或 API Key 账号设置别名。
|
||||||
- `cdxs alias list`:列出所有账号别名。
|
- `cdxs alias list`:列出所有账号别名。
|
||||||
- `cdxs alias remove <别名或账号>`:删除指定账号的别名。
|
- `cdxs alias remove <别名或账号>`:删除指定账号的别名。
|
||||||
- `cdxs switch`:不带参数时自动选择可用配额最优的账号。
|
- `cdxs switch`:不带参数时自动选择可用配额最优的账号。
|
||||||
- `cdxs switch <账号或别名>`:切换到指定账号,OAuth 和 API Key 账号都可通过 alias 切换。
|
- `cdxs switch <账号或别名>`:切换到指定账号,OAuth 和 API Key 账号都可通过 alias 切换。
|
||||||
- `cdxs switch A --model <model> --effort <effort> --name OpenAI`:切换时更新该账号默认模型、思考程度;`--name` 仅用于 API 模式的 provider name。
|
- `cdxs switch A --model <model> --effort <effort> --name OpenAI`:切换时更新该账号默认模型、思考程度;`--name` 仅用于 API 模式的 provider name。
|
||||||
- `cdxs ping`:以 5 并发 ping 所有 OAuth 账号,运行最低 reasoning 的最小 `codex exec`,触发 Codex 侧额度状态刷新。
|
- `cdxs exec <账号> -- --model gpt-5 "hello"`:用指定账号运行 `codex exec`。
|
||||||
- `cdxs ping --account <账号>`:指定账号运行最小 `codex exec`;可重复传多个账号。
|
- `cdxs exec <home> --home --temp -- --model gpt-5 "hello"`:基于指定受管 home 建立一次性临时 `CODEX_HOME` 后运行 `codex exec`。
|
||||||
- `cdxs login --api <key> --base-url <url> --alias A --model <model> --name OpenAI --switch`:保存 API Key 账号并可选切换;`base-url` 为空时使用 OpenAI 默认 API。
|
- `cdxs login --api <key> --base-url <url> --alias A --model <model> --name OpenAI --switch`:保存 API Key 账号并可选切换;`base-url` 为空时使用 OpenAI 默认 API。
|
||||||
- `cdxs login api --key <key> --base-url <url> --alias A --model <model> --name OpenAI --switch`:同上,保留旧的子命令形式。
|
- `cdxs login api --key <key> --base-url <url> --alias A --model <model> --name OpenAI --switch`:同上,保留旧的子命令形式。
|
||||||
- `cdxs remove <账号>`:删除账号,等价于 `cdxs account remove <账号>`。
|
- `cdxs remove <账号>`:删除账号,等价于 `cdxs account remove <账号>`。
|
||||||
|
|||||||
@@ -40,12 +40,6 @@ pub async fn list_accounts(json: bool, force: bool) -> Result<()> {
|
|||||||
print_accounts(&store, json)
|
print_accounts(&store, json)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn show_accounts(json: bool) -> Result<()> {
|
|
||||||
let home = paths::codex_home(None)?;
|
|
||||||
let store = Store::load(&home)?;
|
|
||||||
print_accounts(&store, json)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn list_aliases(json: bool) -> Result<()> {
|
pub fn list_aliases(json: bool) -> Result<()> {
|
||||||
let home = paths::codex_home(None)?;
|
let home = paths::codex_home(None)?;
|
||||||
let store = Store::load(&home)?;
|
let store = Store::load(&home)?;
|
||||||
|
|||||||
+1
-1
@@ -19,7 +19,7 @@ use crate::config_store::{Account, AuthMode, Store};
|
|||||||
pub use homes::{bind_home, create_home, home_path, list_homes, remove_home};
|
pub use homes::{bind_home, create_home, home_path, list_homes, remove_home};
|
||||||
pub use listing::{
|
pub use listing::{
|
||||||
current_account, list_accounts, list_aliases, remove_account, remove_alias, set_alias,
|
current_account, list_accounts, list_aliases, remove_account, remove_alias, set_alias,
|
||||||
show_account, show_accounts,
|
show_account,
|
||||||
};
|
};
|
||||||
pub use storage::{add_api_key, import_auth, upsert_oauth_tokens};
|
pub use storage::{add_api_key, import_auth, upsert_oauth_tokens};
|
||||||
pub use switching::{prepare_account_in_home, resolve_home_for_run, switch_account, switch_auto};
|
pub use switching::{prepare_account_in_home, resolve_home_for_run, switch_account, switch_auto};
|
||||||
|
|||||||
+21
-45
@@ -5,6 +5,9 @@ use clap::{Args, Parser, Subcommand};
|
|||||||
#[derive(Parser)]
|
#[derive(Parser)]
|
||||||
#[command(name = "cdxs", version, about = "Codex Switch CLI")]
|
#[command(name = "cdxs", version, about = "Codex Switch CLI")]
|
||||||
pub struct Cli {
|
pub struct Cli {
|
||||||
|
/// When no subcommand is given, force refresh quota before listing accounts.
|
||||||
|
#[arg(short, long)]
|
||||||
|
pub force: bool,
|
||||||
#[command(subcommand)]
|
#[command(subcommand)]
|
||||||
pub command: Option<Commands>,
|
pub command: Option<Commands>,
|
||||||
}
|
}
|
||||||
@@ -15,15 +18,10 @@ pub enum Commands {
|
|||||||
Login(LoginArgs),
|
Login(LoginArgs),
|
||||||
/// Import an existing Codex auth file.
|
/// Import an existing Codex auth file.
|
||||||
Import(ImportArgs),
|
Import(ImportArgs),
|
||||||
/// List saved accounts.
|
/// Show one saved account.
|
||||||
List {
|
|
||||||
#[arg(long)]
|
|
||||||
json: bool,
|
|
||||||
#[arg(short, long)]
|
|
||||||
force: bool,
|
|
||||||
},
|
|
||||||
/// List saved accounts without refreshing quota.
|
|
||||||
Show {
|
Show {
|
||||||
|
#[arg(value_name = "ACCOUNT_ID_OR_EMAIL")]
|
||||||
|
account: String,
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
json: bool,
|
json: bool,
|
||||||
},
|
},
|
||||||
@@ -70,17 +68,8 @@ pub enum Commands {
|
|||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
name: Option<String>,
|
name: Option<String>,
|
||||||
},
|
},
|
||||||
/// Prepare auth, set CODEX_HOME, and execute a command.
|
/// Prepare auth, set CODEX_HOME, and run codex exec.
|
||||||
Run(RunArgs),
|
Exec(ExecArgs),
|
||||||
/// Run a minimal Codex exec to refresh Codex-side quota state.
|
|
||||||
Ping(PingArgs),
|
|
||||||
/// Refresh and display Codex quota.
|
|
||||||
Quota {
|
|
||||||
#[arg(value_name = "ACCOUNT_ID_OR_EMAIL")]
|
|
||||||
accounts: Vec<String>,
|
|
||||||
#[arg(long)]
|
|
||||||
json: bool,
|
|
||||||
},
|
|
||||||
/// Account management commands.
|
/// Account management commands.
|
||||||
Account {
|
Account {
|
||||||
#[command(subcommand)]
|
#[command(subcommand)]
|
||||||
@@ -386,30 +375,17 @@ pub enum SessionVisibilityCommands {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Args)]
|
#[derive(Args)]
|
||||||
pub struct RunArgs {
|
pub struct ExecArgs {
|
||||||
#[arg(long, conflicts_with = "home")]
|
#[arg(value_name = "ACCOUNT_OR_HOME")]
|
||||||
pub account: Option<String>,
|
pub name: String,
|
||||||
#[arg(long, conflicts_with = "account")]
|
#[arg(long, help = "Treat <ACCOUNT_OR_HOME> as a managed home name")]
|
||||||
pub home: Option<String>,
|
pub home: bool,
|
||||||
#[arg(long)]
|
#[arg(
|
||||||
pub codex_home: Option<PathBuf>,
|
long,
|
||||||
/// Command after `--`, for example: cdxs run --account me -- codex
|
help = "Run in a temporary CODEX_HOME cloned from the source config"
|
||||||
#[arg(required = true, trailing_var_arg = true, allow_hyphen_values = true)]
|
)]
|
||||||
pub command: Vec<String>,
|
pub temp: bool,
|
||||||
}
|
/// Arguments passed to `codex exec` after `--`.
|
||||||
|
#[arg(last = true, allow_hyphen_values = true)]
|
||||||
#[derive(Args)]
|
pub args: Vec<String>,
|
||||||
pub struct PingArgs {
|
|
||||||
#[arg(long, value_name = "ACCOUNT_ID_OR_EMAIL")]
|
|
||||||
pub account: Vec<String>,
|
|
||||||
#[arg(long, default_value_t = 5)]
|
|
||||||
pub concurrency: usize,
|
|
||||||
#[arg(long)]
|
|
||||||
pub codex_home: Option<PathBuf>,
|
|
||||||
#[arg(long, default_value = "gpt-5.4")]
|
|
||||||
pub model: String,
|
|
||||||
#[arg(long, default_value = "none")]
|
|
||||||
pub reasoning_effort: String,
|
|
||||||
#[arg(long, default_value = "hello")]
|
|
||||||
pub prompt: String,
|
|
||||||
}
|
}
|
||||||
|
|||||||
+172
-182
@@ -20,7 +20,7 @@ mod session;
|
|||||||
mod sync_client;
|
mod sync_client;
|
||||||
mod token;
|
mod token;
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::{anyhow, Result};
|
||||||
use clap::Parser;
|
use clap::Parser;
|
||||||
|
|
||||||
use crate::cli::{
|
use crate::cli::{
|
||||||
@@ -31,201 +31,191 @@ use crate::cli::{
|
|||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() -> Result<()> {
|
async fn main() -> Result<()> {
|
||||||
let cli = Cli::parse();
|
let cli = Cli::parse();
|
||||||
match cli.command.unwrap_or(Commands::List {
|
if cli.force && cli.command.is_some() {
|
||||||
json: false,
|
return Err(anyhow!(
|
||||||
force: false,
|
"顶层 -f/--force 仅用于默认账号列表;对子命令请写 `cdxs <subcommand> -f`"
|
||||||
}) {
|
));
|
||||||
Commands::Login(args) => match args.command {
|
}
|
||||||
Some(LoginCommands::Oauth {
|
match cli.command {
|
||||||
manual,
|
None => account::list_accounts(false, cli.force).await,
|
||||||
port,
|
Some(command) => match command {
|
||||||
switch,
|
Commands::Login(args) => match args.command {
|
||||||
}) => oauth::login_oauth(manual, port, switch).await,
|
Some(LoginCommands::Oauth {
|
||||||
Some(LoginCommands::Api {
|
manual,
|
||||||
key,
|
port,
|
||||||
base_url,
|
switch,
|
||||||
alias,
|
}) => oauth::login_oauth(manual, port, switch).await,
|
||||||
model,
|
Some(LoginCommands::Api {
|
||||||
name,
|
key,
|
||||||
switch,
|
base_url,
|
||||||
}) => account::add_api_key(
|
|
||||||
key,
|
|
||||||
base_url,
|
|
||||||
account::ApiKeyOptions {
|
|
||||||
alias,
|
alias,
|
||||||
model,
|
model,
|
||||||
provider_name: name,
|
name,
|
||||||
},
|
switch,
|
||||||
switch,
|
}) => account::add_api_key(
|
||||||
),
|
key,
|
||||||
None => {
|
base_url,
|
||||||
if let Some(key) = args.api {
|
account::ApiKeyOptions {
|
||||||
account::add_api_key(
|
alias,
|
||||||
key,
|
model,
|
||||||
args.base_url,
|
provider_name: name,
|
||||||
account::ApiKeyOptions {
|
},
|
||||||
alias: args.alias,
|
switch,
|
||||||
model: args.model,
|
),
|
||||||
provider_name: args.name,
|
None => {
|
||||||
},
|
if let Some(key) = args.api {
|
||||||
args.switch,
|
account::add_api_key(
|
||||||
)
|
key,
|
||||||
} else {
|
args.base_url,
|
||||||
oauth::login_oauth(args.manual, args.port, args.switch).await
|
account::ApiKeyOptions {
|
||||||
|
alias: args.alias,
|
||||||
|
model: args.model,
|
||||||
|
provider_name: args.name,
|
||||||
|
},
|
||||||
|
args.switch,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
oauth::login_oauth(args.manual, args.port, args.switch).await
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
},
|
Commands::Import(args) => match args.command {
|
||||||
Commands::Import(args) => match args.command {
|
Some(ImportCommands::Auth {
|
||||||
Some(ImportCommands::Auth {
|
file,
|
||||||
file,
|
|
||||||
codex_home,
|
|
||||||
switch,
|
|
||||||
}) => account::import_auth(file, codex_home, switch),
|
|
||||||
None => account::import_auth(args.file, args.codex_home, args.switch),
|
|
||||||
},
|
|
||||||
Commands::List { json, force } => account::list_accounts(json, force).await,
|
|
||||||
Commands::Show { json } => account::show_accounts(json),
|
|
||||||
Commands::Pull { force } => sync_client::pull(force).await,
|
|
||||||
Commands::Push { force } => sync_client::push(force).await,
|
|
||||||
Commands::Remove { account } => account::remove_account(&account),
|
|
||||||
Commands::Alias { command } => match command {
|
|
||||||
AliasCommands::List { json } => account::list_aliases(json),
|
|
||||||
AliasCommands::Set { alias, account } => account::set_alias(&alias, &account),
|
|
||||||
AliasCommands::Remove { alias } => account::remove_alias(&alias),
|
|
||||||
},
|
|
||||||
Commands::Switch {
|
|
||||||
account,
|
|
||||||
auto,
|
|
||||||
codex_home,
|
|
||||||
apply_fingerprint,
|
|
||||||
model,
|
|
||||||
effort,
|
|
||||||
name,
|
|
||||||
} => {
|
|
||||||
let options = account::SwitchOptions {
|
|
||||||
model,
|
|
||||||
reasoning_effort: effort,
|
|
||||||
provider_name: name,
|
|
||||||
};
|
|
||||||
if auto || account.is_none() {
|
|
||||||
account::switch_auto(codex_home, apply_fingerprint, options).await
|
|
||||||
} else {
|
|
||||||
account::switch_account(
|
|
||||||
account.as_deref().ok_or_else(|| {
|
|
||||||
anyhow::anyhow!("switch 需要提供账号,或使用 -a/--auto 自动选择")
|
|
||||||
})?,
|
|
||||||
codex_home,
|
codex_home,
|
||||||
apply_fingerprint,
|
switch,
|
||||||
options,
|
}) => account::import_auth(file, codex_home, switch),
|
||||||
)
|
None => account::import_auth(args.file, args.codex_home, args.switch),
|
||||||
.await
|
},
|
||||||
}
|
Commands::Show { account, json } => account::show_account(&account, json),
|
||||||
}
|
Commands::Pull { force } => sync_client::pull(force).await,
|
||||||
Commands::Run(args) => {
|
Commands::Push { force } => sync_client::push(force).await,
|
||||||
run_cmd::run_with_account_or_home(
|
Commands::Remove { account } => account::remove_account(&account),
|
||||||
args.account,
|
Commands::Alias { command } => match command {
|
||||||
args.home,
|
AliasCommands::List { json } => account::list_aliases(json),
|
||||||
args.codex_home,
|
AliasCommands::Set { alias, account } => account::set_alias(&alias, &account),
|
||||||
args.command,
|
AliasCommands::Remove { alias } => account::remove_alias(&alias),
|
||||||
)
|
},
|
||||||
.await
|
Commands::Switch {
|
||||||
}
|
account,
|
||||||
Commands::Ping(args) => {
|
auto,
|
||||||
run_cmd::ping_codex(
|
codex_home,
|
||||||
args.account,
|
apply_fingerprint,
|
||||||
args.concurrency,
|
|
||||||
args.codex_home,
|
|
||||||
args.model,
|
|
||||||
args.reasoning_effort,
|
|
||||||
args.prompt,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
Commands::Quota { accounts, json } => quota::quota_command(accounts, json).await,
|
|
||||||
Commands::Account { command } => match command {
|
|
||||||
AccountCommands::List { json, force } => account::list_accounts(json, force).await,
|
|
||||||
AccountCommands::Current { json } => account::current_account(json),
|
|
||||||
AccountCommands::Show { account, json } => account::show_account(&account, json),
|
|
||||||
AccountCommands::Remove { account } => account::remove_account(&account),
|
|
||||||
AccountCommands::AddApiKey {
|
|
||||||
key,
|
|
||||||
base_url,
|
|
||||||
alias,
|
|
||||||
model,
|
model,
|
||||||
|
effort,
|
||||||
name,
|
name,
|
||||||
switch,
|
} => {
|
||||||
} => account::add_api_key(
|
let options = account::SwitchOptions {
|
||||||
key,
|
model,
|
||||||
base_url,
|
reasoning_effort: effort,
|
||||||
account::ApiKeyOptions {
|
provider_name: name,
|
||||||
|
};
|
||||||
|
if auto || account.is_none() {
|
||||||
|
account::switch_auto(codex_home, apply_fingerprint, options).await
|
||||||
|
} else {
|
||||||
|
account::switch_account(
|
||||||
|
account.as_deref().ok_or_else(|| {
|
||||||
|
anyhow::anyhow!("switch 需要提供账号,或使用 -a/--auto 自动选择")
|
||||||
|
})?,
|
||||||
|
codex_home,
|
||||||
|
apply_fingerprint,
|
||||||
|
options,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Commands::Exec(args) => {
|
||||||
|
run_cmd::exec_codex(args.name, args.home, args.temp, args.args).await
|
||||||
|
}
|
||||||
|
Commands::Account { command } => match command {
|
||||||
|
AccountCommands::List { json, force } => account::list_accounts(json, force).await,
|
||||||
|
AccountCommands::Current { json } => account::current_account(json),
|
||||||
|
AccountCommands::Show { account, json } => account::show_account(&account, json),
|
||||||
|
AccountCommands::Remove { account } => account::remove_account(&account),
|
||||||
|
AccountCommands::AddApiKey {
|
||||||
|
key,
|
||||||
|
base_url,
|
||||||
alias,
|
alias,
|
||||||
model,
|
model,
|
||||||
provider_name: name,
|
name,
|
||||||
|
switch,
|
||||||
|
} => account::add_api_key(
|
||||||
|
key,
|
||||||
|
base_url,
|
||||||
|
account::ApiKeyOptions {
|
||||||
|
alias,
|
||||||
|
model,
|
||||||
|
provider_name: name,
|
||||||
|
},
|
||||||
|
switch,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
Commands::Home { command } => match command {
|
||||||
|
HomeCommands::List { json } => account::list_homes(json),
|
||||||
|
HomeCommands::Create {
|
||||||
|
name,
|
||||||
|
path,
|
||||||
|
account,
|
||||||
|
} => account::create_home(&name, path, account),
|
||||||
|
HomeCommands::Bind { name, account } => account::bind_home(&name, &account),
|
||||||
|
HomeCommands::Path { name } => account::home_path(&name),
|
||||||
|
HomeCommands::Remove { name } => account::remove_home(&name),
|
||||||
|
},
|
||||||
|
Commands::Server { command } => match command {
|
||||||
|
ServerCommands::Run { bind, data_dir } => server::run_server(bind, data_dir).await,
|
||||||
|
ServerCommands::User { command } => match command {
|
||||||
|
ServerUserCommands::Add {
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
data_dir,
|
||||||
|
} => server::add_user(data_dir, &username, &password),
|
||||||
},
|
},
|
||||||
switch,
|
},
|
||||||
),
|
Commands::Sync { command } => match command {
|
||||||
},
|
SyncCommands::Login {
|
||||||
Commands::Home { command } => match command {
|
server,
|
||||||
HomeCommands::List { json } => account::list_homes(json),
|
user,
|
||||||
HomeCommands::Create {
|
|
||||||
name,
|
|
||||||
path,
|
|
||||||
account,
|
|
||||||
} => account::create_home(&name, path, account),
|
|
||||||
HomeCommands::Bind { name, account } => account::bind_home(&name, &account),
|
|
||||||
HomeCommands::Path { name } => account::home_path(&name),
|
|
||||||
HomeCommands::Remove { name } => account::remove_home(&name),
|
|
||||||
},
|
|
||||||
Commands::Server { command } => match command {
|
|
||||||
ServerCommands::Run { bind, data_dir } => server::run_server(bind, data_dir).await,
|
|
||||||
ServerCommands::User { command } => match command {
|
|
||||||
ServerUserCommands::Add {
|
|
||||||
username,
|
|
||||||
password,
|
password,
|
||||||
data_dir,
|
} => sync_client::login(&server, &user, &password).await,
|
||||||
} => server::add_user(data_dir, &username, &password),
|
SyncCommands::Pull { force } => sync_client::pull(force).await,
|
||||||
|
SyncCommands::Push { force } => sync_client::push(force).await,
|
||||||
|
SyncCommands::Remote { json } => sync_client::remote(json).await,
|
||||||
|
SyncCommands::Status => sync_client::status(),
|
||||||
},
|
},
|
||||||
},
|
Commands::Session { command } => match command {
|
||||||
Commands::Sync { command } => match command {
|
SessionCommands::List { all_homes, json } => {
|
||||||
SyncCommands::Login {
|
session::list_sessions(all_homes, json)
|
||||||
server,
|
|
||||||
user,
|
|
||||||
password,
|
|
||||||
} => sync_client::login(&server, &user, &password).await,
|
|
||||||
SyncCommands::Pull { force } => sync_client::pull(force).await,
|
|
||||||
SyncCommands::Push { force } => sync_client::push(force).await,
|
|
||||||
SyncCommands::Remote { json } => sync_client::remote(json).await,
|
|
||||||
SyncCommands::Status => sync_client::status(),
|
|
||||||
},
|
|
||||||
Commands::Session { command } => match command {
|
|
||||||
SessionCommands::List { all_homes, json } => session::list_sessions(all_homes, json),
|
|
||||||
SessionCommands::Stats {
|
|
||||||
session_id,
|
|
||||||
all_homes,
|
|
||||||
json,
|
|
||||||
} => session::session_stats(&session_id, all_homes, json),
|
|
||||||
SessionCommands::Trash {
|
|
||||||
session_ids,
|
|
||||||
all_homes,
|
|
||||||
} => session::trash_sessions(session_ids, all_homes),
|
|
||||||
SessionCommands::TrashList { all_homes, json } => session::list_trash(all_homes, json),
|
|
||||||
SessionCommands::Restore {
|
|
||||||
session_ids,
|
|
||||||
all_homes,
|
|
||||||
} => session::restore_sessions(session_ids, all_homes),
|
|
||||||
SessionCommands::Visibility { command } => match command {
|
|
||||||
SessionVisibilityCommands::Check { all_homes, json } => {
|
|
||||||
session::visibility_check(all_homes, json)
|
|
||||||
}
|
}
|
||||||
SessionVisibilityCommands::Repair { all_homes, json } => {
|
SessionCommands::Stats {
|
||||||
session::visibility_repair(all_homes, json)
|
session_id,
|
||||||
|
all_homes,
|
||||||
|
json,
|
||||||
|
} => session::session_stats(&session_id, all_homes, json),
|
||||||
|
SessionCommands::Trash {
|
||||||
|
session_ids,
|
||||||
|
all_homes,
|
||||||
|
} => session::trash_sessions(session_ids, all_homes),
|
||||||
|
SessionCommands::TrashList { all_homes, json } => {
|
||||||
|
session::list_trash(all_homes, json)
|
||||||
}
|
}
|
||||||
|
SessionCommands::Restore {
|
||||||
|
session_ids,
|
||||||
|
all_homes,
|
||||||
|
} => session::restore_sessions(session_ids, all_homes),
|
||||||
|
SessionCommands::Visibility { command } => match command {
|
||||||
|
SessionVisibilityCommands::Check { all_homes, json } => {
|
||||||
|
session::visibility_check(all_homes, json)
|
||||||
|
}
|
||||||
|
SessionVisibilityCommands::Repair { all_homes, json } => {
|
||||||
|
session::visibility_repair(all_homes, json)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
SessionCommands::SyncThreads {
|
||||||
|
all_homes,
|
||||||
|
dry_run,
|
||||||
|
json,
|
||||||
|
} => session::sync_threads(all_homes, dry_run, json),
|
||||||
},
|
},
|
||||||
SessionCommands::SyncThreads {
|
|
||||||
all_homes,
|
|
||||||
dry_run,
|
|
||||||
json,
|
|
||||||
} => session::sync_threads(all_homes, dry_run, json),
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
-110
@@ -34,15 +34,6 @@ struct UsageResponse {
|
|||||||
rate_limit: Option<RateLimitInfo>,
|
rate_limit: Option<RateLimitInfo>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize)]
|
|
||||||
struct QuotaDisplay<'a> {
|
|
||||||
id: &'a str,
|
|
||||||
email: &'a str,
|
|
||||||
plan_type: Option<&'a str>,
|
|
||||||
quota: Option<&'a Quota>,
|
|
||||||
error: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct QuotaRefreshReport {
|
pub struct QuotaRefreshReport {
|
||||||
pub errors: Vec<(String, String)>,
|
pub errors: Vec<(String, String)>,
|
||||||
pub changed: bool,
|
pub changed: bool,
|
||||||
@@ -55,95 +46,6 @@ struct QuotaFetchInput {
|
|||||||
account_id: Option<String>,
|
account_id: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn quota_command(accounts: Vec<String>, json: bool) -> Result<()> {
|
|
||||||
let home = crate::paths::codex_home(None)?;
|
|
||||||
let mut store = Store::load(&home)?;
|
|
||||||
let concurrency = store.settings.quota_concurrency;
|
|
||||||
// No account arguments means refresh every saved account. Otherwise refresh
|
|
||||||
// exactly the requested accounts, preserving the user's argument order.
|
|
||||||
let ids = if accounts.is_empty() {
|
|
||||||
store
|
|
||||||
.accounts
|
|
||||||
.iter()
|
|
||||||
.map(|account| account.id.clone())
|
|
||||||
.collect()
|
|
||||||
} else {
|
|
||||||
accounts
|
|
||||||
.iter()
|
|
||||||
.map(|query| {
|
|
||||||
store
|
|
||||||
.find_account(query)
|
|
||||||
.ok_or_else(|| anyhow!("账号不存在: {query}"))
|
|
||||||
.map(|account| account.id.clone())
|
|
||||||
})
|
|
||||||
.collect::<Result<Vec<_>>>()?
|
|
||||||
};
|
|
||||||
|
|
||||||
if ids.is_empty() {
|
|
||||||
return Err(anyhow!("没有可查询的账号"));
|
|
||||||
}
|
|
||||||
|
|
||||||
let report = refresh_quotas(&mut store, &ids, None, concurrency).await;
|
|
||||||
let errors = report.errors;
|
|
||||||
if report.changed {
|
|
||||||
store.save(&home)?;
|
|
||||||
}
|
|
||||||
|
|
||||||
if json {
|
|
||||||
let rows = ids
|
|
||||||
.iter()
|
|
||||||
.filter_map(|id| store.find_account(id))
|
|
||||||
.map(|account| QuotaDisplay {
|
|
||||||
id: &account.id,
|
|
||||||
email: &account.email,
|
|
||||||
plan_type: account.plan_type.as_deref(),
|
|
||||||
quota: account.quota.as_ref(),
|
|
||||||
error: errors
|
|
||||||
.iter()
|
|
||||||
.find(|(id, _)| id == &account.id)
|
|
||||||
.map(|(_, error)| error.clone()),
|
|
||||||
})
|
|
||||||
.collect::<Vec<_>>();
|
|
||||||
println!("{}", serde_json::to_string_pretty(&rows)?);
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
println!(
|
|
||||||
"{:<22} {:<34} {:<12} {:<10} {:<10} {}",
|
|
||||||
"ID", "Email", "Plan", "5h", "Weekly", "Status"
|
|
||||||
);
|
|
||||||
for id in &ids {
|
|
||||||
let Some(account) = store.find_account(id) else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
let error = errors.iter().find(|(err_id, _)| err_id == id);
|
|
||||||
let (primary, secondary) = account
|
|
||||||
.quota
|
|
||||||
.as_ref()
|
|
||||||
.map(|quota| {
|
|
||||||
(
|
|
||||||
format!("{}%", quota.primary_remaining_percent),
|
|
||||||
format!("{}%", quota.secondary_remaining_percent),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
.unwrap_or_else(|| ("-".to_string(), "-".to_string()));
|
|
||||||
println!(
|
|
||||||
"{:<22} {:<34} {:<12} {:<10} {:<10} {}",
|
|
||||||
shorten(&account.id, 22),
|
|
||||||
shorten(&account.email, 34),
|
|
||||||
account.plan_type.as_deref().unwrap_or("-"),
|
|
||||||
primary,
|
|
||||||
secondary,
|
|
||||||
error.map(|(_, error)| error.as_str()).unwrap_or("ok")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if !errors.is_empty() {
|
|
||||||
return Err(anyhow!("部分账号配额刷新失败: {} 个", errors.len()));
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn refresh_stale_quotas(
|
pub async fn refresh_stale_quotas(
|
||||||
store: &mut Store,
|
store: &mut Store,
|
||||||
ids: &[String],
|
ids: &[String],
|
||||||
@@ -429,15 +331,3 @@ fn should_retry_with_refresh(message: &str) -> bool {
|
|||||||
|| lower.contains("token_invalidated")
|
|| lower.contains("token_invalidated")
|
||||||
|| lower.contains("authentication token has been invalidated")
|
|| lower.contains("authentication token has been invalidated")
|
||||||
}
|
}
|
||||||
|
|
||||||
fn shorten(value: &str, width: usize) -> String {
|
|
||||||
if value.chars().count() <= width {
|
|
||||||
return value.to_string();
|
|
||||||
}
|
|
||||||
let mut out = value
|
|
||||||
.chars()
|
|
||||||
.take(width.saturating_sub(1))
|
|
||||||
.collect::<String>();
|
|
||||||
out.push('…');
|
|
||||||
out
|
|
||||||
}
|
|
||||||
|
|||||||
+121
-174
@@ -1,212 +1,159 @@
|
|||||||
//! Execute a child command with a prepared Codex authentication context.
|
//! Run `codex exec` with a prepared authentication context.
|
||||||
|
|
||||||
use std::path::PathBuf;
|
use std::fs;
|
||||||
use std::process::{Command, Stdio};
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::process::Command;
|
||||||
|
|
||||||
use anyhow::{anyhow, Context, Result};
|
use anyhow::{anyhow, Context, Result};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::{
|
use crate::{account, paths};
|
||||||
account,
|
|
||||||
config_store::{AuthMode, Store},
|
|
||||||
paths,
|
|
||||||
};
|
|
||||||
|
|
||||||
pub async fn run_with_account_or_home(
|
pub async fn exec_codex(
|
||||||
account_query: Option<String>,
|
name: String,
|
||||||
home_name: Option<String>,
|
use_home: bool,
|
||||||
codex_home: Option<PathBuf>,
|
temp: bool,
|
||||||
command: Vec<String>,
|
exec_args: Vec<String>,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
if command.is_empty() {
|
let base_home = if use_home {
|
||||||
return Err(anyhow!("缺少要执行的命令,请使用 `-- codex` 形式"));
|
|
||||||
}
|
|
||||||
|
|
||||||
let target_home = if let Some(name) = home_name {
|
|
||||||
// Named homes may have a bound account. If so, refresh and materialize
|
|
||||||
// auth.json before launching the child command.
|
|
||||||
let (home_path, bound_account_id) = account::resolve_home_for_run(&name)?;
|
let (home_path, bound_account_id) = account::resolve_home_for_run(&name)?;
|
||||||
if let Some(account_id) = bound_account_id {
|
ExecBase {
|
||||||
account::prepare_account_in_home(&account_id, home_path.clone()).await?;
|
source_home: home_path,
|
||||||
|
account_query: bound_account_id,
|
||||||
}
|
}
|
||||||
home_path
|
|
||||||
} else {
|
} else {
|
||||||
let account = account_query.ok_or_else(|| anyhow!("run 需要 --account 或 --home"))?;
|
ExecBase {
|
||||||
let home_path = paths::codex_home(codex_home)?;
|
source_home: paths::codex_home(None)?,
|
||||||
account::prepare_account_in_home(&account, home_path.clone()).await?;
|
account_query: Some(name),
|
||||||
home_path
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut child = Command::new(&command[0]);
|
let temp_home = if temp {
|
||||||
child.args(&command[1..]);
|
let temp_home = create_temp_home()?;
|
||||||
// The child process sees only the selected home, so Codex reads the right
|
clone_exec_template(&base_home.source_home, &temp_home)?;
|
||||||
// auth.json and state files without changing the parent shell.
|
Some(temp_home)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
let target_home = temp_home
|
||||||
|
.as_ref()
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_else(|| base_home.source_home.clone());
|
||||||
|
|
||||||
|
if let Some(account_query) = base_home.account_query.as_deref() {
|
||||||
|
account::prepare_account_in_home(account_query, target_home.clone()).await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut child = codex_exec_command(&exec_args);
|
||||||
child.env("CODEX_HOME", &target_home);
|
child.env("CODEX_HOME", &target_home);
|
||||||
|
|
||||||
|
let display = command_display(&exec_args);
|
||||||
let status = child
|
let status = child
|
||||||
.status()
|
.status()
|
||||||
.with_context(|| format!("启动命令失败: {}", command.join(" ")))?;
|
.with_context(|| format!("启动命令失败: {display}"));
|
||||||
|
|
||||||
|
if let Some(path) = temp_home.as_deref() {
|
||||||
|
cleanup_temp_home(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
let status = status?;
|
||||||
if !status.success() {
|
if !status.success() {
|
||||||
return Err(anyhow!("命令退出失败: status={status}"));
|
return Err(anyhow!("命令退出失败: status={status}"));
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn ping_codex(
|
struct ExecBase {
|
||||||
account_queries: Vec<String>,
|
source_home: PathBuf,
|
||||||
concurrency: usize,
|
account_query: Option<String>,
|
||||||
codex_home: Option<PathBuf>,
|
|
||||||
model: String,
|
|
||||||
reasoning_effort: String,
|
|
||||||
prompt: String,
|
|
||||||
) -> Result<()> {
|
|
||||||
ping_codex_many(
|
|
||||||
account_queries,
|
|
||||||
concurrency,
|
|
||||||
codex_home,
|
|
||||||
model,
|
|
||||||
reasoning_effort,
|
|
||||||
prompt,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn ping_codex_many(
|
fn create_temp_home() -> Result<PathBuf> {
|
||||||
account_queries: Vec<String>,
|
let path = std::env::temp_dir().join(format!("cdxs-exec-{}", Uuid::new_v4()));
|
||||||
concurrency: usize,
|
fs::create_dir_all(&path)
|
||||||
codex_home: Option<PathBuf>,
|
.with_context(|| format!("创建临时 CODEX_HOME 失败: {}", path.display()))?;
|
||||||
model: String,
|
Ok(path)
|
||||||
reasoning_effort: String,
|
}
|
||||||
prompt: String,
|
|
||||||
) -> Result<()> {
|
fn clone_exec_template(source_home: &Path, target_home: &Path) -> Result<()> {
|
||||||
let main_home = paths::codex_home(None)?;
|
fs::create_dir_all(target_home)
|
||||||
let base_home = paths::codex_home(codex_home)?;
|
.with_context(|| format!("创建 CODEX_HOME 目录失败: {}", target_home.display()))?;
|
||||||
let store = Store::load(&main_home)?;
|
copy_if_exists(
|
||||||
let account_ids = if account_queries.is_empty() {
|
&paths::auth_path(source_home),
|
||||||
store
|
&paths::auth_path(target_home),
|
||||||
.accounts
|
)?;
|
||||||
.iter()
|
copy_if_exists(
|
||||||
.filter(|account| account.auth_mode == AuthMode::Oauth)
|
&paths::codex_config_path(source_home),
|
||||||
.map(|account| account.id.clone())
|
&paths::codex_config_path(target_home),
|
||||||
.collect::<Vec<_>>()
|
)?;
|
||||||
|
if !source_home.exists() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
for entry in fs::read_dir(source_home)
|
||||||
|
.with_context(|| format!("读取 CODEX_HOME 目录失败: {}", source_home.display()))?
|
||||||
|
{
|
||||||
|
let entry = entry
|
||||||
|
.with_context(|| format!("读取 CODEX_HOME 子项失败: {}", source_home.display()))?;
|
||||||
|
let path = entry.path();
|
||||||
|
if !path.is_file() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let Some(file_name) = path.file_name().and_then(|value| value.to_str()) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if file_name.ends_with(".config.toml") {
|
||||||
|
copy_if_exists(&path, &target_home.join(file_name))?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn copy_if_exists(source: &Path, target: &Path) -> Result<()> {
|
||||||
|
if !source.exists() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
if let Some(parent) = target.parent() {
|
||||||
|
fs::create_dir_all(parent)
|
||||||
|
.with_context(|| format!("创建目录失败: {}", parent.display()))?;
|
||||||
|
}
|
||||||
|
fs::copy(source, target).with_context(|| {
|
||||||
|
format!(
|
||||||
|
"复制文件失败: source={}, target={}",
|
||||||
|
source.display(),
|
||||||
|
target.display()
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cleanup_temp_home(path: &Path) {
|
||||||
|
if let Err(error) = fs::remove_dir_all(path) {
|
||||||
|
eprintln!(
|
||||||
|
"提示: 清理临时 CODEX_HOME 失败: {}: {error}",
|
||||||
|
path.display()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn command_display(args: &[String]) -> String {
|
||||||
|
if args.is_empty() {
|
||||||
|
"codex exec".to_string()
|
||||||
} else {
|
} else {
|
||||||
account_queries
|
format!("codex exec {}", args.join(" "))
|
||||||
.iter()
|
|
||||||
.map(|query| {
|
|
||||||
store
|
|
||||||
.find_account(query)
|
|
||||||
.ok_or_else(|| anyhow!("账号不存在: {query}"))
|
|
||||||
.map(|account| account.id.clone())
|
|
||||||
})
|
|
||||||
.collect::<Result<Vec<_>>>()?
|
|
||||||
};
|
|
||||||
if account_ids.is_empty() {
|
|
||||||
return Err(anyhow!("没有可 ping 的 OAuth 账号"));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut jobs = Vec::new();
|
|
||||||
for account_id in &account_ids {
|
|
||||||
let ping_home = base_home
|
|
||||||
.join("cdxs-ping")
|
|
||||||
.join(safe_path_component(account_id));
|
|
||||||
account::prepare_account_in_home(account_id, ping_home.clone()).await?;
|
|
||||||
jobs.push((account_id.clone(), ping_home));
|
|
||||||
}
|
|
||||||
|
|
||||||
let concurrency = concurrency.max(1);
|
|
||||||
let mut failures = Vec::new();
|
|
||||||
for chunk in jobs.chunks(concurrency) {
|
|
||||||
let mut handles = Vec::new();
|
|
||||||
for (account_id, ping_home) in chunk.iter().cloned() {
|
|
||||||
let model = model.clone();
|
|
||||||
let reasoning_effort = reasoning_effort.clone();
|
|
||||||
let prompt = prompt.clone();
|
|
||||||
handles.push(tokio::spawn(async move {
|
|
||||||
let result = run_codex_ping(ping_home, model, reasoning_effort, prompt, true).await;
|
|
||||||
(account_id, result)
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
for handle in handles {
|
|
||||||
match handle.await {
|
|
||||||
Ok((account_id, Ok(()))) => println!("ping ok: {account_id}"),
|
|
||||||
Ok((account_id, Err(error))) => {
|
|
||||||
eprintln!("ping failed: {account_id}: {error}");
|
|
||||||
failures.push(account_id);
|
|
||||||
}
|
|
||||||
Err(error) => {
|
|
||||||
eprintln!("ping task failed: {error}");
|
|
||||||
failures.push("<unknown>".to_string());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if !failures.is_empty() {
|
|
||||||
return Err(anyhow!("部分账号 ping 失败: {} 个", failures.len()));
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn run_codex_ping(
|
|
||||||
codex_home: PathBuf,
|
|
||||||
model: String,
|
|
||||||
reasoning_effort: String,
|
|
||||||
prompt: String,
|
|
||||||
quiet: bool,
|
|
||||||
) -> Result<()> {
|
|
||||||
let command_args = vec![
|
|
||||||
"codex".to_string(),
|
|
||||||
"exec".to_string(),
|
|
||||||
"--ignore-user-config".to_string(),
|
|
||||||
"-c".to_string(),
|
|
||||||
format!("model_reasoning_effort=\"{reasoning_effort}\""),
|
|
||||||
"--model".to_string(),
|
|
||||||
model,
|
|
||||||
"--skip-git-repo-check".to_string(),
|
|
||||||
prompt,
|
|
||||||
];
|
|
||||||
let status = tokio::task::spawn_blocking(move || {
|
|
||||||
let mut child = codex_exec_command(&command_args);
|
|
||||||
child.env("CODEX_HOME", &codex_home);
|
|
||||||
if quiet {
|
|
||||||
child.stdout(Stdio::null()).stderr(Stdio::null());
|
|
||||||
}
|
|
||||||
child
|
|
||||||
.status()
|
|
||||||
.with_context(|| format!("启动命令失败: {}", command_args.join(" ")))
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
.context("ping task join failed")??;
|
|
||||||
|
|
||||||
if !status.success() {
|
|
||||||
return Err(anyhow!("命令退出失败: status={status}"));
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn safe_path_component(value: &str) -> String {
|
|
||||||
value
|
|
||||||
.chars()
|
|
||||||
.map(|ch| {
|
|
||||||
if ch.is_ascii_alphanumeric() || ch == '_' || ch == '-' {
|
|
||||||
ch
|
|
||||||
} else {
|
|
||||||
'_'
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.collect()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn codex_exec_command(args: &[String]) -> Command {
|
fn codex_exec_command(args: &[String]) -> Command {
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
{
|
{
|
||||||
let mut command = Command::new("cmd.exe");
|
let mut command = Command::new("cmd.exe");
|
||||||
command.arg("/C").args(args);
|
command.arg("/C").arg("codex").arg("exec").args(args);
|
||||||
command
|
command
|
||||||
}
|
}
|
||||||
#[cfg(not(windows))]
|
#[cfg(not(windows))]
|
||||||
{
|
{
|
||||||
let mut command = Command::new(&args[0]);
|
let mut command = Command::new("codex");
|
||||||
command.args(&args[1..]);
|
command.arg("exec").args(args);
|
||||||
command
|
command
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user