fix(skills): make open-skills sync opt-in and configurable
This commit is contained in:
parent
d0674c4b98
commit
a2e9c0d1e1
10 changed files with 312 additions and 29 deletions
|
|
@ -308,7 +308,10 @@ impl Agent {
|
|||
.classification_config(config.query_classification.clone())
|
||||
.available_hints(available_hints)
|
||||
.identity_config(config.identity.clone())
|
||||
.skills(crate::skills::load_skills(&config.workspace_dir))
|
||||
.skills(crate::skills::load_skills_with_config(
|
||||
&config.workspace_dir,
|
||||
config,
|
||||
))
|
||||
.auto_save(config.memory.auto_save)
|
||||
.build()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1348,7 +1348,7 @@ pub async fn run(
|
|||
.collect();
|
||||
|
||||
// ── Build system prompt from workspace MD files (OpenClaw framework) ──
|
||||
let skills = crate::skills::load_skills(&config.workspace_dir);
|
||||
let skills = crate::skills::load_skills_with_config(&config.workspace_dir, &config);
|
||||
let mut tool_descs: Vec<(&str, &str)> = vec![
|
||||
(
|
||||
"shell",
|
||||
|
|
@ -1778,7 +1778,7 @@ pub async fn process_message(config: Config, message: &str) -> Result<String> {
|
|||
.map(|b| b.board.clone())
|
||||
.collect();
|
||||
|
||||
let skills = crate::skills::load_skills(&config.workspace_dir);
|
||||
let skills = crate::skills::load_skills_with_config(&config.workspace_dir, &config);
|
||||
let mut tool_descs: Vec<(&str, &str)> = vec![
|
||||
("shell", "Execute terminal commands."),
|
||||
("file_read", "Read file contents."),
|
||||
|
|
|
|||
|
|
@ -2302,7 +2302,7 @@ pub async fn start_channels(config: Config) -> Result<()> {
|
|||
&config,
|
||||
));
|
||||
|
||||
let skills = crate::skills::load_skills(&workspace);
|
||||
let skills = crate::skills::load_skills_with_config(&workspace, &config);
|
||||
|
||||
// Collect tool descriptions for the prompt
|
||||
let mut tool_descs: Vec<(&str, &str)> = vec![
|
||||
|
|
|
|||
|
|
@ -11,9 +11,9 @@ pub use schema::{
|
|||
IdentityConfig, LarkConfig, MatrixConfig, MemoryConfig, ModelRouteConfig, MultimodalConfig,
|
||||
ObservabilityConfig, PeripheralBoardConfig, PeripheralsConfig, ProxyConfig, ProxyScope,
|
||||
QueryClassificationConfig, ReliabilityConfig, ResourceLimitsConfig, RuntimeConfig,
|
||||
SandboxBackend, SandboxConfig, SchedulerConfig, SecretsConfig, SecurityConfig, SlackConfig,
|
||||
StorageConfig, StorageProviderConfig, StorageProviderSection, StreamMode, TelegramConfig,
|
||||
TunnelConfig, WebSearchConfig, WebhookConfig,
|
||||
SandboxBackend, SandboxConfig, SchedulerConfig, SecretsConfig, SecurityConfig, SkillsConfig,
|
||||
SlackConfig, StorageConfig, StorageProviderConfig, StorageProviderSection, StreamMode,
|
||||
TelegramConfig, TunnelConfig, WebSearchConfig, WebhookConfig,
|
||||
};
|
||||
|
||||
#[cfg(test)]
|
||||
|
|
|
|||
|
|
@ -94,6 +94,10 @@ pub struct Config {
|
|||
#[serde(default)]
|
||||
pub agent: AgentConfig,
|
||||
|
||||
/// Skills loading and community repository behavior (`[skills]`).
|
||||
#[serde(default)]
|
||||
pub skills: SkillsConfig,
|
||||
|
||||
/// Model routing rules — route `hint:<name>` to specific provider+model combos.
|
||||
#[serde(default)]
|
||||
pub model_routes: Vec<ModelRouteConfig>,
|
||||
|
|
@ -325,6 +329,28 @@ impl Default for AgentConfig {
|
|||
}
|
||||
}
|
||||
|
||||
/// Skills loading configuration (`[skills]` section).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
||||
pub struct SkillsConfig {
|
||||
/// Enable loading and syncing the community open-skills repository.
|
||||
/// Default: `false` (opt-in).
|
||||
#[serde(default)]
|
||||
pub open_skills_enabled: bool,
|
||||
/// Optional path to a local open-skills repository.
|
||||
/// If unset, defaults to `$HOME/open-skills` when enabled.
|
||||
#[serde(default)]
|
||||
pub open_skills_dir: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for SkillsConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
open_skills_enabled: false,
|
||||
open_skills_dir: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Multimodal (image) handling configuration (`[multimodal]` section).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
||||
pub struct MultimodalConfig {
|
||||
|
|
@ -2742,6 +2768,7 @@ impl Default for Config {
|
|||
reliability: ReliabilityConfig::default(),
|
||||
scheduler: SchedulerConfig::default(),
|
||||
agent: AgentConfig::default(),
|
||||
skills: SkillsConfig::default(),
|
||||
model_routes: Vec::new(),
|
||||
embedding_routes: Vec::new(),
|
||||
heartbeat: HeartbeatConfig::default(),
|
||||
|
|
@ -3235,6 +3262,27 @@ impl Config {
|
|||
}
|
||||
}
|
||||
|
||||
// Open-skills opt-in flag: ZEROCLAW_OPEN_SKILLS_ENABLED
|
||||
if let Ok(flag) = std::env::var("ZEROCLAW_OPEN_SKILLS_ENABLED") {
|
||||
if !flag.trim().is_empty() {
|
||||
match flag.trim().to_ascii_lowercase().as_str() {
|
||||
"1" | "true" | "yes" | "on" => self.skills.open_skills_enabled = true,
|
||||
"0" | "false" | "no" | "off" => self.skills.open_skills_enabled = false,
|
||||
_ => tracing::warn!(
|
||||
"Ignoring invalid ZEROCLAW_OPEN_SKILLS_ENABLED (valid: 1|0|true|false|yes|no|on|off)"
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Open-skills directory override: ZEROCLAW_OPEN_SKILLS_DIR
|
||||
if let Ok(path) = std::env::var("ZEROCLAW_OPEN_SKILLS_DIR") {
|
||||
let trimmed = path.trim();
|
||||
if !trimmed.is_empty() {
|
||||
self.skills.open_skills_dir = Some(trimmed.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
// Gateway port: ZEROCLAW_GATEWAY_PORT or PORT
|
||||
if let Ok(port_str) =
|
||||
std::env::var("ZEROCLAW_GATEWAY_PORT").or_else(|_| std::env::var("PORT"))
|
||||
|
|
@ -3574,6 +3622,7 @@ mod tests {
|
|||
assert!(c.default_model.as_deref().unwrap().contains("claude"));
|
||||
assert!((c.default_temperature - 0.7).abs() < f64::EPSILON);
|
||||
assert!(c.api_key.is_none());
|
||||
assert!(!c.skills.open_skills_enabled);
|
||||
assert!(c.workspace_dir.to_string_lossy().contains("workspace"));
|
||||
assert!(c.config_path.to_string_lossy().contains("config.toml"));
|
||||
}
|
||||
|
|
@ -3596,6 +3645,7 @@ mod tests {
|
|||
.expect("schema should expose top-level properties");
|
||||
|
||||
assert!(properties.contains_key("default_provider"));
|
||||
assert!(properties.contains_key("skills"));
|
||||
assert!(properties.contains_key("gateway"));
|
||||
assert!(properties.contains_key("channels_config"));
|
||||
assert!(!properties.contains_key("workspace_dir"));
|
||||
|
|
@ -3745,6 +3795,7 @@ default_temperature = 0.7
|
|||
},
|
||||
reliability: ReliabilityConfig::default(),
|
||||
scheduler: SchedulerConfig::default(),
|
||||
skills: SkillsConfig::default(),
|
||||
model_routes: Vec::new(),
|
||||
embedding_routes: Vec::new(),
|
||||
query_classification: QueryClassificationConfig::default(),
|
||||
|
|
@ -3941,6 +3992,7 @@ tool_dispatcher = "xml"
|
|||
runtime: RuntimeConfig::default(),
|
||||
reliability: ReliabilityConfig::default(),
|
||||
scheduler: SchedulerConfig::default(),
|
||||
skills: SkillsConfig::default(),
|
||||
model_routes: Vec::new(),
|
||||
embedding_routes: Vec::new(),
|
||||
query_classification: QueryClassificationConfig::default(),
|
||||
|
|
@ -4900,6 +4952,40 @@ default_temperature = 0.7
|
|||
std::env::remove_var("ZEROCLAW_PROVIDER");
|
||||
}
|
||||
|
||||
#[test]
|
||||
async fn env_override_open_skills_enabled_and_dir() {
|
||||
let _env_guard = env_override_lock().await;
|
||||
let mut config = Config::default();
|
||||
assert!(!config.skills.open_skills_enabled);
|
||||
assert!(config.skills.open_skills_dir.is_none());
|
||||
|
||||
std::env::set_var("ZEROCLAW_OPEN_SKILLS_ENABLED", "true");
|
||||
std::env::set_var("ZEROCLAW_OPEN_SKILLS_DIR", "/tmp/open-skills");
|
||||
config.apply_env_overrides();
|
||||
|
||||
assert!(config.skills.open_skills_enabled);
|
||||
assert_eq!(
|
||||
config.skills.open_skills_dir.as_deref(),
|
||||
Some("/tmp/open-skills")
|
||||
);
|
||||
|
||||
std::env::remove_var("ZEROCLAW_OPEN_SKILLS_ENABLED");
|
||||
std::env::remove_var("ZEROCLAW_OPEN_SKILLS_DIR");
|
||||
}
|
||||
|
||||
#[test]
|
||||
async fn env_override_open_skills_enabled_invalid_value_keeps_existing_value() {
|
||||
let _env_guard = env_override_lock().await;
|
||||
let mut config = Config::default();
|
||||
config.skills.open_skills_enabled = true;
|
||||
|
||||
std::env::set_var("ZEROCLAW_OPEN_SKILLS_ENABLED", "maybe");
|
||||
config.apply_env_overrides();
|
||||
|
||||
assert!(config.skills.open_skills_enabled);
|
||||
std::env::remove_var("ZEROCLAW_OPEN_SKILLS_ENABLED");
|
||||
}
|
||||
|
||||
#[test]
|
||||
async fn env_override_provider_fallback() {
|
||||
let _env_guard = env_override_lock().await;
|
||||
|
|
|
|||
|
|
@ -884,9 +884,7 @@ async fn main() -> Result<()> {
|
|||
integration_command,
|
||||
} => integrations::handle_command(integration_command, &config),
|
||||
|
||||
Commands::Skills { skill_command } => {
|
||||
skills::handle_command(skill_command, &config.workspace_dir)
|
||||
}
|
||||
Commands::Skills { skill_command } => skills::handle_command(skill_command, &config),
|
||||
|
||||
Commands::Migrate { migrate_command } => {
|
||||
migration::handle_command(migrate_command, &config).await
|
||||
|
|
|
|||
|
|
@ -160,6 +160,7 @@ pub async fn run_wizard() -> Result<Config> {
|
|||
reliability: crate::config::ReliabilityConfig::default(),
|
||||
scheduler: crate::config::schema::SchedulerConfig::default(),
|
||||
agent: crate::config::schema::AgentConfig::default(),
|
||||
skills: crate::config::SkillsConfig::default(),
|
||||
model_routes: Vec::new(),
|
||||
embedding_routes: Vec::new(),
|
||||
heartbeat: HeartbeatConfig::default(),
|
||||
|
|
@ -398,6 +399,7 @@ async fn run_quick_setup_with_home(
|
|||
reliability: crate::config::ReliabilityConfig::default(),
|
||||
scheduler: crate::config::schema::SchedulerConfig::default(),
|
||||
agent: crate::config::schema::AgentConfig::default(),
|
||||
skills: crate::config::SkillsConfig::default(),
|
||||
model_routes: Vec::new(),
|
||||
embedding_routes: Vec::new(),
|
||||
heartbeat: HeartbeatConfig::default(),
|
||||
|
|
|
|||
|
|
@ -71,9 +71,28 @@ fn default_version() -> String {
|
|||
|
||||
/// Load all skills from the workspace skills directory
|
||||
pub fn load_skills(workspace_dir: &Path) -> Vec<Skill> {
|
||||
load_skills_with_open_skills_config(workspace_dir, None, None)
|
||||
}
|
||||
|
||||
/// Load skills using runtime config values (preferred at runtime).
|
||||
pub fn load_skills_with_config(workspace_dir: &Path, config: &crate::config::Config) -> Vec<Skill> {
|
||||
load_skills_with_open_skills_config(
|
||||
workspace_dir,
|
||||
Some(config.skills.open_skills_enabled),
|
||||
config.skills.open_skills_dir.as_deref(),
|
||||
)
|
||||
}
|
||||
|
||||
fn load_skills_with_open_skills_config(
|
||||
workspace_dir: &Path,
|
||||
config_open_skills_enabled: Option<bool>,
|
||||
config_open_skills_dir: Option<&str>,
|
||||
) -> Vec<Skill> {
|
||||
let mut skills = Vec::new();
|
||||
|
||||
if let Some(open_skills_dir) = ensure_open_skills_repo() {
|
||||
if let Some(open_skills_dir) =
|
||||
ensure_open_skills_repo(config_open_skills_enabled, config_open_skills_dir)
|
||||
{
|
||||
skills.extend(load_open_skills(&open_skills_dir));
|
||||
}
|
||||
|
||||
|
|
@ -158,33 +177,79 @@ fn load_open_skills(repo_dir: &Path) -> Vec<Skill> {
|
|||
skills
|
||||
}
|
||||
|
||||
fn open_skills_enabled() -> bool {
|
||||
if let Ok(raw) = std::env::var("ZEROCLAW_OPEN_SKILLS_ENABLED") {
|
||||
let value = raw.trim().to_ascii_lowercase();
|
||||
return !matches!(value.as_str(), "0" | "false" | "off" | "no");
|
||||
fn parse_open_skills_enabled(raw: &str) -> Option<bool> {
|
||||
match raw.trim().to_ascii_lowercase().as_str() {
|
||||
"1" | "true" | "yes" | "on" => Some(true),
|
||||
"0" | "false" | "no" | "off" => Some(false),
|
||||
_ => None,
|
||||
}
|
||||
|
||||
// Keep tests deterministic and network-free by default.
|
||||
!cfg!(test)
|
||||
}
|
||||
|
||||
fn resolve_open_skills_dir() -> Option<PathBuf> {
|
||||
if let Ok(path) = std::env::var("ZEROCLAW_OPEN_SKILLS_DIR") {
|
||||
let trimmed = path.trim();
|
||||
if !trimmed.is_empty() {
|
||||
return Some(PathBuf::from(trimmed));
|
||||
fn open_skills_enabled_from_sources(
|
||||
config_open_skills_enabled: Option<bool>,
|
||||
env_override: Option<&str>,
|
||||
) -> bool {
|
||||
if let Some(raw) = env_override {
|
||||
if let Some(enabled) = parse_open_skills_enabled(&raw) {
|
||||
return enabled;
|
||||
}
|
||||
if !raw.trim().is_empty() {
|
||||
tracing::warn!(
|
||||
"Ignoring invalid ZEROCLAW_OPEN_SKILLS_ENABLED (valid: 1|0|true|false|yes|no|on|off)"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
UserDirs::new().map(|dirs| dirs.home_dir().join("open-skills"))
|
||||
config_open_skills_enabled.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn ensure_open_skills_repo() -> Option<PathBuf> {
|
||||
if !open_skills_enabled() {
|
||||
fn open_skills_enabled(config_open_skills_enabled: Option<bool>) -> bool {
|
||||
let env_override = std::env::var("ZEROCLAW_OPEN_SKILLS_ENABLED").ok();
|
||||
open_skills_enabled_from_sources(config_open_skills_enabled, env_override.as_deref())
|
||||
}
|
||||
|
||||
fn resolve_open_skills_dir_from_sources(
|
||||
env_dir: Option<&str>,
|
||||
config_dir: Option<&str>,
|
||||
home_dir: Option<&Path>,
|
||||
) -> Option<PathBuf> {
|
||||
let parse_dir = |raw: &str| {
|
||||
let trimmed = raw.trim();
|
||||
if trimmed.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(PathBuf::from(trimmed))
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(env_dir) = env_dir.and_then(parse_dir) {
|
||||
return Some(env_dir);
|
||||
}
|
||||
if let Some(config_dir) = config_dir.and_then(parse_dir) {
|
||||
return Some(config_dir);
|
||||
}
|
||||
home_dir.map(|home| home.join("open-skills"))
|
||||
}
|
||||
|
||||
fn resolve_open_skills_dir(config_open_skills_dir: Option<&str>) -> Option<PathBuf> {
|
||||
let env_dir = std::env::var("ZEROCLAW_OPEN_SKILLS_DIR").ok();
|
||||
let home_dir = UserDirs::new().map(|dirs| dirs.home_dir().to_path_buf());
|
||||
resolve_open_skills_dir_from_sources(
|
||||
env_dir.as_deref(),
|
||||
config_open_skills_dir,
|
||||
home_dir.as_deref(),
|
||||
)
|
||||
}
|
||||
|
||||
fn ensure_open_skills_repo(
|
||||
config_open_skills_enabled: Option<bool>,
|
||||
config_open_skills_dir: Option<&str>,
|
||||
) -> Option<PathBuf> {
|
||||
if !open_skills_enabled(config_open_skills_enabled) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let repo_dir = resolve_open_skills_dir()?;
|
||||
let repo_dir = resolve_open_skills_dir(config_open_skills_dir)?;
|
||||
|
||||
if !repo_dir.exists() {
|
||||
if !clone_open_skills_repo(&repo_dir) {
|
||||
|
|
@ -542,10 +607,11 @@ fn copy_dir_recursive(src: &Path, dest: &Path) -> Result<()> {
|
|||
|
||||
/// Handle the `skills` CLI command
|
||||
#[allow(clippy::too_many_lines)]
|
||||
pub fn handle_command(command: crate::SkillCommands, workspace_dir: &Path) -> Result<()> {
|
||||
pub fn handle_command(command: crate::SkillCommands, config: &crate::config::Config) -> Result<()> {
|
||||
let workspace_dir = &config.workspace_dir;
|
||||
match command {
|
||||
crate::SkillCommands::List => {
|
||||
let skills = load_skills(workspace_dir);
|
||||
let skills = load_skills_with_config(workspace_dir, config);
|
||||
if skills.is_empty() {
|
||||
println!("No skills installed.");
|
||||
println!();
|
||||
|
|
@ -711,6 +777,35 @@ pub fn handle_command(command: crate::SkillCommands, workspace_dir: &Path) -> Re
|
|||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
|
||||
fn open_skills_env_lock() -> &'static Mutex<()> {
|
||||
static ENV_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
|
||||
ENV_LOCK.get_or_init(|| Mutex::new(()))
|
||||
}
|
||||
|
||||
struct EnvVarGuard {
|
||||
key: &'static str,
|
||||
original: Option<String>,
|
||||
}
|
||||
|
||||
impl EnvVarGuard {
|
||||
fn unset(key: &'static str) -> Self {
|
||||
let original = std::env::var(key).ok();
|
||||
std::env::remove_var(key);
|
||||
Self { key, original }
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for EnvVarGuard {
|
||||
fn drop(&mut self) {
|
||||
if let Some(value) = &self.original {
|
||||
std::env::set_var(self.key, value);
|
||||
} else {
|
||||
std::env::remove_var(self.key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_empty_skills_dir() {
|
||||
|
|
@ -1071,6 +1166,78 @@ description = "Bare minimum"
|
|||
assert_eq!(skills.len(), 1);
|
||||
assert_eq!(skills[0].name, "from-toml"); // TOML takes priority
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_skills_enabled_resolution_prefers_env_then_config_then_default_false() {
|
||||
assert!(!open_skills_enabled_from_sources(None, None));
|
||||
assert!(open_skills_enabled_from_sources(Some(true), None));
|
||||
assert!(!open_skills_enabled_from_sources(Some(true), Some("0")));
|
||||
assert!(open_skills_enabled_from_sources(Some(false), Some("yes")));
|
||||
// Invalid env values should fall back to config.
|
||||
assert!(open_skills_enabled_from_sources(
|
||||
Some(true),
|
||||
Some("invalid")
|
||||
));
|
||||
assert!(!open_skills_enabled_from_sources(
|
||||
Some(false),
|
||||
Some("invalid")
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_open_skills_dir_resolution_prefers_env_then_config_then_home() {
|
||||
let home = Path::new("/tmp/home-dir");
|
||||
assert_eq!(
|
||||
resolve_open_skills_dir_from_sources(
|
||||
Some("/tmp/env-skills"),
|
||||
Some("/tmp/config"),
|
||||
Some(home)
|
||||
),
|
||||
Some(PathBuf::from("/tmp/env-skills"))
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_open_skills_dir_from_sources(
|
||||
Some(" "),
|
||||
Some("/tmp/config-skills"),
|
||||
Some(home)
|
||||
),
|
||||
Some(PathBuf::from("/tmp/config-skills"))
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_open_skills_dir_from_sources(None, None, Some(home)),
|
||||
Some(PathBuf::from("/tmp/home-dir/open-skills"))
|
||||
);
|
||||
assert_eq!(resolve_open_skills_dir_from_sources(None, None, None), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_skills_with_config_reads_open_skills_dir_without_network() {
|
||||
let _env_guard = open_skills_env_lock().lock().unwrap();
|
||||
let _enabled_guard = EnvVarGuard::unset("ZEROCLAW_OPEN_SKILLS_ENABLED");
|
||||
let _dir_guard = EnvVarGuard::unset("ZEROCLAW_OPEN_SKILLS_DIR");
|
||||
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let workspace_dir = dir.path().join("workspace");
|
||||
fs::create_dir_all(workspace_dir.join("skills")).unwrap();
|
||||
|
||||
let open_skills_dir = dir.path().join("open-skills-local");
|
||||
fs::create_dir_all(&open_skills_dir).unwrap();
|
||||
fs::write(open_skills_dir.join("README.md"), "# open skills\n").unwrap();
|
||||
fs::write(
|
||||
open_skills_dir.join("http_request.md"),
|
||||
"# HTTP request\nFetch API responses.\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let mut config = crate::config::Config::default();
|
||||
config.workspace_dir = workspace_dir.clone();
|
||||
config.skills.open_skills_enabled = true;
|
||||
config.skills.open_skills_dir = Some(open_skills_dir.to_string_lossy().to_string());
|
||||
|
||||
let skills = load_skills_with_config(&workspace_dir, &config);
|
||||
assert_eq!(skills.len(), 1);
|
||||
assert_eq!(skills[0].name, "http_request");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue