Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
18 changes: 18 additions & 0 deletions crates/hk-core/src/models.rs
Original file line number Diff line number Diff line change
Expand Up @@ -286,9 +286,27 @@ pub struct DiscoveredProject {
pub enum UpdateStatus {
UpToDate { remote_hash: String },
UpdateAvailable { remote_hash: String },
RemovedFromRepo,
Error { message: String },
}

// --- New Repo Skills ---

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NewRepoSkill {
pub repo_url: String,
pub pack: Option<String>,
pub skill_id: String,
pub name: String,
pub description: String,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CheckUpdatesResult {
pub statuses: Vec<(String, UpdateStatus)>,
pub new_skills: Vec<NewRepoSkill>,
}

// --- Agent Info ---

#[derive(Debug, Clone, Serialize, Deserialize)]
Expand Down
164 changes: 107 additions & 57 deletions crates/hk-desktop/src/commands/extensions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -574,8 +574,12 @@ pub fn get_cached_update_statuses(
}
}
_ => {
if let Some(err) = meta.check_error {
UpdateStatus::Error { message: err }
if let Some(ref err) = meta.check_error {
if err == "removed_from_repo" {
UpdateStatus::RemovedFromRepo
} else {
UpdateStatus::Error { message: err.clone() }
}
} else {
continue; // No remote_revision and no error — nothing to report
}
Expand All @@ -589,10 +593,10 @@ pub fn get_cached_update_statuses(
#[tauri::command]
pub async fn check_updates(
state: State<'_, AppState>,
) -> Result<Vec<(String, UpdateStatus)>, HkError> {
) -> Result<CheckUpdatesResult, HkError> {
let store_clone = state.store.clone();

tauri::async_runtime::spawn_blocking(move || -> Result<Vec<(String, UpdateStatus)>, HkError> {
tauri::async_runtime::spawn_blocking(move || -> Result<CheckUpdatesResult, HkError> {
// Read all extensions and release the lock before doing slow network calls
type Updatable = Vec<(String, String, InstallMeta)>; // (id, name, meta)
type Unlinked = Vec<(String, String)>;
Expand Down Expand Up @@ -694,64 +698,100 @@ pub async fn check_updates(
})
.collect();

// For skills marked UpdateAvailable that have a subpath, verify the
// skill still exists in the repo. Group by URL so we clone each repo
// at most once.
// For all skills marked UpdateAvailable, clone the repo to:
// 1. Verify existing skills still exist (RemovedFromRepo detection)
// 2. Discover new skills not yet installed
// Group by URL so we clone each repo at most once.
let mut new_skills: Vec<NewRepoSkill> = Vec::new();
{
use std::collections::HashMap;
let needs_verify: Vec<usize> = statuses
.iter()
.enumerate()
.filter(|(_, (_, _, meta, status))| {
matches!(status, UpdateStatus::UpdateAvailable { .. })
&& meta.subpath.is_some()
})
.map(|(i, _)| i)
.collect();

if !needs_verify.is_empty() {
// url -> temp clone dir
let mut cloned_repos: HashMap<String, Option<tempfile::TempDir>> = HashMap::new();
// Collect all UpdateAvailable indices grouped by resolved URL
let mut url_to_indices: HashMap<String, Vec<usize>> = HashMap::new();
for (idx, (_, _, meta, status)) in statuses.iter().enumerate() {
if !matches!(status, UpdateStatus::UpdateAvailable { .. }) {
continue;
}
let url = meta
.url_resolved
.as_deref()
.or(meta.url.as_deref())
.unwrap_or("");
if !url.is_empty() {
url_to_indices
.entry(url.to_string())
.or_default()
.push(idx);
}
}

for (url, indices) in &url_to_indices {
// Clone once per URL
let temp = match tempfile::tempdir() {
Ok(t) => t,
Err(_) => continue,
};
let clone_path = temp.path().join("repo");
let output = std::process::Command::new("git")
.args(["clone", "--depth", "1", "--", url, &clone_path.to_string_lossy()])
.output();
let ok = output.map(|o| o.status.success()).unwrap_or(false);
if !ok {
continue;
}

for idx in needs_verify {
// 1. Verify existing skills with subpath
for &idx in indices {
let (_, name, meta, _) = &statuses[idx];
let url = meta
.url_resolved
.as_deref()
.or(meta.url.as_deref())
.unwrap_or("");
if url.is_empty() {
continue;
if meta.subpath.is_some()
&& manager::find_skill_in_repo(&clone_path, name).is_none()
{
eprintln!(
"[hk] Skill '{}' no longer exists in repository",
name
);
statuses[idx].3 = UpdateStatus::RemovedFromRepo;
}
}

// Clone once per unique URL
let clone_dir = cloned_repos.entry(url.to_string()).or_insert_with(|| {
let temp = tempfile::tempdir().ok()?;
let clone_path = temp.path().join("repo");
let output = std::process::Command::new("git")
.args(["clone", "--depth", "1", "--", url, &clone_path.to_string_lossy()])
.output()
.ok()?;
if output.status.success() {
Some(temp)
} else {
None
}
});

if let Some(temp) = clone_dir {
let clone_path = temp.path().join("repo");
if manager::find_skill_in_repo(&clone_path, name).is_none() {
eprintln!(
"[hk] Skill '{}' no longer exists in repository — marking as up-to-date",
name
);
// Use install_revision as remote_hash so they match in
// the DB and won't be flagged again after restart.
let local_hash = meta.revision.clone().unwrap_or_default();
statuses[idx].3 = UpdateStatus::UpToDate { remote_hash: local_hash };
// 2. Discover new skills in this repo
let repo_skills = manager::scan_repo_skills(&clone_path);
if repo_skills.len() <= 1 {
// Single-skill repo or empty — no new skills to discover
continue;
}

// Collect installed skill names from DB
// (not just from statuses — covers skills without install_meta too)
let installed_names: std::collections::HashSet<String> = {
let store = store_clone.lock();
let all_exts = store.list_extensions(Some(ExtensionKind::Skill), None)
.unwrap_or_default();
let mut names = std::collections::HashSet::new();
for ext in &all_exts {
let matches_url = ext.install_meta.as_ref().map_or(false, |m| {
m.url_resolved.as_deref().or(m.url.as_deref())
== Some(url.as_str())
});
if matches_url {
names.insert(ext.name.clone());
}
}
names
};

let pack = hk_core::scanner::extract_pack_from_url(url);

for skill in &repo_skills {
if !installed_names.contains(skill.name.as_str()) {
new_skills.push(NewRepoSkill {
repo_url: url.clone(),
pack: pack.clone(),
skill_id: skill.skill_id.clone(),
name: skill.name.clone(),
description: skill.description.clone(),
});
}
}
}
}
Expand All @@ -763,17 +803,21 @@ pub async fn check_updates(
let (remote_rev, check_err) = match status {
UpdateStatus::UpToDate { remote_hash } => (Some(remote_hash.as_str()), None),
UpdateStatus::UpdateAvailable { remote_hash } => (Some(remote_hash.as_str()), None),
UpdateStatus::RemovedFromRepo => (None, Some("removed_from_repo")),
UpdateStatus::Error { message } => (None, Some(message.as_str())),
};
if let Err(e) = store.update_check_state(id, remote_rev, now, check_err) {
eprintln!("[hk] warning: {e}");
}
}

Ok(statuses
.into_iter()
.map(|(id, _, _, status)| (id, status))
.collect())
Ok(CheckUpdatesResult {
statuses: statuses
.into_iter()
.map(|(id, _, _, status)| (id, status))
.collect(),
new_skills,
})
})
.await
.map_err(|e| HkError::Internal(e.to_string()))?
Expand Down Expand Up @@ -836,6 +880,12 @@ pub async fn update_extension(
"[hk] Skill '{}' no longer exists in repository — skipping update",
skill_name
);
// Persist removed_from_repo state so UI shows it after restart
let store = store_clone.lock();
let now = chrono::Utc::now();
if let Err(e) = store.update_check_state(&id, None, now, Some("removed_from_repo")) {
eprintln!("[hk] warning: {e}");
}
return Ok(manager::InstallResult {
name: skill_name.clone(),
was_update: false,
Expand Down
105 changes: 105 additions & 0 deletions crates/hk-desktop/src/commands/install.rs
Original file line number Diff line number Diff line change
Expand Up @@ -420,6 +420,111 @@ pub async fn install_scanned_skills(
.map_err(|e| HkError::Internal(e.to_string()))?
}

// --- Install new skills discovered in existing repos ---

#[tauri::command]
pub async fn install_new_repo_skills(
state: State<'_, AppState>,
url: String,
skill_ids: Vec<String>,
target_agents: Vec<String>,
) -> Result<Vec<manager::InstallResult>, HkError> {
let store_clone = state.store.clone();
let adapters = state.adapters.clone();

tauri::async_runtime::spawn_blocking(move || -> Result<Vec<manager::InstallResult>, HkError> {
// Clone the repo once
let temp = tempfile::tempdir()
.map_err(|e| HkError::Internal(format!("Failed to create temp directory: {e}")))?;
let clone_dir = temp.path().join("repo");
let output = std::process::Command::new("git")
.args(["clone", "--depth", "1", "--", &url, &clone_dir.to_string_lossy()])
.output()
.map_err(|e| HkError::CommandFailed(format!("Failed to run git clone: {e}")))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(HkError::CommandFailed(format!(
"git clone failed: {}",
stderr.trim()
)));
}

let mut results = Vec::new();
for agent_name in &target_agents {
let a = adapters
.iter()
.find(|a| a.name() == agent_name.as_str())
.ok_or_else(|| HkError::NotFound(format!("Agent '{}' not found", agent_name)))?;
let target_dir = a.skill_dirs().into_iter().next().ok_or_else(|| {
HkError::Internal(format!("No skill directory for agent '{}'", agent_name))
})?;
std::fs::create_dir_all(&target_dir)?;

for sid in &skill_ids {
let skill_id_opt = if sid.is_empty() { None } else { Some(sid.as_str()) };
let result = manager::install_from_clone(
&clone_dir,
&target_dir,
skill_id_opt,
&url,
)?;
results.push((agent_name.clone(), sid.clone(), result));
}
}

// Post-install: scan, sync, set meta, audit
{
let store = store_clone.lock();
let install_pack = hk_core::scanner::extract_pack_from_url(&url);
let mut synced_skills = std::collections::HashSet::new();
for (_agent_name, sid, result) in &results {
if !synced_skills.insert(result.name.clone()) {
let meta = InstallMeta {
install_type: "git".into(),
url: Some(url.clone()),
url_resolved: None,
branch: None,
subpath: if sid.is_empty() { None } else { Some(sid.clone()) },
revision: result.revision.clone(),
remote_revision: None,
checked_at: None,
check_error: None,
};
let ext_id = scanner::stable_id_for(&result.name, "skill", _agent_name);
let _ = store.set_install_meta(&ext_id, &meta);
if let Some(ref p) = install_pack {
let _ = store.update_pack(&ext_id, Some(p));
}
continue;
}
let meta = InstallMeta {
install_type: "git".into(),
url: Some(url.clone()),
url_resolved: None,
branch: None,
subpath: if sid.is_empty() { None } else { Some(sid.clone()) },
revision: result.revision.clone(),
remote_revision: None,
checked_at: None,
check_error: None,
};
service::post_install_sync(
&store,
&adapters,
&target_agents,
&result.name,
Some(meta),
install_pack.as_deref(),
)?;
}
}

Ok(results.into_iter().map(|(_, _, r)| r).collect())
})
.await
.map_err(|e| HkError::Internal(e.to_string()))?
}

// --- Cross-agent deploy command ---

#[tauri::command]
Expand Down
1 change: 1 addition & 0 deletions crates/hk-desktop/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ fn main() {
commands::read_config_file_preview,
commands::scan_git_repo,
commands::install_scanned_skills,
commands::install_new_repo_skills,
commands::get_cli_with_children,
commands::list_cli_marketplace,
commands::install_cli,
Expand Down
9 changes: 9 additions & 0 deletions src/components/extensions/extension-detail.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {
AlertTriangle,
Calendar,
Download,
FolderOpen,
Expand Down Expand Up @@ -238,6 +239,14 @@ export function ExtensionDetail() {
</>
);
})()}
{group.instances.some(
(inst) => updateStatuses.get(inst.id)?.status === "removed_from_repo",
) && (
<div className="flex items-center gap-2 text-muted-foreground">
<AlertTriangle size={14} />
<span>No longer available in the repository</span>
</div>
)}
<div className="flex items-center gap-2 text-muted-foreground">
<Calendar size={14} />
<span>
Expand Down
Loading
Loading