Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
3aa762b
platform: find node next to npm-installed CLIs
razmser Oct 30, 2026
0093337
platform: skip unusable PATH entries
razmser Oct 30, 2026
0a4f468
detection: Claude Desktop version is optional without dpkg-query
razmser Oct 30, 2026
d7bd3b6
config files: read symlinked configs, refuse to replace them
razmser Oct 30, 2026
7bc3167
backups: never reuse or replace a backup, back up only changed files
razmser Oct 30, 2026
adae5d4
config files: keep JSON key order
razmser Oct 30, 2026
3f8edcd
claude desktop: remove only CoreInfra's connection
razmser Oct 30, 2026
2a1cc03
claude desktop: recheck the installed version before replacing it
razmser Oct 30, 2026
8ab3d9b
codex: fix ChatGPT proxy cleanup and step order
razmser Oct 30, 2026
23942dc
codex: refuse to enable when ChatGPT was never launched
razmser Oct 30, 2026
053bfad
cli: refuse Codex changes while the GUI's ChatGPT proxy is on
razmser Oct 30, 2026
125c785
pi: remove the CoreInfra key without the plugin
razmser Oct 30, 2026
7df109f
gui: start a stopped proxy on apply, per app
razmser Oct 30, 2026
2b6539a
gui: keep observing agent state after an error
razmser Oct 30, 2026
53db413
hub tokens: ignore stale 401s and logouts during issue
razmser Oct 30, 2026
4dbe114
ui: retry loading Hub tokens
razmser Oct 30, 2026
0b63151
hub login: cap the callback request head
razmser Oct 30, 2026
d140dd1
remove dead code
razmser Oct 30, 2026
3f2abfb
xtask: fetch dev tags in one request
razmser Oct 30, 2026
d771583
ci: serialize dev releases
razmser Oct 30, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .github/workflows/dev-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ permissions:
contents: write

concurrency:
group: dev-release-${{ github.sha }}
# One group for all commits: concurrent runs could publish latest-dev.json out of order.
group: dev-release
cancel-in-progress: false

jobs:
Expand Down
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions crates/wizard-cli/src/agent.rs
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,11 @@ pub(crate) fn run(
AgentCommand::Codex { command } => {
let (mode, args) = routing_mode(&command);
let settings = settings::load_from_file()?;
// The CLI cannot update ChatGPT's profile or the GUI's listener that it uses.
ensure!(
settings.codex_proxy_enabled.is_none(),
"Настройка Codex доступна только в GUI, пока в нём включён прокси для ChatGPT."
);
let Some(token) = resolve_token(
runtime,
yes,
Expand Down
2 changes: 1 addition & 1 deletion crates/wizard-core/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ log.workspace = true
regex.workspace = true
reqwest.workspace = true
serde.workspace = true
serde_json.workspace = true
serde_json = { workspace = true, features = ["preserve_order"] } # keep key order in user files
semver.workspace = true
sha2.workspace = true
jsonc-parser.workspace = true
Expand Down
2 changes: 1 addition & 1 deletion crates/wizard-core/src/agents/claude.rs
Original file line number Diff line number Diff line change
Expand Up @@ -189,7 +189,7 @@ fn remove_inactive_routing_settings(env: &mut Map<String, Value>, current_mode:
],
};
for key in previous_keys {
env.remove(*key);
env.shift_remove(*key);
}
}

Expand Down
23 changes: 15 additions & 8 deletions crates/wizard-core/src/agents/claude_desktop.rs
Original file line number Diff line number Diff line change
Expand Up @@ -172,9 +172,12 @@ pub fn prepare_routing(mode: RoutingMode, token: &str) -> Result<PreparedPlan> {
}

let proxy_url = format!("http://{}", wizard_proxy::CLAUDE_DESKTOP_ADDRESS);
// Wizard points every profile it configures at its proxy.
let managed = json::parse(&config.after)?["egressProxyUrl"] == proxy_url;
json::update(&mut config.after, |config| {
let remove = mode != RoutingMode::Hub;
let remove_proxy = mode == RoutingMode::Disabled && config["egressProxyUrl"] == proxy_url;
// Remove only CoreInfra's connection; another gateway belongs to the user and is not
// backed up. Claude ignores inference settings in the 1p mode that Sub selects.
let remove = mode != RoutingMode::Hub && has_hub_connection(config);

let object = config
.as_object_mut()
Expand All @@ -188,27 +191,31 @@ pub fn prepare_routing(mode: RoutingMode, token: &str) -> Result<PreparedPlan> {
object.insert("inferenceGatewayAuthScheme".into(), json!("x-api-key"));
} else if remove {
for key in CONNECTION_KEYS {
object.remove(key);
object.shift_remove(key);
}
}

if mode == RoutingMode::Sub {
object.insert("disableAutoUpdates".into(), json!(true));
} else if managed {
// Undo Sub's override only in a profile Wizard configured.
object.shift_remove("disableAutoUpdates");
}

if mode != RoutingMode::Disabled {
object.insert("egressProxyUrl".into(), json!(proxy_url));
object.remove("egressProxyPacUrl");
} else if remove_proxy {
object.remove("egressProxyUrl");
object.shift_remove("egressProxyPacUrl");
} else if managed {
object.shift_remove("egressProxyUrl");
}
Ok(())
})?;

if mode != RoutingMode::Disabled {
// Disabled returns a Wizard profile to the Claude account (1p), where sign-in works.
if mode != RoutingMode::Disabled || managed {
json::update(&mut preferences.after, |preferences| {
preferences["deploymentMode"] =
json!(if mode == RoutingMode::Sub { "1p" } else { "3p" });
json!(if mode == RoutingMode::Hub { "3p" } else { "1p" });
Ok(())
})?;
}
Expand Down
6 changes: 6 additions & 0 deletions crates/wizard-core/src/agents/claude_desktop_install.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1087,6 +1087,12 @@ fn install_release_inner(download: &DownloadedRelease) -> Result<()> {
)?;

verify_bundle(&staged, &release.version)?;
// Claude may have updated itself since the check; never replace a different version.
let installed = detect_target(release.target.path.as_deref())?;
ensure!(
installed.version == release.target.version,
"Claude Desktop изменился после проверки обновления; проверьте обновление снова"
);
// Check all Claude instances, including a running app at a different path. Never terminate them.
ensure!(
!app_is_running(BUNDLE_ID),
Expand Down
46 changes: 35 additions & 11 deletions crates/wizard-core/src/agents/codex.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ use crate::{
changes::{FileChange, FileSnapshot},
},
platform::{
command_output, daemon_command_status, env_var_not_empty, find_executable, is_executable,
append_node_path, command_output, daemon_command_status, env_var_not_empty,
find_executable, is_executable,
},
prepared_plan::{PreparedAgent, PreparedPlan, PreparedStep},
};
Expand Down Expand Up @@ -119,6 +120,7 @@ pub fn restart_daemon(executable: &Path, codex_home: &Path) -> Result<()> {
// daemon on updates. It keeps variables later removed from `.env`, and one started by an
// older Wizard stays attributed to Wizard by macOS. Bootstrap resets remote control, though.
let mut command = tokio::process::Command::new(executable);
append_node_path(&mut command)?;
command
.args(["app-server", "daemon", "bootstrap"])
.env("CODEX_HOME", codex_home);
Expand Down Expand Up @@ -163,7 +165,8 @@ fn managed_daemon_running(executable: &Path, codex_home: &Path) -> bool {
// TODO: Distinguish an absent daemon from command failures and unsupported versions.
// For now, spawn errors, timeouts, nonzero exit statuses and unmanaged servers all mean
// "no daemon".
command_output(probe, Duration::from_secs(15))
append_node_path(&mut probe)
.and_then(|()| command_output(probe, Duration::from_secs(15)))
.ok()
.filter(|output| output.status.success())
.and_then(|output| serde_json::from_slice::<Version>(&output.stdout).ok())
Expand Down Expand Up @@ -242,18 +245,33 @@ pub fn prepare_routing_for_gui(
settings.last_paths.codex_desktop.as_deref(),
) {
AgentDetection::Found(_) => true,
AgentDetection::NotFound => return Ok(plan),
// Clean up a configuration left from before ChatGPT was removed.
AgentDetection::NotFound => false,
AgentDetection::Error(error) => {
anyhow::bail!("failed to detect ChatGPT for local proxy: {error}")
}
}
};
let path = desktop_preferences_path()?.context("ChatGPT profile location is unavailable")?;
let change = prepare_desktop_proxy(path, enabled)?;
plan.steps.insert(0, PreparedStep::ChangeFile { change });
plan.steps.push(PreparedStep::SetCodexProxyEnabled {
token: enabled.then(|| token.to_owned()),
});
// Without ChatGPT installed (Windows), there is no profile to clean up when disabling.
match desktop_preferences_path()? {
Some(path) => {
let change = prepare_desktop_proxy(path, enabled)?;
plan.steps.insert(0, PreparedStep::ChangeFile { change });
}
None => ensure!(!enabled, "ChatGPT profile location is unavailable"),
}
// Before the daemon restart: it can fail after Preferences already points at the listener.
let restart = plan
.steps
.iter()
.position(|step| matches!(step, PreparedStep::CodexRestartDaemon { .. }))
.unwrap_or(plan.steps.len());
plan.steps.insert(
restart,
PreparedStep::SetCodexProxyEnabled {
token: enabled.then(|| token.to_owned()),
},
);
Ok(plan)
}

Expand Down Expand Up @@ -297,7 +315,13 @@ fn prepare_desktop_proxy(path: PathBuf, enabled: bool) -> Result<FileChange> {
use serde_json::json;

let mut change = FileChange::read(std::path::absolute(path)?)?;
// ChatGPT creates its profile on first launch; until then there is nothing to clean up.
if matches!(change.after, FileSnapshot::Missing) {
// Linux detection accepts any `chatgpt` executable, which may be another program.
ensure!(
!enabled || cfg!(target_os = "linux"),
"ChatGPT ещё не запускался: откройте его один раз и повторите."
);
log::info!("ChatGPT proxy configuration skipped: profile Preferences file is missing");
return Ok(change);
}
Expand Down Expand Up @@ -326,7 +350,7 @@ fn prepare_desktop_proxy(path: PathBuf, enabled: bool) -> Result<FileChange> {
config
.as_object_mut()
.context("ChatGPT Preferences must contain an object")?
.remove("proxy");
.shift_remove("proxy");
}
Ok(())
})?;
Expand Down Expand Up @@ -373,7 +397,7 @@ fn daemon_executable(settings: &Settings) -> Result<PathBuf> {

for relative in candidates {
let path = resources.join(relative);
if is_executable(&path)? {
if is_executable(&path) {
return std::path::absolute(path).context("failed to resolve bundled Codex executable");
}
}
Expand Down
47 changes: 24 additions & 23 deletions crates/wizard-core/src/agents/detection.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
use super::AgentDetection;
#[cfg(target_os = "macos")]
use crate::platform::env_var_not_empty;
use crate::platform::{append_command_path, command_output, find_executable};
use crate::platform::{append_node_path, command_output, find_executable};
use anyhow::{Context as _, Result};
use core::time::Duration;
use serde::Serialize;
Expand Down Expand Up @@ -90,12 +90,7 @@ fn cli_version(agent: Agent, path: &Path) -> Result<String> {
.arg("--version")
.stdout(Stdio::piped())
.stderr(Stdio::piped());
if let Some(node) = find_executable("node", vec![], None)? {
let directory = node
.parent()
.context("Node executable has no parent directory")?;
append_command_path(&mut command, &[directory])?;
}
append_node_path(&mut command)?;
let output = command_output(command, Duration::from_secs(5))?;
anyhow::ensure!(
output.status.success(),
Expand Down Expand Up @@ -130,10 +125,12 @@ pub(super) fn detect_desktop(
) -> AgentDetection<AgentInfo> {
#[cfg(target_os = "macos")]
{
let app_path = Path::new(match agent {
DesktopAgent::Codex => "/Applications/ChatGPT.app",
DesktopAgent::Claude => CLAUDE_APP_PATH,
});
let (app_path, bundle_id) = match agent {
// The classic ChatGPT app (com.openai.chat) has the same name.
DesktopAgent::Codex => ("/Applications/ChatGPT.app", Some("com.openai.codex")),
DesktopAgent::Claude => (CLAUDE_APP_PATH, None),
};
let app_path = Path::new(app_path);
let app_name = app_path
.file_name()
.expect("desktop app path has a filename");
Expand All @@ -149,10 +146,17 @@ pub(super) fn detect_desktop(
Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
Err(error) => return AgentDetection::failed(&path, &error),
}
return match crate::platform::macos::read_app_version(&path) {
Ok(version) => AgentDetection::Found(AgentInfo { path, version }),
Err(error) => AgentDetection::failed(&path, &error),
let info = match crate::platform::macos::read_app_info(&path) {
Ok(info) => info,
Err(error) => return AgentDetection::failed(&path, &error),
};
if bundle_id.is_some_and(|id| info.identifier.as_deref() != Some(id)) {
continue;
}
return AgentDetection::Found(AgentInfo {
path,
version: info.version,
});
}
AgentDetection::NotFound
}
Expand All @@ -176,18 +180,15 @@ pub(super) fn detect_desktop(
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::null());
let output = match command_output(command, Duration::from_secs(5)) {
Ok(output) => output,
Err(error) => return AgentDetection::failed(&path, &error),
};
if output.status.success() {
String::from_utf8(output.stdout).ok().and_then(|text| {
// The version is optional: without dpkg-query (non-Debian systems) it is unknown.
command_output(command, Duration::from_secs(5))
.ok()
.filter(|output| output.status.success())
.and_then(|output| String::from_utf8(output.stdout).ok())
.and_then(|text| {
text.strip_prefix("install ok installed ")
.map(|version| version.trim().to_owned())
})
} else {
None
}
} else {
None
};
Expand Down
16 changes: 5 additions & 11 deletions crates/wizard-core/src/agents/opencode.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,7 @@ use super::{
};
use crate::{
config_files::{self, changes::FileChange},
platform::{
append_command_path, command_output, command_output_with_stdin, env_var_not_empty,
find_executable,
},
platform::{append_node_path, command_output, command_output_with_stdin, env_var_not_empty},
prepared_plan::{PreparedAgent, PreparedPlan, PreparedStep},
};
use anyhow::{Context as _, Result, bail, ensure};
Expand Down Expand Up @@ -238,7 +235,7 @@ pub fn prepare_hub(mode: HubMode, token: &str, settings: &Settings) -> Result<Pr
if mode == HubMode::Hub {
credentials.insert(INTEGRATION.into(), json!({"type": "api", "key": token}));
} else {
credentials.remove(INTEGRATION);
credentials.shift_remove(INTEGRATION);
}
Ok(())
})?;
Expand Down Expand Up @@ -310,12 +307,7 @@ pub fn install_plugin(executable: &Path, config_dir: &Path) -> Result<()> {
let cwd = tempfile::tempdir().context("failed to create OpenCode install working directory")?;

let mut command = tokio::process::Command::new(executable);
if let Some(node) = find_executable("node", vec![], None)? {
append_command_path(
&mut command,
&[node.parent().context("Node executable has no parent")?],
)?;
}
append_node_path(&mut command)?;
command
.args(["--pure", "plugin", "-g", "--force", INSTALL_SOURCE])
.env(
Expand Down Expand Up @@ -477,6 +469,7 @@ fn run_cli(
let cwd = tempfile::tempdir().context("failed to create OpenCode working directory")?;

let mut command = tokio::process::Command::new(executable);
append_node_path(&mut command)?;
command
.args(args)
.env_remove("OPENCODE_CONFIG")
Expand Down Expand Up @@ -517,6 +510,7 @@ pub fn add_plugin(executable: &Path, config_dir: &Path) -> Result<()> {
let cwd = tempfile::tempdir().context("failed to create OpenCode install working directory")?;

let mut command = tokio::process::Command::new(executable);
append_node_path(&mut command)?;
command
.args(["plugin", "add", INSTALL_SOURCE])
.env(
Expand Down
14 changes: 10 additions & 4 deletions crates/wizard-core/src/agents/pi.rs
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,7 @@ pub fn prepare_hub(mode: HubMode, token: &str, settings: &Settings) -> Result<Pr
json!({"type": "api_key", "key": token}),
);
} else {
object.remove("coreinfra");
object.shift_remove("coreinfra");
}
Ok(())
})?;
Expand Down Expand Up @@ -154,7 +154,7 @@ pub fn prepare_hub(mode: HubMode, token: &str, settings: &Settings) -> Result<Pr
});
}

if !steps.is_empty() && auth.is_changed() {
if auth.is_changed() {
steps.push(PreparedStep::PiApplyAuth { dir, change: auth });
}
Ok(PreparedPlan::new(PreparedAgent::Pi, steps))
Expand All @@ -171,8 +171,14 @@ pub fn remove_plugin(executable: &Path, agent_dir: &Path) -> Result<()> {
}

fn run_package_command(executable: &Path, dir: &Path, action: &str) -> Result<()> {
let node = find_executable("node", vec![], None)?.context("Node executable not found")?;
let npm = find_executable("npm", vec![], None)?.context("npm executable not found")?;
// Version managers (nvm, fnm, Volta) keep node and npm next to the Pi CLI.
let near: Vec<_> = executable
.parent()
.map(Path::to_owned)
.into_iter()
.collect();
let node = find_executable("node", near.clone(), None)?.context("Node executable not found")?;
let npm = find_executable("npm", near, None)?.context("npm executable not found")?;
let paths = [
node.parent().context("Node executable has no parent")?,
npm.parent().context("npm executable has no parent")?,
Expand Down
Loading
Loading