Merge pull request #2472 from Hmbown/codex/update-check-config
Make startup update checks configurable
This commit is contained in:
@@ -71,6 +71,16 @@ reasoning_effort = "max"
|
||||
# Display estimated usage in USD or CNY. Aliases `yuan` and `rmb` normalize to `cny`.
|
||||
cost_currency = "usd" # usd | cny
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────────
|
||||
# Startup update check
|
||||
# ─────────────────────────────────────────────────────────────────────────────────
|
||||
# The TUI checks for newer CodeWhale releases in the background at startup.
|
||||
# Set check_for_updates = false in managed or air-gapped environments.
|
||||
# update_uri may point at a GitHub-compatible latest-release JSON endpoint.
|
||||
[update]
|
||||
check_for_updates = true
|
||||
# update_uri = "https://internal.mirror.example/codewhale/releases/latest"
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────────
|
||||
# Paths
|
||||
# ─────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -1287,6 +1287,40 @@ pub struct AutoConfig {
|
||||
pub cost_saving: Option<bool>,
|
||||
}
|
||||
|
||||
fn default_update_check_for_updates() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
/// Startup update-check configuration (`[update]` table in config.toml).
|
||||
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
|
||||
pub struct UpdateConfig {
|
||||
/// When false, skip the TUI startup background update check entirely.
|
||||
#[serde(default = "default_update_check_for_updates")]
|
||||
pub check_for_updates: bool,
|
||||
/// Optional GitHub-compatible latest-release JSON endpoint.
|
||||
#[serde(default)]
|
||||
pub update_uri: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for UpdateConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
check_for_updates: true,
|
||||
update_uri: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl UpdateConfig {
|
||||
#[must_use]
|
||||
pub fn update_uri(&self) -> Option<&str> {
|
||||
self.update_uri
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolved CLI configuration, including defaults and environment overrides.
|
||||
#[derive(Debug, Clone, Default, Deserialize)]
|
||||
pub struct Config {
|
||||
@@ -1395,6 +1429,11 @@ pub struct Config {
|
||||
#[serde(default)]
|
||||
pub auto: Option<AutoConfig>,
|
||||
|
||||
/// Startup update-check behavior. When absent, the TUI keeps the default
|
||||
/// fire-and-forget latest-release check.
|
||||
#[serde(default)]
|
||||
pub update: Option<UpdateConfig>,
|
||||
|
||||
/// Post-edit LSP diagnostics injection (#136). When absent, the engine
|
||||
/// applies the defaults documented in [`LspConfigToml`].
|
||||
#[serde(default)]
|
||||
@@ -2458,6 +2497,12 @@ impl Config {
|
||||
self.snapshots.clone().unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Resolve startup update-check settings with defaults applied.
|
||||
#[must_use]
|
||||
pub fn update_config(&self) -> UpdateConfig {
|
||||
self.update.clone().unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Resolve enabled features from defaults and config entries.
|
||||
#[must_use]
|
||||
pub fn features(&self) -> Features {
|
||||
@@ -2696,6 +2741,11 @@ default_text_model = "{DEFAULT_TEXT_MODEL}"
|
||||
# "auto" | "off" | "low" | "medium" | "high" | "max"
|
||||
# Shift+Tab in the TUI cycles between off / high / max.
|
||||
reasoning_effort = "auto"
|
||||
|
||||
# Startup update check
|
||||
[update]
|
||||
check_for_updates = true
|
||||
# update_uri = "https://internal.mirror.example/codewhale/releases/latest"
|
||||
"#
|
||||
);
|
||||
write_config_file_secure(&config_path, &content)
|
||||
@@ -3717,6 +3767,7 @@ fn merge_config(base: Config, override_cfg: Config) -> Config {
|
||||
search: override_cfg.search.or(base.search),
|
||||
memory: override_cfg.memory.or(base.memory),
|
||||
auto: override_cfg.auto.or(base.auto),
|
||||
update: override_cfg.update.or(base.update),
|
||||
lsp: override_cfg.lsp.or(base.lsp),
|
||||
context: ContextConfig {
|
||||
enabled: override_cfg.context.enabled.or(base.context.enabled),
|
||||
@@ -4712,6 +4763,34 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_config_defaults_to_enabled_without_uri() {
|
||||
let config = Config::default();
|
||||
assert_eq!(config.update, None);
|
||||
assert_eq!(config.update_config(), UpdateConfig::default());
|
||||
assert!(config.update_config().check_for_updates);
|
||||
assert_eq!(config.update_config().update_uri(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_config_deserializes_disable_and_custom_uri() {
|
||||
let config: Config = toml::from_str(
|
||||
r#"
|
||||
[update]
|
||||
check_for_updates = false
|
||||
update_uri = "https://mirror.example/releases/latest"
|
||||
"#,
|
||||
)
|
||||
.expect("update config");
|
||||
|
||||
let update = config.update_config();
|
||||
assert!(!update.check_for_updates);
|
||||
assert_eq!(
|
||||
update.update_uri(),
|
||||
Some("https://mirror.example/releases/latest")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn network_policy_toml_maps_proxy_hosts_to_runtime_policy() {
|
||||
let policy: NetworkPolicyToml = toml::from_str(
|
||||
|
||||
+123
-35
@@ -6,7 +6,7 @@ use std::path::PathBuf;
|
||||
use std::sync::{Arc, LazyLock};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use anyhow::Result;
|
||||
use anyhow::{Context, Result};
|
||||
// On Windows the push/pop helpers write the escapes directly; crossterm's
|
||||
// PushKeyboardEnhancementFlags / PopKeyboardEnhancementFlags commands are
|
||||
// never referenced, so the imports are gated to avoid -D warnings failures.
|
||||
@@ -43,7 +43,7 @@ use crate::commands;
|
||||
use crate::compaction::estimate_input_tokens_conservative;
|
||||
use crate::config::{
|
||||
ApiProvider, Config, DEFAULT_NVIDIA_NIM_BASE_URL, ProviderConfig, ProvidersConfig, StatusItem,
|
||||
save_provider_auth_mode_for,
|
||||
UpdateConfig, save_provider_auth_mode_for,
|
||||
};
|
||||
use crate::config_ui::{self, ConfigUiMode, WebConfigSession, WebConfigSessionEvent};
|
||||
use crate::core::engine::{EngineConfig, EngineHandle, spawn_engine};
|
||||
@@ -999,27 +999,8 @@ async fn run_event_loop(
|
||||
// Fire-and-forget version check — runs once per session in the
|
||||
// background. On success, a short status toast advertises the update
|
||||
// without replacing the user's configured footer/status-line chips.
|
||||
let mut version_check: Option<tokio::task::JoinHandle<Option<String>>> = Some({
|
||||
let current = env!("CARGO_PKG_VERSION").to_string();
|
||||
tokio::spawn(async move {
|
||||
let client = match reqwest::Client::builder()
|
||||
.user_agent("codewhale-version-check")
|
||||
.timeout(std::time::Duration::from_secs(5))
|
||||
.build()
|
||||
{
|
||||
Ok(c) => c,
|
||||
Err(_) => return None,
|
||||
};
|
||||
let resp = client
|
||||
.get("https://api.github.com/repos/Hmbown/CodeWhale/releases/latest")
|
||||
.header("Accept", "application/vnd.github+json")
|
||||
.send()
|
||||
.await
|
||||
.ok()?;
|
||||
let json: serde_json::Value = resp.json().await.ok()?;
|
||||
version_hint_from_release_json(&json, ¤t)
|
||||
})
|
||||
});
|
||||
let mut version_check: Option<tokio::task::JoinHandle<Option<String>>> =
|
||||
spawn_startup_version_check(config.update_config());
|
||||
|
||||
// Fire a one-shot initial balance fetch for DeepSeek providers
|
||||
// so the footer chip shows balance on the first frame without
|
||||
@@ -8668,12 +8649,119 @@ fn extract_reasoning_header(text: &str) -> Option<String> {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
enum StartupVersionCheckSource {
|
||||
Disabled,
|
||||
ConfiguredUrl(String),
|
||||
ReleaseResolver,
|
||||
}
|
||||
|
||||
fn startup_version_check_source(config: &UpdateConfig) -> StartupVersionCheckSource {
|
||||
if !config.check_for_updates {
|
||||
return StartupVersionCheckSource::Disabled;
|
||||
}
|
||||
if let Some(update_uri) = config.update_uri() {
|
||||
return StartupVersionCheckSource::ConfiguredUrl(update_uri.to_string());
|
||||
}
|
||||
StartupVersionCheckSource::ReleaseResolver
|
||||
}
|
||||
|
||||
fn spawn_startup_version_check(
|
||||
config: UpdateConfig,
|
||||
) -> Option<tokio::task::JoinHandle<Option<String>>> {
|
||||
let source = startup_version_check_source(&config);
|
||||
if source == StartupVersionCheckSource::Disabled {
|
||||
return None;
|
||||
}
|
||||
|
||||
let current = env!("CARGO_PKG_VERSION").to_string();
|
||||
Some(tokio::spawn(async move {
|
||||
version_hint_from_startup_source(source, ¤t).await
|
||||
}))
|
||||
}
|
||||
|
||||
async fn version_hint_from_startup_source(
|
||||
source: StartupVersionCheckSource,
|
||||
current: &str,
|
||||
) -> Option<String> {
|
||||
match source {
|
||||
StartupVersionCheckSource::Disabled => None,
|
||||
StartupVersionCheckSource::ConfiguredUrl(url) => {
|
||||
match version_hint_from_configured_update_uri(&url, current).await {
|
||||
Ok(hint) => hint,
|
||||
Err(_) => version_hint_from_release_mirror_env(current).await,
|
||||
}
|
||||
}
|
||||
StartupVersionCheckSource::ReleaseResolver => {
|
||||
if release_mirror_env_configured() {
|
||||
return version_hint_from_release_mirror_env(current).await;
|
||||
}
|
||||
|
||||
let body = codewhale_release::fetch_release_json_async(
|
||||
codewhale_release::LATEST_RELEASE_URL,
|
||||
"latest release",
|
||||
)
|
||||
.await
|
||||
.ok()?;
|
||||
let json: serde_json::Value = serde_json::from_str(&body).ok()?;
|
||||
version_hint_from_release_json(&json, current)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn version_hint_from_release_mirror_env(current: &str) -> Option<String> {
|
||||
if !release_mirror_env_configured() {
|
||||
return None;
|
||||
}
|
||||
let tag =
|
||||
codewhale_release::latest_release_tag_async(codewhale_release::ReleaseChannel::Stable)
|
||||
.await
|
||||
.ok()?;
|
||||
version_hint_from_latest_tag(&tag, current)
|
||||
}
|
||||
|
||||
fn release_mirror_env_configured() -> bool {
|
||||
let version = codewhale_release::update_version_from_env()
|
||||
.unwrap_or_else(|| env!("CARGO_PKG_VERSION").to_string());
|
||||
codewhale_release::release_base_url_from_env(&version).is_some()
|
||||
}
|
||||
|
||||
async fn version_hint_from_configured_update_uri(
|
||||
update_uri: &str,
|
||||
current: &str,
|
||||
) -> Result<Option<String>> {
|
||||
let body = codewhale_release::fetch_release_json_async(update_uri, "configured latest release")
|
||||
.await?;
|
||||
let json: serde_json::Value = serde_json::from_str(&body).with_context(|| {
|
||||
format!("failed to parse release JSON from configured URI {update_uri}")
|
||||
})?;
|
||||
Ok(version_hint_from_custom_release_json(&json, current))
|
||||
}
|
||||
|
||||
fn version_hint_from_release_json(json: &serde_json::Value, current: &str) -> Option<String> {
|
||||
if !release_has_required_assets(json) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let tag = json["tag_name"].as_str()?;
|
||||
version_hint_from_latest_tag(tag, current)
|
||||
}
|
||||
|
||||
fn version_hint_from_custom_release_json(
|
||||
json: &serde_json::Value,
|
||||
current: &str,
|
||||
) -> Option<String> {
|
||||
if !release_is_publishable(json) {
|
||||
return None;
|
||||
}
|
||||
if json.get("assets").is_some() && !release_has_required_assets(json) {
|
||||
return None;
|
||||
}
|
||||
let tag = json["tag_name"].as_str()?;
|
||||
version_hint_from_latest_tag(tag, current)
|
||||
}
|
||||
|
||||
fn version_hint_from_latest_tag(tag: &str, current: &str) -> Option<String> {
|
||||
let latest = tag.trim_start_matches('v');
|
||||
if !is_newer_version(latest, current) {
|
||||
return None;
|
||||
@@ -8685,18 +8773,7 @@ fn version_hint_from_release_json(json: &serde_json::Value, current: &str) -> Op
|
||||
}
|
||||
|
||||
fn release_has_required_assets(json: &serde_json::Value) -> bool {
|
||||
if json
|
||||
.get("draft")
|
||||
.and_then(serde_json::Value::as_bool)
|
||||
.unwrap_or(false)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if json
|
||||
.get("prerelease")
|
||||
.and_then(serde_json::Value::as_bool)
|
||||
.unwrap_or(false)
|
||||
{
|
||||
if !release_is_publishable(json) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -8705,6 +8782,17 @@ fn release_has_required_assets(json: &serde_json::Value) -> bool {
|
||||
.all(|required| release_has_uploaded_asset(json, required))
|
||||
}
|
||||
|
||||
fn release_is_publishable(json: &serde_json::Value) -> bool {
|
||||
!json
|
||||
.get("draft")
|
||||
.and_then(serde_json::Value::as_bool)
|
||||
.unwrap_or(false)
|
||||
&& !json
|
||||
.get("prerelease")
|
||||
.and_then(serde_json::Value::as_bool)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn release_has_uploaded_asset(json: &serde_json::Value, required: &str) -> bool {
|
||||
let Some(assets) = json.get("assets").and_then(serde_json::Value::as_array) else {
|
||||
return false;
|
||||
|
||||
@@ -2868,6 +2868,45 @@ fn version_hint_ignores_draft_prerelease_and_current_versions() {
|
||||
assert!(version_hint_from_release_json(¤t, "0.8.46").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn startup_version_check_source_respects_update_config() {
|
||||
assert_eq!(
|
||||
startup_version_check_source(&UpdateConfig {
|
||||
check_for_updates: false,
|
||||
update_uri: Some("https://mirror.example/releases/latest".to_string()),
|
||||
}),
|
||||
StartupVersionCheckSource::Disabled
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
startup_version_check_source(&UpdateConfig {
|
||||
check_for_updates: true,
|
||||
update_uri: Some(" https://mirror.example/releases/latest ".to_string()),
|
||||
}),
|
||||
StartupVersionCheckSource::ConfiguredUrl(
|
||||
"https://mirror.example/releases/latest".to_string()
|
||||
)
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
startup_version_check_source(&UpdateConfig::default()),
|
||||
StartupVersionCheckSource::ReleaseResolver
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_update_uri_accepts_tag_only_release_json() {
|
||||
let json = serde_json::json!({
|
||||
"tag_name": "v0.8.47",
|
||||
"draft": false,
|
||||
"prerelease": false,
|
||||
});
|
||||
|
||||
let hint = version_hint_from_custom_release_json(&json, "0.8.46")
|
||||
.expect("tag-only custom metadata should be enough for mirrors");
|
||||
assert!(hint.contains("v0.8.47 available"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(any(unix, windows))]
|
||||
fn external_url_launcher_does_not_wait_for_browser_process() {
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
#[path = "support/qa_harness/mod.rs"]
|
||||
mod qa_harness;
|
||||
|
||||
use std::sync::{Mutex, MutexGuard};
|
||||
use std::time::Duration;
|
||||
|
||||
use qa_harness::harness::{Harness, make_sealed_workspace};
|
||||
@@ -20,6 +21,13 @@ use qa_harness::keys;
|
||||
|
||||
const BOOT_TIMEOUT: Duration = Duration::from_secs(15);
|
||||
const KEY_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
static QA_PTY_TEST_LOCK: Mutex<()> = Mutex::new(());
|
||||
|
||||
fn qa_pty_test_lock() -> MutexGuard<'static, ()> {
|
||||
QA_PTY_TEST_LOCK
|
||||
.lock()
|
||||
.unwrap_or_else(|poison| poison.into_inner())
|
||||
}
|
||||
|
||||
fn boot_minimal() -> anyhow::Result<(qa_harness::harness::SealedWorkspace, Harness)> {
|
||||
let ws = make_sealed_workspace()?;
|
||||
@@ -95,6 +103,7 @@ fn assert_viewport_starts_at_top(frame: &qa_harness::Frame) {
|
||||
/// broken before we worry about any scenario.
|
||||
#[test]
|
||||
fn smoke_boot_paints_composer() -> anyhow::Result<()> {
|
||||
let _guard = qa_pty_test_lock();
|
||||
let (_ws, mut h) = boot_minimal()?;
|
||||
|
||||
// The composer panel border is labelled "Composer" — wait for it.
|
||||
@@ -115,6 +124,7 @@ fn smoke_boot_paints_composer() -> anyhow::Result<()> {
|
||||
/// origin/scroll-region state must not leave blank rows above the TUI.
|
||||
#[test]
|
||||
fn viewport_origin_stays_row_zero_after_failed_turn() -> anyhow::Result<()> {
|
||||
let _guard = qa_pty_test_lock();
|
||||
let (_ws, mut h) = boot_minimal_without_retry()?;
|
||||
h.wait_for_text("Composer", BOOT_TIMEOUT)?;
|
||||
assert_viewport_starts_at_top(h.frame());
|
||||
@@ -142,6 +152,7 @@ fn viewport_origin_stays_row_zero_after_failed_turn() -> anyhow::Result<()> {
|
||||
/// we lean on it for real scenarios.
|
||||
#[test]
|
||||
fn smoke_keystroke_reaches_composer() -> anyhow::Result<()> {
|
||||
let _guard = qa_pty_test_lock();
|
||||
let (_ws, mut h) = boot_minimal()?;
|
||||
h.wait_for_text("Composer", BOOT_TIMEOUT)?;
|
||||
|
||||
@@ -157,6 +168,7 @@ fn smoke_keystroke_reaches_composer() -> anyhow::Result<()> {
|
||||
/// skills directory.
|
||||
#[test]
|
||||
fn skills_menu_shows_local_and_global_skills() -> anyhow::Result<()> {
|
||||
let _guard = qa_pty_test_lock();
|
||||
let ws = make_sealed_workspace()?;
|
||||
write_skill(ws.user_skills_dir(), "global-alpha", "Global alpha skill")?;
|
||||
write_skill(
|
||||
@@ -182,6 +194,7 @@ fn skills_menu_shows_local_and_global_skills() -> anyhow::Result<()> {
|
||||
|
||||
h.wait_for_text("Composer", BOOT_TIMEOUT)?;
|
||||
h.send(keys::key::text("/skills"))?;
|
||||
h.wait_for_text("/skills", KEY_TIMEOUT)?;
|
||||
h.wait_for_idle(Duration::from_millis(300), Duration::from_secs(2))?;
|
||||
h.send(keys::key::enter())?;
|
||||
h.wait_for_text("Available skills", KEY_TIMEOUT)?;
|
||||
@@ -210,6 +223,7 @@ fn skills_menu_shows_local_and_global_skills() -> anyhow::Result<()> {
|
||||
/// holding the text, not start a turn.
|
||||
#[test]
|
||||
fn paste_bracketed_with_trailing_newline_does_not_autosubmit() -> anyhow::Result<()> {
|
||||
let _guard = qa_pty_test_lock();
|
||||
let (_ws, mut h) = boot_minimal()?;
|
||||
h.wait_for_text("Composer", BOOT_TIMEOUT)?;
|
||||
|
||||
@@ -250,6 +264,7 @@ fn paste_bracketed_with_trailing_newline_does_not_autosubmit() -> anyhow::Result
|
||||
/// This is the Windows / PowerShell repro from #1073.
|
||||
#[test]
|
||||
fn paste_unbracketed_with_trailing_newline_does_not_autosubmit() -> anyhow::Result<()> {
|
||||
let _guard = qa_pty_test_lock();
|
||||
let (_ws, mut h) = boot_minimal()?;
|
||||
h.wait_for_text("Composer", BOOT_TIMEOUT)?;
|
||||
// Let the boot fully settle so input handling is wired up.
|
||||
|
||||
@@ -168,6 +168,37 @@ distinct set of commands (`auth`, `config`, `model`, `thread`, `sandbox`,
|
||||
`app-server`, `mcp-server`, `completion`) and forwards plain prompts to
|
||||
`codewhale-tui`.
|
||||
|
||||
### Startup Update Checks
|
||||
|
||||
By default, the TUI starts a background check for the latest stable CodeWhale
|
||||
release and shows a short toast only when a newer release is available and the
|
||||
official release assets are complete.
|
||||
|
||||
Disable the startup check entirely for air-gapped, corporate-proxy, or managed
|
||||
desktop environments:
|
||||
|
||||
```toml
|
||||
[update]
|
||||
check_for_updates = false
|
||||
```
|
||||
|
||||
To redirect the startup check, set `update_uri` to an internal endpoint that
|
||||
returns GitHub-compatible latest-release JSON. Minimal mirror metadata with a
|
||||
`tag_name` field is accepted; if `assets` are present, CodeWhale requires the
|
||||
same uploaded asset set as the official release before showing the toast.
|
||||
|
||||
```toml
|
||||
[update]
|
||||
check_for_updates = true
|
||||
update_uri = "https://internal.mirror.example/codewhale/releases/latest"
|
||||
```
|
||||
|
||||
When `update_uri` is not set, startup checks honor release mirror environment
|
||||
variables such as `CODEWHALE_RELEASE_BASE_URL` before falling back to the
|
||||
official GitHub API endpoint. If a configured `update_uri` cannot be fetched or
|
||||
parsed and a release mirror env var is set, the TUI falls back to that mirror
|
||||
instead of failing startup.
|
||||
|
||||
## Profiles
|
||||
|
||||
You can define multiple profiles in the same file:
|
||||
@@ -311,6 +342,9 @@ Remaining variables:
|
||||
- `CODEWHALE_HOME` (override the base data directory; defaults to `~/.codewhale`).
|
||||
If you previously exported `DEEPSEEK_HOME`, rename it to `CODEWHALE_HOME`;
|
||||
the old env var is not used for new CodeWhale state paths.
|
||||
- `CODEWHALE_RELEASE_BASE_URL` (release asset mirror used by `codewhale update`
|
||||
and by TUI startup update checks when `[update].update_uri` is not set, or as
|
||||
a fallback when that configured URI cannot be fetched)
|
||||
- `DEEPSEEK_AUTOMATIONS_DIR` (override the automations storage directory; uses
|
||||
`~/.codewhale/automations` when that directory exists, otherwise the legacy
|
||||
`~/.deepseek/automations` path)
|
||||
|
||||
Reference in New Issue
Block a user