Synced from monorepo
Changes: - Gate session-lifecycle heap steady state with a dhat soak - Unbreak merge lifecycle e2e after default model → grok-4.5 - Scan home-scope rules dirs at <root>/rules - Complete text-input paste and terminal parity - Gate project roles and personas - Use canonical editing in dialogs - Use canonical editing in search bars - Reject ambiguous MCP tool IDs - Harden Git operands for plugins - Simplify queue drain API - Pass RFC 9207 iss through MCP OAuth token exchange - Show leader roster when local agents map is empty - Use canonical editing in Persona views - Remove marketplace default-skills auto-install and purge old installs - Use canonical editing in extension forms - Add canonical dashboard text editing - Use canonical editing in settings - Add /summarize as a /recap alias - Restore previous agent when exiting dashboard - Use tool_choice auto for compaction - Settings toggle for snap-prompt-to-top on send - Update default models to grok-4.5 - Source login shell once for local bash (env + alias/function snapshot) - Template hardcoded param names in server-native tool descriptions - Fix System-Reminder XML tag injection in CLAUDE.md via agents_md - Fix remote workspace-server hardcoding LSP trust (repo code execution risk) - Clear orphaned tool-call updates at turn end - Suppress task wake after cancel - Send x-grok-client-identifier on direct API tool calls - Harden dashboard peek lease transitions - Host /btw side panel in live region (minimal mode) - Bound scroll presentation latency - Highlight multi-line constructs correctly in diffs and the file viewer - Block web_fetch non-public IPs; local opt-in is explicit-host only - Seed coding_data_retention_opt_out=false for OAuth e2es in pty-harness - Follow up clipboard delivery feedback - Use canonical editing in pickers - Route TextArea through canonical editor - Persistent "watching" status row; quieter turn markers - Gate sensitive edit targets - Expose agent registry counts and gate session churn on them - Default coding data sharing to opt-out until server preference applies - Wire chat attachment ids through gateway prompts - On auth refresh failure, issue retry - Forward preview provenance and computer lifecycle state - Document independent privacy controls and scope /privacy output - Strip SamplingError Display prefix on rate-limit UI copy - Stop dumping Cloudflare HTML into Retry failed - Disable in-place prompt edit (scroll jank on enter) - Strip forced ANSI color from gh pr view JSON - Plumb bash tool description onto ToolUsageCard wire
This commit is contained in:
parent
98c3b2438a
commit
7cfcb20d2b
292 changed files with 23315 additions and 9209 deletions
|
|
@ -59,6 +59,10 @@ pub fn sync_source_cache_with_mode(
|
|||
cache_root: &Path,
|
||||
mode: SyncMode,
|
||||
) -> Result<SourceCacheLease, String> {
|
||||
let url = xai_grok_agent::plugins::git_install::validate_git_url(url)?;
|
||||
let branch = branch
|
||||
.map(xai_grok_agent::plugins::git_install::validate_git_ref)
|
||||
.transpose()?;
|
||||
let hash = cache_hash(url);
|
||||
let cache_dir = cache_root.join(&hash);
|
||||
let start = Instant::now();
|
||||
|
|
@ -89,6 +93,10 @@ fn sync_cache_locked(
|
|||
cache_dir: &Path,
|
||||
mode: SyncMode,
|
||||
) -> Result<(), String> {
|
||||
let url = xai_grok_agent::plugins::git_install::validate_git_url(url)?;
|
||||
let branch = branch
|
||||
.map(xai_grok_agent::plugins::git_install::validate_git_ref)
|
||||
.transpose()?;
|
||||
if cache_dir.join(".git").exists() {
|
||||
if mode == SyncMode::UseTtl && is_cache_fresh(cache_dir) {
|
||||
return Ok(());
|
||||
|
|
@ -225,6 +233,10 @@ fn unique_reclone_suffix() -> u128 {
|
|||
}
|
||||
|
||||
fn clone_with_git2(url: &str, branch: Option<&str>, dest: &Path) -> Result<(), String> {
|
||||
let url = xai_grok_agent::plugins::git_install::validate_git_url(url)?;
|
||||
let branch = branch
|
||||
.map(xai_grok_agent::plugins::git_install::validate_git_ref)
|
||||
.transpose()?;
|
||||
let mut fetch_opts = git2::FetchOptions::new();
|
||||
fetch_opts.depth(1);
|
||||
|
||||
|
|
@ -261,15 +273,22 @@ pub fn git_command() -> std::process::Command {
|
|||
cmd
|
||||
}
|
||||
|
||||
fn clone_with_cli(url: &str, branch: Option<&str>, dest: &Path) -> Result<(), String> {
|
||||
fn clone_cli_command(url: &str, branch: Option<&str>, dest: &Path) -> std::process::Command {
|
||||
let mut cmd = git_command();
|
||||
cmd.args(["clone", "--depth", "1"]);
|
||||
if let Some(b) = branch {
|
||||
cmd.args(["--branch", b]);
|
||||
}
|
||||
cmd.arg(url).arg(dest.as_os_str());
|
||||
cmd.arg("--").arg(url).arg(dest.as_os_str());
|
||||
cmd
|
||||
}
|
||||
|
||||
let output = cmd
|
||||
fn clone_with_cli(url: &str, branch: Option<&str>, dest: &Path) -> Result<(), String> {
|
||||
let url = xai_grok_agent::plugins::git_install::validate_git_url(url)?;
|
||||
let branch = branch
|
||||
.map(xai_grok_agent::plugins::git_install::validate_git_ref)
|
||||
.transpose()?;
|
||||
let output = clone_cli_command(url, branch, dest)
|
||||
.output()
|
||||
.map_err(|e| format!("failed to run git clone: {e}"))?;
|
||||
if !output.status.success() {
|
||||
|
|
@ -279,11 +298,24 @@ fn clone_with_cli(url: &str, branch: Option<&str>, dest: &Path) -> Result<(), St
|
|||
Ok(())
|
||||
}
|
||||
|
||||
fn fetch_cli_command(repo_dir: &Path, branch: Option<&str>) -> std::process::Command {
|
||||
let mut cmd = git_command();
|
||||
cmd.current_dir(repo_dir).args([
|
||||
"fetch",
|
||||
"--depth",
|
||||
"1",
|
||||
"--",
|
||||
"origin",
|
||||
branch.unwrap_or("HEAD"),
|
||||
]);
|
||||
cmd
|
||||
}
|
||||
|
||||
fn fetch_reset_cached_repo(repo_dir: &Path, branch: Option<&str>) -> Result<(), String> {
|
||||
let branch_arg = branch.unwrap_or("HEAD");
|
||||
let fetch_output = git_command()
|
||||
.current_dir(repo_dir)
|
||||
.args(["fetch", "--depth", "1", "origin", branch_arg])
|
||||
let branch = branch
|
||||
.map(xai_grok_agent::plugins::git_install::validate_git_ref)
|
||||
.transpose()?;
|
||||
let fetch_output = fetch_cli_command(repo_dir, branch)
|
||||
.output()
|
||||
.map_err(|e| format!("failed to run git fetch: {e}"))?;
|
||||
|
||||
|
|
@ -343,6 +375,60 @@ mod tests {
|
|||
assert!(root.to_string_lossy().contains("marketplace-cache"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cli_git_args_terminate_options_before_operands() {
|
||||
let clone_cmd = clone_cli_command("repo", Some("main"), Path::new("dest"));
|
||||
let clone_args: Vec<_> = clone_cmd
|
||||
.get_args()
|
||||
.map(|arg| arg.to_str().unwrap())
|
||||
.collect();
|
||||
assert_eq!(
|
||||
clone_args,
|
||||
[
|
||||
"--no-optional-locks",
|
||||
"clone",
|
||||
"--depth",
|
||||
"1",
|
||||
"--branch",
|
||||
"main",
|
||||
"--",
|
||||
"repo",
|
||||
"dest",
|
||||
]
|
||||
);
|
||||
|
||||
let fetch_cmd = fetch_cli_command(Path::new("repo"), Some("main"));
|
||||
let fetch_args: Vec<_> = fetch_cmd
|
||||
.get_args()
|
||||
.map(|arg| arg.to_str().unwrap())
|
||||
.collect();
|
||||
assert_eq!(
|
||||
fetch_args,
|
||||
[
|
||||
"--no-optional-locks",
|
||||
"fetch",
|
||||
"--depth",
|
||||
"1",
|
||||
"--",
|
||||
"origin",
|
||||
"main",
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_cache_operands_fail_before_cache_root_creation() {
|
||||
for (url, branch) in [
|
||||
("--upload-pack=cmd", Some("main")),
|
||||
("https://example.com/repo.git", Some("--upload-pack=cmd")),
|
||||
] {
|
||||
let parent = tempfile::tempdir().unwrap();
|
||||
let cache_root = parent.path().join("cache");
|
||||
assert!(sync_source_cache(url, branch, &cache_root).is_err());
|
||||
assert!(!cache_root.exists());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sync_source_cache_uses_ttl_by_default() {
|
||||
if !git_available() {
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@
|
|||
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
|
||||
use xai_grok_agent::plugins::git_install::{self, InstallSource};
|
||||
use xai_grok_agent::plugins::install_registry::{
|
||||
|
|
@ -56,7 +55,6 @@ pub fn install_from_marketplace(
|
|||
.map_err(|e| InstallError::InstallFailed {
|
||||
detail: format!("invalid marketplace plugin path: {e}"),
|
||||
})?;
|
||||
let plugin_relative_path = plugin_relative_path.as_str();
|
||||
let source = InstallSource::Local {
|
||||
path: plugin_dir,
|
||||
subdir: None,
|
||||
|
|
@ -67,17 +65,6 @@ pub fn install_from_marketplace(
|
|||
match git_install::install_from_source(&source, registry, false) {
|
||||
Ok(result) => {
|
||||
let repo_key = result.repo_key.clone();
|
||||
let installed_path = registry.install_dir().join(&repo_key);
|
||||
|
||||
// If the installed dir has no manifest but has SKILL.md files
|
||||
// at the root level (e.g. default-skills/), write a synthetic
|
||||
// plugin.json so the plugin discovery system finds the skills.
|
||||
ensure_manifest_for_root_skills(
|
||||
&installed_path,
|
||||
plugin_relative_path,
|
||||
&provenance.source_display_name,
|
||||
);
|
||||
|
||||
let mut repo = git_install::build_installed_repo(&result, &source);
|
||||
repo.marketplace = Some(provenance);
|
||||
registry.insert(repo_key.clone(), repo);
|
||||
|
|
@ -98,12 +85,6 @@ pub fn install_from_marketplace(
|
|||
match git_install::install_from_source(&source, registry, false) {
|
||||
Ok(result) => {
|
||||
let repo_key = result.repo_key.clone();
|
||||
let installed_path = registry.install_dir().join(&repo_key);
|
||||
ensure_manifest_for_root_skills(
|
||||
&installed_path,
|
||||
plugin_relative_path,
|
||||
&provenance.source_display_name,
|
||||
);
|
||||
let mut repo = git_install::build_installed_repo(&result, &source);
|
||||
repo.marketplace = Some(provenance);
|
||||
registry.insert(repo_key.clone(), repo);
|
||||
|
|
@ -153,6 +134,7 @@ pub fn install_from_remote_url(
|
|||
})
|
||||
})
|
||||
.transpose()?;
|
||||
let (url, git_ref, git_sha) = git_install::clone_operands(url, git_ref, git_sha)?;
|
||||
// No-fetch short-circuit before the pin gate: re-install of an already-present
|
||||
// plugin must not refuse just because the catalog entry is unpinned.
|
||||
if let Some((existing_key, _)) = find_installed_marketplace_plugin(
|
||||
|
|
@ -166,8 +148,8 @@ pub fn install_from_remote_url(
|
|||
}
|
||||
let source = InstallSource::Git {
|
||||
url: url.to_string(),
|
||||
git_ref: git_ref.map(|s| s.to_string()),
|
||||
git_sha: git_sha.map(|s| s.to_string()),
|
||||
git_ref: git_ref.map(str::to_owned),
|
||||
git_sha: git_sha.map(str::to_owned),
|
||||
subdir,
|
||||
};
|
||||
|
||||
|
|
@ -181,12 +163,6 @@ pub fn install_from_remote_url(
|
|||
) {
|
||||
Ok(result) => {
|
||||
let repo_key = result.repo_key.clone();
|
||||
let installed_path = registry.install_dir().join(&repo_key);
|
||||
ensure_manifest_for_root_skills(
|
||||
&installed_path,
|
||||
plugin_name,
|
||||
&provenance.source_display_name,
|
||||
);
|
||||
let mut repo = git_install::build_installed_repo(&result, &source);
|
||||
repo.marketplace = Some(provenance);
|
||||
registry.insert(repo_key.clone(), repo);
|
||||
|
|
@ -209,12 +185,6 @@ pub fn install_from_remote_url(
|
|||
) {
|
||||
Ok(result) => {
|
||||
let repo_key = result.repo_key.clone();
|
||||
let installed_path = registry.install_dir().join(&repo_key);
|
||||
ensure_manifest_for_root_skills(
|
||||
&installed_path,
|
||||
plugin_name,
|
||||
&provenance.source_display_name,
|
||||
);
|
||||
let mut repo = git_install::build_installed_repo(&result, &source);
|
||||
repo.marketplace = Some(provenance);
|
||||
registry.insert(repo_key.clone(), repo);
|
||||
|
|
@ -288,6 +258,21 @@ pub fn update_from_marketplace_entry_transactional(
|
|||
name: provenance.plugin_subdir.clone(),
|
||||
})?;
|
||||
|
||||
let remote_source = entry
|
||||
.remote_url
|
||||
.as_deref()
|
||||
.map(|url| {
|
||||
// Catalog pins published as `ref` still need hoisting for the verified clone path.
|
||||
let (git_ref, git_sha) = git_install::hoist_pin_slots(
|
||||
entry.remote_ref.as_deref(),
|
||||
entry.remote_sha.as_deref(),
|
||||
);
|
||||
let source = git_install::clone_operands(url, git_ref, git_sha)?;
|
||||
git_install::ensure_pinned(require_sha, source.2, &entry.name, source.0)?;
|
||||
Ok::<_, InstallError>(source)
|
||||
})
|
||||
.transpose()?;
|
||||
|
||||
let install_dir = registry.install_dir().to_path_buf();
|
||||
std::fs::create_dir_all(&install_dir).map_err(|e| InstallError::Io {
|
||||
path: install_dir.clone(),
|
||||
|
|
@ -302,11 +287,7 @@ pub fn update_from_marketplace_entry_transactional(
|
|||
remove_path_if_exists(&staging_path)?;
|
||||
remove_path_if_exists(&backup_path)?;
|
||||
|
||||
let stage_result = if let Some(url) = entry.remote_url.as_deref() {
|
||||
// Catalog pins published as `ref` still need hoisting for the verified clone path.
|
||||
let (git_ref, git_sha) =
|
||||
git_install::hoist_pin_slots(entry.remote_ref.as_deref(), entry.remote_sha.as_deref());
|
||||
git_install::ensure_pinned(require_sha, git_sha, &entry.name, url)?;
|
||||
let stage_result = if let Some((url, git_ref, git_sha)) = remote_source {
|
||||
clone_repo_to_path(url, git_ref, git_sha, &staging_path)
|
||||
} else {
|
||||
let source_path = plugin_relative_path
|
||||
|
|
@ -330,11 +311,6 @@ pub fn update_from_marketplace_entry_transactional(
|
|||
return Err(e);
|
||||
}
|
||||
|
||||
ensure_manifest_for_root_skills(
|
||||
&staging_path,
|
||||
plugin_relative_path.as_str(),
|
||||
&provenance.source_display_name,
|
||||
);
|
||||
let plugins = match discover_plugins_in_dir(&staging_path, remote_subdir.as_deref()) {
|
||||
Ok(plugins) if !plugins.is_empty() => plugins,
|
||||
Ok(_) => {
|
||||
|
|
@ -355,16 +331,14 @@ pub fn update_from_marketplace_entry_transactional(
|
|||
let new_version = first_plugin_version(&new_plugins);
|
||||
let changed = old_version != new_version;
|
||||
let updated_at = chrono::Utc::now().to_rfc3339();
|
||||
let kind = if let Some(url) = entry.remote_url.as_ref() {
|
||||
InstallKind::Git {
|
||||
url: url.clone(),
|
||||
git_ref: entry
|
||||
.remote_sha
|
||||
.clone()
|
||||
.or_else(|| entry.remote_ref.clone()),
|
||||
commit: read_head_commit(&staging_path).unwrap_or_default(),
|
||||
subdir: remote_subdir.clone(),
|
||||
}
|
||||
let kind = if let Some((url, git_ref, git_sha)) = remote_source {
|
||||
remote_install_kind(
|
||||
url,
|
||||
git_ref,
|
||||
git_sha,
|
||||
read_head_commit(&staging_path).unwrap_or_default(),
|
||||
remote_subdir.clone(),
|
||||
)
|
||||
} else {
|
||||
let source_path = plugin_relative_path
|
||||
.join_under(marketplace_root)
|
||||
|
|
@ -511,27 +485,39 @@ fn remove_path_if_exists(path: &Path) -> Result<(), InstallError> {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
fn remote_install_kind(
|
||||
url: &str,
|
||||
git_ref: Option<&str>,
|
||||
git_sha: Option<&str>,
|
||||
commit: String,
|
||||
subdir: Option<String>,
|
||||
) -> InstallKind {
|
||||
InstallKind::Git {
|
||||
url: url.to_owned(),
|
||||
git_ref: git_sha.or(git_ref).map(str::to_owned),
|
||||
commit,
|
||||
subdir,
|
||||
}
|
||||
}
|
||||
|
||||
fn clone_repo_to_path(
|
||||
url: &str,
|
||||
git_ref: Option<&str>,
|
||||
git_sha: Option<&str>,
|
||||
target: &Path,
|
||||
) -> Result<(), InstallError> {
|
||||
let (url, git_ref, git_sha) = git_install::clone_operands(url, git_ref, git_sha)?;
|
||||
if let Some(sha) = git_sha {
|
||||
return clone_repo_at_sha(url, sha, target);
|
||||
}
|
||||
|
||||
let mut cmd = Command::new("git");
|
||||
xai_tty_utils::detach_std_command(&mut cmd);
|
||||
cmd.arg("clone")
|
||||
.arg("--depth")
|
||||
.arg("1")
|
||||
.stdin(std::process::Stdio::null())
|
||||
.envs(xai_tty_utils::pager_env());
|
||||
// Same auth/LFS/SSH suppression as marketplace cache clones.
|
||||
let mut cmd = xai_tty_utils::git_command();
|
||||
cmd.arg("clone").arg("--depth").arg("1");
|
||||
if let Some(r) = git_ref {
|
||||
cmd.arg("--branch").arg(r);
|
||||
}
|
||||
cmd.arg(url).arg(target);
|
||||
cmd.arg("--").arg(url).arg(target);
|
||||
let output = cmd.output().map_err(|e| InstallError::InstallFailed {
|
||||
detail: format!("failed to run git clone: {e}"),
|
||||
})?;
|
||||
|
|
@ -549,11 +535,10 @@ fn clone_repo_to_path(
|
|||
}
|
||||
|
||||
fn clone_repo_at_sha(url: &str, sha: &str, target: &Path) -> Result<(), InstallError> {
|
||||
if sha.is_empty() {
|
||||
return Err(InstallError::InstallFailed {
|
||||
detail: "empty SHA provided for pinned clone".into(),
|
||||
});
|
||||
}
|
||||
let url = git_install::validate_git_url(url)
|
||||
.map_err(|detail| InstallError::InstallFailed { detail })?;
|
||||
let sha = git_install::validate_git_sha(sha)
|
||||
.map_err(|detail| InstallError::InstallFailed { detail })?;
|
||||
std::fs::create_dir_all(target).map_err(|e| InstallError::Io {
|
||||
path: target.to_path_buf(),
|
||||
source: e,
|
||||
|
|
@ -563,8 +548,8 @@ fn clone_repo_at_sha(url: &str, sha: &str, target: &Path) -> Result<(), InstallE
|
|||
InstallError::InstallFailed { detail }
|
||||
};
|
||||
run_git_in(target, &["init", "--quiet"]).map_err(wrap_fail)?;
|
||||
run_git_in(target, &["remote", "add", "origin", url]).map_err(wrap_fail)?;
|
||||
run_git_in(target, &["fetch", "--depth", "1", "origin", sha])
|
||||
run_git_in(target, &git_install::remote_add_args(url)).map_err(wrap_fail)?;
|
||||
run_git_in(target, &git_install::fetch_sha_args(sha))
|
||||
.map_err(|d| wrap_fail(format!("fetch-by-sha failed: {d}")))?;
|
||||
run_git_in(target, &["checkout", "--quiet", "FETCH_HEAD"]).map_err(wrap_fail)?;
|
||||
let head = read_head_commit(target).ok_or_else(|| {
|
||||
|
|
@ -588,12 +573,8 @@ fn run_git_in(cwd: &Path, args: &[&str]) -> Result<(), String> {
|
|||
}
|
||||
|
||||
fn run_git_in_capture(cwd: &Path, args: &[&str]) -> Result<std::process::Output, String> {
|
||||
let mut cmd = Command::new("git");
|
||||
xai_tty_utils::detach_std_command(&mut cmd);
|
||||
cmd.args(args)
|
||||
.current_dir(cwd)
|
||||
.stdin(std::process::Stdio::null())
|
||||
.envs(xai_tty_utils::pager_env());
|
||||
let mut cmd = xai_tty_utils::git_command();
|
||||
cmd.args(args).current_dir(cwd);
|
||||
let output = cmd
|
||||
.output()
|
||||
.map_err(|e| format!("failed to run git {}: {e}", args.first().unwrap_or(&"")))?;
|
||||
|
|
@ -740,89 +721,47 @@ fn copy_dir_recursive(src: &Path, dst: &Path) -> std::io::Result<()> {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
/// Write a synthetic `plugin.json` for directories that have SKILL.md files
|
||||
/// at the root level (not under a `skills/` subdirectory).
|
||||
///
|
||||
/// This handles `default-skills/` directories where each subdirectory IS a
|
||||
/// skill, rather than a plugin with a `skills/` convention directory.
|
||||
fn ensure_manifest_for_root_skills(
|
||||
installed_path: &Path,
|
||||
plugin_relative_path: &str,
|
||||
source_display_name: &str,
|
||||
) {
|
||||
use xai_grok_agent::plugins::manifest::load_manifest;
|
||||
|
||||
// Skip if a manifest already exists.
|
||||
if let Ok(xai_grok_agent::plugins::manifest::ManifestLoadResult::Found(_)) =
|
||||
load_manifest(installed_path)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip if there's already a skills/ directory (convention will work).
|
||||
if installed_path.join("skills").is_dir() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if there are SKILL.md files at the root level.
|
||||
let has_root_skills = std::fs::read_dir(installed_path)
|
||||
.ok()
|
||||
.map(|rd| {
|
||||
rd.filter_map(|e| e.ok())
|
||||
.any(|e| e.path().join("SKILL.md").exists())
|
||||
})
|
||||
.unwrap_or(false);
|
||||
|
||||
if !has_root_skills {
|
||||
return;
|
||||
}
|
||||
|
||||
// Build a unique name from the source display name + relative path.
|
||||
// e.g. source="xAI Marketplace", path="default-skills"
|
||||
// -> "xai-marketplace-default-skills"
|
||||
let source_slug: String = source_display_name
|
||||
.chars()
|
||||
.map(|c| {
|
||||
if c.is_alphanumeric() {
|
||||
c.to_ascii_lowercase()
|
||||
} else {
|
||||
'-'
|
||||
}
|
||||
})
|
||||
.collect::<String>()
|
||||
.split('-')
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect::<Vec<_>>()
|
||||
.join("-");
|
||||
let path_slug = plugin_relative_path
|
||||
.rsplit('/')
|
||||
.next()
|
||||
.unwrap_or(plugin_relative_path);
|
||||
let name = format!("{source_slug}-{path_slug}");
|
||||
|
||||
// Write a minimal plugin.json with skills pointing to root.
|
||||
let manifest = serde_json::json!({
|
||||
"name": name,
|
||||
"description": format!("Default skills from {source_display_name}"),
|
||||
"skills": "./"
|
||||
});
|
||||
let manifest_path = installed_path.join("plugin.json");
|
||||
if let Err(e) = std::fs::write(&manifest_path, manifest.to_string()) {
|
||||
tracing::warn!(
|
||||
path = %manifest_path.display(),
|
||||
error = %e,
|
||||
"failed to write synthetic plugin.json for root-level skills"
|
||||
);
|
||||
}
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::process::Command;
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
|
||||
static TEST_HOME: OnceLock<tempfile::TempDir> = OnceLock::new();
|
||||
static TEST_LOCK: Mutex<()> = Mutex::new(());
|
||||
|
||||
#[test]
|
||||
fn transactional_sha_git_args_terminate_options_before_operands() {
|
||||
assert_eq!(
|
||||
git_install::remote_add_args("repo"),
|
||||
["remote", "add", "--", "origin", "repo"]
|
||||
);
|
||||
assert_eq!(
|
||||
git_install::fetch_sha_args("0123456789abcdef0123456789abcdef01234567"),
|
||||
[
|
||||
"fetch",
|
||||
"--depth",
|
||||
"1",
|
||||
"--",
|
||||
"origin",
|
||||
"0123456789abcdef0123456789abcdef01234567",
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transactional_sha_clone_rejects_before_target_creation() {
|
||||
for bad in ["deadbeef", "--upload-pack=cmd"] {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let target = root.path().join("staging");
|
||||
assert!(matches!(
|
||||
clone_repo_to_path("file:///unused", None, Some(bad), &target),
|
||||
Err(InstallError::InstallFailed { .. })
|
||||
));
|
||||
assert!(!target.exists());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn require_sha_rejects_unpinned_remote_install() {
|
||||
with_test_registry(|registry| {
|
||||
|
|
@ -861,10 +800,50 @@ mod tests {
|
|||
true,
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
matches!(err, InstallError::UnpinnedRemoteRefused { .. }),
|
||||
"a non-hex 'pin' must be refused up front, got: {err}"
|
||||
);
|
||||
match err {
|
||||
InstallError::InstallFailed { detail } => assert!(
|
||||
detail.contains("40 or 64 hexadecimal"),
|
||||
"expected full-SHA validation detail, got: {detail}"
|
||||
),
|
||||
other => panic!("expected InstallFailed for malformed SHA, got: {other}"),
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn already_installed_remote_still_rejects_malformed_operands() {
|
||||
with_test_registry(|registry| {
|
||||
let marketplace = tempfile::tempdir().unwrap();
|
||||
write_plugin(marketplace.path(), "demo", "1.0.0", "old");
|
||||
install_test_plugin(registry, marketplace.path(), "demo");
|
||||
let provenance = provenance(marketplace.path(), "plugins/demo");
|
||||
let registry_len = registry.list().len();
|
||||
let installed_path = registry.list().into_iter().next().unwrap().1.path.clone();
|
||||
|
||||
for (url, git_ref, git_sha) in [
|
||||
("--upload-pack=cmd", Some("main"), None),
|
||||
(
|
||||
"https://example.com/plugin.git",
|
||||
Some("--upload-pack=cmd"),
|
||||
None,
|
||||
),
|
||||
("https://example.com/plugin.git", None, Some("deadbeef")),
|
||||
] {
|
||||
let err = install_from_remote_url(
|
||||
url,
|
||||
git_ref,
|
||||
git_sha,
|
||||
None,
|
||||
"plugins/demo",
|
||||
provenance.clone(),
|
||||
registry,
|
||||
false,
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, InstallError::InstallFailed { .. }));
|
||||
assert_eq!(registry.list().len(), registry_len);
|
||||
assert!(installed_path.exists());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -1078,6 +1057,39 @@ mod tests {
|
|||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transactional_git_kind_uses_normalized_operands() {
|
||||
let sha = "a".repeat(40);
|
||||
let padded_sha = format!(" {sha} ");
|
||||
let (url, git_ref, git_sha) = git_install::clone_operands(
|
||||
" https://example.com/plugin.git ",
|
||||
Some(" v1.2.3 "),
|
||||
Some(&padded_sha),
|
||||
)
|
||||
.unwrap();
|
||||
let kind = remote_install_kind(url, git_ref, git_sha, sha.clone(), None);
|
||||
let repo = InstalledRepo {
|
||||
kind,
|
||||
installed_at: String::new(),
|
||||
updated_at: String::new(),
|
||||
path: Path::new("/unused").to_path_buf(),
|
||||
plugins: HashMap::new(),
|
||||
marketplace: None,
|
||||
};
|
||||
|
||||
match &repo.kind {
|
||||
InstallKind::Git { url, git_ref, .. } => {
|
||||
assert_eq!(url, "https://example.com/plugin.git");
|
||||
assert_eq!(git_ref.as_deref(), Some(sha.as_str()));
|
||||
}
|
||||
InstallKind::Local { .. } => panic!("expected Git"),
|
||||
}
|
||||
assert!(matches!(
|
||||
git_install::update_repo("repo", &repo, true),
|
||||
Ok(git_install::UpdateStatus::Pinned { ref_name }) if ref_name == sha
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transactional_update_preserves_installed_at_and_updates_updated_at() {
|
||||
with_test_registry(|registry| {
|
||||
|
|
|
|||
|
|
@ -14,46 +14,10 @@ use crate::types::{MarketplaceEntry, MarketplaceScan};
|
|||
/// Scan a marketplace directory for plugins, reporting whether a
|
||||
/// `plugin-index.json` component catalog was loaded.
|
||||
///
|
||||
/// Tries indexed mode first, falls back to filesystem scanning.
|
||||
/// Tries indexed mode first, falls back to filesystem scanning. The component
|
||||
/// catalog is only consulted in indexed mode: its keys are defined as index
|
||||
/// names, so the filesystem fallback ignores it.
|
||||
pub fn scan_marketplace(root: &Path) -> MarketplaceScan {
|
||||
let MarketplaceScan {
|
||||
entries: mut plugins,
|
||||
catalog_loaded,
|
||||
} = scan_plugins(root);
|
||||
|
||||
// Also scan `default-skills/` as a virtual plugin if present.
|
||||
let default_skills_dir = root.join("default-skills");
|
||||
if default_skills_dir.is_dir() {
|
||||
// default-skills/ has skills at root level (each subdir is a skill),
|
||||
// not under a skills/ subdirectory. Count SKILL.md files directly.
|
||||
let skill_count = std::fs::read_dir(&default_skills_dir)
|
||||
.ok()
|
||||
.map(|rd| {
|
||||
rd.filter_map(|e| e.ok())
|
||||
.filter(|e| e.path().join("SKILL.md").exists())
|
||||
.count()
|
||||
})
|
||||
.unwrap_or(0);
|
||||
if skill_count > 0 {
|
||||
let mut entry = scan_single_plugin(&default_skills_dir, "default-skills");
|
||||
// Override skill_count since scan_single_plugin looks under skills/.
|
||||
entry.skill_count = skill_count;
|
||||
plugins.push(entry);
|
||||
}
|
||||
}
|
||||
|
||||
MarketplaceScan {
|
||||
entries: plugins,
|
||||
catalog_loaded,
|
||||
}
|
||||
}
|
||||
|
||||
/// Core plugin scanning — tries indexed mode first, falls back to filesystem.
|
||||
///
|
||||
/// The component catalog is only consulted in indexed mode: its keys are
|
||||
/// defined as index names, so the filesystem fallback ignores it.
|
||||
fn scan_plugins(root: &Path) -> MarketplaceScan {
|
||||
// Try indexed mode.
|
||||
match index::load_index(root) {
|
||||
Ok(Some(idx)) => {
|
||||
tracing::debug!(
|
||||
|
|
@ -738,34 +702,6 @@ mod tests {
|
|||
assert_eq!(scan.entries[0].skill_count, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_skills_virtual_plugin_has_no_components() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
write_grok_file(
|
||||
dir.path(),
|
||||
"marketplace.json",
|
||||
r#"{"name": "m", "plugins": []}"#,
|
||||
);
|
||||
write_grok_file(
|
||||
dir.path(),
|
||||
"plugin-index.json",
|
||||
r#"{
|
||||
"version": 1,
|
||||
"plugins": { "default-skills": { "components": { "skills": [ { "name": "s" } ] } } }
|
||||
}"#,
|
||||
);
|
||||
let skill_dir = dir.path().join("default-skills").join("a-skill");
|
||||
std::fs::create_dir_all(&skill_dir).unwrap();
|
||||
std::fs::write(skill_dir.join("SKILL.md"), "# A Skill").unwrap();
|
||||
|
||||
let scan = scan_marketplace(dir.path());
|
||||
assert!(scan.catalog_loaded);
|
||||
assert_eq!(scan.entries.len(), 1);
|
||||
assert_eq!(scan.entries[0].name, "default-skills");
|
||||
assert_eq!(scan.entries[0].skill_count, 1);
|
||||
assert!(scan.entries[0].components.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn root_plugin_json_preferred() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
|
|
|
|||
Loading…
Reference in a new issue