diff --git a/.github/workflows/package-and-publish.yml b/.github/workflows/package-and-publish.yml index bf52c5356..65abb7719 100644 --- a/.github/workflows/package-and-publish.yml +++ b/.github/workflows/package-and-publish.yml @@ -10,7 +10,7 @@ name: Package and publish on: push: - branches: ["master","main"] + branches: ["master", "main"] workflow_dispatch: inputs: forceRelease: @@ -45,7 +45,7 @@ jobs: run: | PACKAGE_VERSION=$(grep -m1 '^version' tauri/Cargo.toml | sed -E 's/.*"([^"]+)".*/\1/') TAG_NAME="air_v${PACKAGE_VERSION}" - RELEASE_NAME="Telegram Air v${PACKAGE_VERSION}" + RELEASE_NAME="AlphaHuman v${PACKAGE_VERSION}" echo "package-version=$PACKAGE_VERSION" >> $GITHUB_OUTPUT echo "tag-name=$TAG_NAME" >> $GITHUB_OUTPUT echo "release-name=$RELEASE_NAME" >> $GITHUB_OUTPUT diff --git a/.gitignore b/.gitignore index 371ae8b63..2893b6400 100644 --- a/.gitignore +++ b/.gitignore @@ -32,3 +32,5 @@ dist-ssr *.sw? references/ src-tauri/runtime-skill-* +.mypy_cache +.ruff_cache diff --git a/public/skills-catalog.json b/public/skills-catalog.json deleted file mode 100644 index a1226dcce..000000000 --- a/public/skills-catalog.json +++ /dev/null @@ -1,139 +0,0 @@ -{ - "generatedAt": "2026-01-31T22:33:58.672153+00:00", - "version": "1.0.0", - "skills": [ - { - "name": "email", - "description": "Email integration via IMAP/SMTP \u2014 35 tools for reading, sending, searching, and managing email across Gmail, Outlook, Yahoo, iCloud, and custom servers.", - "icon": null, - "version": "1.0.0", - "tools": [], - "hooks": [ - "on_load", - "on_unload", - "on_tick", - "on_setup_start", - "on_setup_submit", - "on_setup_cancel" - ], - "tickIntervalMinutes": 5.0, - "path": "skills/email" - }, - { - "name": "github", - "description": "GitHub integration via PyGithub \u2014 72 tools for repos, issues, PRs, releases, gists, actions, search, notifications, and raw API access.", - "icon": null, - "version": "1.0.0", - "tools": [], - "hooks": [ - "on_load", - "on_unload", - "on_tick", - "on_setup_start", - "on_setup_submit", - "on_setup_cancel" - ], - "tickIntervalMinutes": 5.0, - "path": "skills/github" - }, - { - "name": "notion", - "description": "Notion workspace integration \u2014 22 tools for pages, databases, blocks, users, comments, and search.", - "icon": null, - "version": "1.0.0", - "tools": [ - "notion_search", - "notion_get_page", - "notion_create_page", - "notion_update_page", - "notion_delete_page", - "notion_query_database", - "notion_get_database", - "notion_create_database", - "notion_update_database", - "notion_get_block", - "notion_get_block_children", - "notion_append_blocks", - "notion_update_block", - "notion_delete_block", - "notion_list_users", - "notion_get_user", - "notion_create_comment", - "notion_list_comments", - "notion_get_page_content", - "notion_append_text", - "notion_list_all_pages", - "notion_list_all_databases" - ], - "hooks": [ - "on_load", - "on_unload", - "on_tick" - ], - "tickIntervalMinutes": 5.0, - "path": "skills/notion" - }, - { - "name": "otter", - "description": "Otter.ai meeting notes integration \u2014 fetch transcripts, summaries, and search across meetings.", - "icon": null, - "version": "1.0.0", - "tools": [ - "list_meetings", - "get_meeting", - "get_meeting_summary", - "search_meetings", - "search_in_meeting", - "download_meeting_transcript", - "get_otter_user", - "list_speakers" - ], - "hooks": [ - "on_load", - "on_unload", - "on_tick" - ], - "tickIntervalMinutes": 5.0, - "path": "skills/otter" - }, - { - "name": "skill-generator", - "description": "Meta-skill that creates, validates, tests, and scans new AlphaHuman skills on-the-fly.", - "icon": null, - "version": "1.0.0", - "tools": [ - "list_available_skills", - "generate_skill", - "write_skill_file", - "validate_skill", - "test_skill", - "security_scan_skill" - ], - "hooks": [ - "on_load", - "on_unload", - "on_session_start", - "on_before_message" - ], - "tickIntervalMinutes": null, - "path": "skills/skill-generator" - }, - { - "name": "telegram", - "description": "Telegram integration via Telethon MTProto \u2014 75+ tools for chats, messages, contacts, admin, media, and settings.", - "icon": null, - "version": "2.0.0", - "tools": [], - "hooks": [ - "on_load", - "on_unload", - "on_tick", - "on_setup_start", - "on_setup_submit", - "on_setup_cancel" - ], - "tickIntervalMinutes": 20.0, - "path": "skills/telegram" - } - ] -} diff --git a/skills b/skills index 87bb3adab..760019ff9 160000 --- a/skills +++ b/skills @@ -1 +1 @@ -Subproject commit 87bb3adab1286e3923b26ba9407c0a2af6ad4069 +Subproject commit 760019ff9a25df62c1310a929f86d020ab82f86f diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 89ff40dcb..988c49be1 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1159,6 +1159,17 @@ dependencies = [ "rustc_version", ] +[[package]] +name = "filetime" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f98844151eee8917efc50bd9e8318cb963ae8b297431495d3f758616ea5c57db" +dependencies = [ + "cfg-if", + "libc", + "libredox", +] + [[package]] name = "find-msvc-tools" version = "0.1.8" @@ -2247,6 +2258,7 @@ checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616" dependencies = [ "bitflags 2.10.0", "libc", + "redox_syscall 0.7.0", ] [[package]] @@ -2869,7 +2881,7 @@ checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" dependencies = [ "cfg-if", "libc", - "redox_syscall", + "redox_syscall 0.5.18", "smallvec", "windows-link 0.2.1", ] @@ -3383,6 +3395,15 @@ dependencies = [ "bitflags 2.10.0", ] +[[package]] +name = "redox_syscall" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49f3fe0889e69e2ae9e41f4d6c4c0181701d00e4697b356fb1f74173a5e0ee27" +dependencies = [ + "bitflags 2.10.0", +] + [[package]] name = "redox_users" version = "0.4.6" @@ -3998,7 +4019,7 @@ dependencies = [ "objc2-foundation", "objc2-quartz-core", "raw-window-handle", - "redox_syscall", + "redox_syscall 0.5.18", "tracing", "wasm-bindgen", "web-sys", @@ -4212,6 +4233,17 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "tar" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d863878d212c87a19c1a610eb53bb01fe12951c0501cf5a0d65f724914a667a" +dependencies = [ + "filetime", + "libc", + "xattr", +] + [[package]] name = "target-lexicon" version = "0.12.16" @@ -4278,6 +4310,7 @@ dependencies = [ "base64 0.22.1", "dirs 5.0.1", "env_logger", + "flate2", "keyring", "log", "once_cell", @@ -4287,6 +4320,7 @@ dependencies = [ "serde", "serde_json", "sha2", + "tar", "tauri", "tauri-build", "tauri-plugin-autostart", @@ -5976,6 +6010,16 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix", +] + [[package]] name = "yoke" version = "0.8.1" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 188dee124..3a3777f25 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -47,6 +47,10 @@ parking_lot = "0.12" log = "0.4" env_logger = "0.11" +# Archive extraction (for downloading skills repo without git) +tar = "0.4" +flate2 = "1" + # AI module dependencies base64 = "0.22" aes-gcm = "0.10" diff --git a/src-tauri/src/commands/skills.rs b/src-tauri/src/commands/skills.rs index 7126d02f9..d2cb140ca 100644 --- a/src-tauri/src/commands/skills.rs +++ b/src-tauri/src/commands/skills.rs @@ -1,9 +1,111 @@ //! Skill data I/O commands. //! //! Provides filesystem operations for skill data directories, -//! used by the reverse RPC handler in the frontend. +//! catalog reading, and remote repo syncing (without git). +use base64::Engine; use std::path::PathBuf; +use std::time::{SystemTime, UNIX_EPOCH}; + +/// Default GitHub repo for skills (owner/repo format). +const SKILLS_GITHUB_REPO: &str = "alphahumanxyz/skills"; +/// Default branch to sync from. +const SKILLS_GITHUB_BRANCH: &str = "main"; +/// Minimum interval between remote update checks (24 hours in seconds). +const UPDATE_CHECK_INTERVAL_SECS: u64 = 24 * 60 * 60; + +// --------------------------------------------------------------------------- +// Sync metadata — persisted at ~/.alphahuman/skills-sync.json +// --------------------------------------------------------------------------- + +#[derive(serde::Serialize, serde::Deserialize, Default, Clone)] +struct SyncMeta { + /// Full commit SHA of the currently-synced skills. + commit_sha: Option, + /// Unix epoch seconds of the last remote check. + last_checked_at: Option, + /// The repo that was synced (owner/repo). + repo: Option, + /// The branch that was synced. + branch: Option, +} + +fn sync_meta_path() -> Result { + let data_dir = crate::ai::encryption::get_data_dir()?; + Ok(data_dir.join("skills-sync.json")) +} + +fn read_sync_meta() -> SyncMeta { + let path = match sync_meta_path() { + Ok(p) => p, + Err(_) => return SyncMeta::default(), + }; + match std::fs::read_to_string(&path) { + Ok(content) => serde_json::from_str(&content).unwrap_or_default(), + Err(_) => SyncMeta::default(), + } +} + +fn write_sync_meta(meta: &SyncMeta) -> Result<(), String> { + let path = sync_meta_path()?; + let json = serde_json::to_string_pretty(meta) + .map_err(|e| format!("Failed to serialize sync meta: {}", e))?; + std::fs::write(&path, json.as_bytes()) + .map_err(|e| format!("Failed to write sync meta: {}", e)) +} + +fn now_epoch_secs() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() +} + +/// Fetch the latest commit SHA for a branch from the GitHub API. +async fn fetch_remote_commit_sha( + repo: &str, + branch: &str, + github_token: Option<&str>, +) -> Result { + let url = format!( + "https://api.github.com/repos/{}/commits/{}", + repo, branch + ); + + let client = reqwest::Client::new(); + let mut request = client + .get(&url) + .header("User-Agent", "AlphaHuman-Desktop") + .header("Accept", "application/vnd.github.sha"); + + if let Some(token) = github_token { + if !token.is_empty() { + request = request.header("Authorization", format!("Bearer {}", token)); + } + } + + let response = request + .send() + .await + .map_err(|e| format!("Failed to fetch latest commit: {}", e))?; + + if !response.status().is_success() { + return Err(format!( + "GitHub API returned status {} when checking commits", + response.status() + )); + } + + // With Accept: application/vnd.github.sha the body is the raw SHA string + let sha = response + .text() + .await + .map_err(|e| format!("Failed to read commit SHA response: {}", e))? + .trim() + .to_string(); + + Ok(sha) +} /// Absolute path to the skills working directory (cwd for `python -m skills.xxx`). /// In dev: project root's `skills/` (submodule). In prod: `~/.alphahuman/skills/`. @@ -200,3 +302,260 @@ pub async fn skill_list_manifests() -> Result, String> { Ok(manifests) } + +/// Read the skills-catalog.json from the local skills directory. +/// +/// In dev: reads from the submodule at `./skills/skills-catalog.json`. +/// In prod: reads from `~/.alphahuman/skills/skills-catalog.json`. +#[tauri::command] +pub async fn skill_read_catalog() -> Result { + let skills_cwd = skill_cwd().await?; + let catalog_path = PathBuf::from(&skills_cwd).join("skills-catalog.json"); + + let content = tokio::fs::read_to_string(&catalog_path) + .await + .map_err(|e| format!("Failed to read skills catalog at {:?}: {}", catalog_path, e))?; + + serde_json::from_str(&content) + .map_err(|e| format!("Failed to parse skills catalog: {}", e)) +} + +/// Download and extract the skills repository from GitHub (no git required). +/// +/// Fetches the repo as a tarball via the GitHub API, extracts it to +/// `~/.alphahuman/skills/`. Pass `github_token` for private repositories. +/// Optionally override `repo` (default: `alphahumanxyz/skills`) and +/// `branch` (default: `main`). +/// +/// After a successful sync, saves the commit SHA and timestamp to +/// `~/.alphahuman/skills-sync.json` for future update checks. +#[tauri::command] +pub async fn skill_sync_repo( + repo: Option, + branch: Option, + github_token: Option, +) -> Result<(), String> { + let repo = repo.unwrap_or_else(|| SKILLS_GITHUB_REPO.to_string()); + let branch = branch.unwrap_or_else(|| SKILLS_GITHUB_BRANCH.to_string()); + let token_ref = github_token.as_deref(); + + let data_dir = crate::ai::encryption::get_data_dir()?; + let skills_dir = data_dir.join("skills"); + + // Fetch the latest commit SHA so we can record it after sync + let commit_sha = fetch_remote_commit_sha(&repo, &branch, token_ref).await?; + + // Download tarball from GitHub API + let url = format!( + "https://api.github.com/repos/{}/tarball/{}", + repo, branch + ); + + log::info!("Syncing skills from {} (commit {})", url, &commit_sha[..8.min(commit_sha.len())]); + + let client = reqwest::Client::new(); + let mut request = client + .get(&url) + .header("User-Agent", "AlphaHuman-Desktop") + .header("Accept", "application/vnd.github+json"); + + if let Some(token) = token_ref { + if !token.is_empty() { + request = request.header("Authorization", format!("Bearer {}", token)); + } + } + + let response = request + .send() + .await + .map_err(|e| format!("Failed to download skills repo: {}", e))?; + + if !response.status().is_success() { + return Err(format!( + "GitHub API returned status {} for {}", + response.status(), + url + )); + } + + let bytes = response + .bytes() + .await + .map_err(|e| format!("Failed to read response body: {}", e))?; + + // Extract tarball to a temporary directory first + let temp_dir = data_dir.join("skills_sync_tmp"); + + // Clean up temp dir if leftover from a previous failed sync + let _ = tokio::fs::remove_dir_all(&temp_dir).await; + tokio::fs::create_dir_all(&temp_dir) + .await + .map_err(|e| format!("Failed to create temp dir: {}", e))?; + + // Decompress gzip and extract tar archive + let gz = flate2::read::GzDecoder::new(&bytes[..]); + let mut archive = tar::Archive::new(gz); + + archive + .unpack(&temp_dir) + .map_err(|e| format!("Failed to extract tarball: {}", e))?; + + // GitHub tarballs contain a single top-level directory like "owner-repo-commitsha/" + // Find that directory. + let mut entries = tokio::fs::read_dir(&temp_dir) + .await + .map_err(|e| format!("Failed to read temp dir: {}", e))?; + + let extracted_dir = if let Ok(Some(entry)) = entries.next_entry().await { + let path = entry.path(); + if path.is_dir() { + path + } else { + return Err("Extracted tarball did not contain a directory".to_string()); + } + } else { + return Err("No directory found in extracted tarball".to_string()); + }; + + // Replace the production skills directory with the extracted content + if skills_dir.exists() { + tokio::fs::remove_dir_all(&skills_dir) + .await + .map_err(|e| format!("Failed to remove old skills dir: {}", e))?; + } + + tokio::fs::rename(&extracted_dir, &skills_dir) + .await + .map_err(|e| format!("Failed to move extracted skills: {}", e))?; + + // Clean up temp dir + let _ = tokio::fs::remove_dir_all(&temp_dir).await; + + // Persist sync metadata + let meta = SyncMeta { + commit_sha: Some(commit_sha.clone()), + last_checked_at: Some(now_epoch_secs()), + repo: Some(repo), + branch: Some(branch), + }; + if let Err(e) = write_sync_meta(&meta) { + log::warn!("Failed to write sync metadata: {}", e); + } + + log::info!( + "Skills synced successfully to {:?} (commit {})", + skills_dir, + &commit_sha[..8.min(commit_sha.len())] + ); + Ok(()) +} + +/// Check whether the local skills directory has a catalog file. +/// Returns `true` if `skills-catalog.json` exists locally (no sync needed). +#[tauri::command] +pub async fn skill_catalog_exists() -> Result { + let skills_cwd = skill_cwd().await?; + let catalog_path = PathBuf::from(&skills_cwd).join("skills-catalog.json"); + Ok(catalog_path.exists()) +} + +/// Check if a skills update is available from GitHub. +/// +/// Respects a 24-hour cooldown — if the last check was less than 24 hours +/// ago, returns immediately without hitting the network. +/// +/// Returns a JSON object: +/// ```json +/// { "needs_update": true, "local_sha": "abc...", "remote_sha": "def..." } +/// ``` +#[tauri::command] +pub async fn skill_check_for_updates( + repo: Option, + branch: Option, + github_token: Option, + force: Option, +) -> Result { + let repo = repo.unwrap_or_else(|| SKILLS_GITHUB_REPO.to_string()); + let branch = branch.unwrap_or_else(|| SKILLS_GITHUB_BRANCH.to_string()); + let force = force.unwrap_or(false); + + let mut meta = read_sync_meta(); + let now = now_epoch_secs(); + + // Respect cooldown unless forced + if !force { + if let Some(last_checked) = meta.last_checked_at { + if now.saturating_sub(last_checked) < UPDATE_CHECK_INTERVAL_SECS { + return Ok(serde_json::json!({ + "needs_update": false, + "skipped": true, + "reason": "checked_recently", + "local_sha": meta.commit_sha, + })); + } + } + } + + // Fetch latest commit SHA from GitHub + let remote_sha = fetch_remote_commit_sha( + &repo, + &branch, + github_token.as_deref(), + ) + .await?; + + // Update last_checked_at regardless of whether an update is needed + meta.last_checked_at = Some(now); + if let Err(e) = write_sync_meta(&meta) { + log::warn!("Failed to update last_checked_at: {}", e); + } + + let needs_update = match &meta.commit_sha { + Some(local_sha) => local_sha != &remote_sha, + None => true, // No local SHA means never synced + }; + + Ok(serde_json::json!({ + "needs_update": needs_update, + "local_sha": meta.commit_sha, + "remote_sha": remote_sha, + })) +} + +/// Read the icon file for a skill, returning it as a base64 data URL. +/// +/// Checks for `icon.svg` first, then `icon.png` in the skill's directory. +/// Returns `null` if no icon file is found. +#[tauri::command] +pub async fn skill_read_icon(skill_id: String) -> Result, String> { + if skill_id.contains("..") || skill_id.contains('/') || skill_id.contains('\\') { + return Err("Invalid skill ID".to_string()); + } + + let skills_cwd = skill_cwd().await?; + let skill_dir = PathBuf::from(&skills_cwd).join("skills").join(&skill_id); + + let engine = base64::engine::general_purpose::STANDARD; + + // Check for icon.svg + let svg_path = skill_dir.join("icon.svg"); + if svg_path.exists() { + let content = tokio::fs::read(&svg_path) + .await + .map_err(|e| format!("Failed to read icon.svg: {}", e))?; + let b64 = engine.encode(&content); + return Ok(Some(format!("data:image/svg+xml;base64,{}", b64))); + } + + // Check for icon.png + let png_path = skill_dir.join("icon.png"); + if png_path.exists() { + let content = tokio::fs::read(&png_path) + .await + .map_err(|e| format!("Failed to read icon.png: {}", e))?; + let b64 = engine.encode(&content); + return Ok(Some(format!("data:image/png;base64,{}", b64))); + } + + Ok(None) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 4d1cc8612..60d7a0b55 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -233,6 +233,11 @@ pub fn run() { skill_list_manifests, skill_cwd, skill_venv_site_packages, + skill_read_catalog, + skill_sync_repo, + skill_catalog_exists, + skill_check_for_updates, + skill_read_icon, ]) .build(tauri::generate_context!()) .expect("error while building tauri application") diff --git a/src/components/ConnectionIndicator.tsx b/src/components/ConnectionIndicator.tsx index be9ba35de..7dfc8f27b 100644 --- a/src/components/ConnectionIndicator.tsx +++ b/src/components/ConnectionIndicator.tsx @@ -9,7 +9,7 @@ interface ConnectionIndicatorProps { const ConnectionIndicator = ({ status: overrideStatus, - description = "Your browser is now connected to the AlphaHuman AI. Keep this tab open to keep the connection alive. You can message your assistant with the button below.", + description = "Your browser is now connected to the AlphaHuman AI. Keep the app running to keep the connection alive. You can message your assistant with the button below.", className = "", }: ConnectionIndicatorProps) => { // Use socket store status, but allow override via props diff --git a/src/components/SkillsGrid.tsx b/src/components/SkillsGrid.tsx index a06052218..80ac54c52 100644 --- a/src/components/SkillsGrid.tsx +++ b/src/components/SkillsGrid.tsx @@ -1,6 +1,7 @@ -import { useState, useEffect } from "react"; +import { useState, useEffect, useMemo } from "react"; import { useSkillConnectionStatus } from "../lib/skills/hooks"; -import type { SkillConnectionStatus } from "../lib/skills/types"; +import type { SkillConnectionStatus, SkillHostConnectionState } from "../lib/skills/types"; +import { useAppSelector } from "../store/hooks"; import SkillSetupModal from "./skills/SkillSetupModal"; import TelegramIcon from "../assets/icons/telegram.svg"; @@ -9,16 +10,16 @@ import NotionIcon from "../assets/icons/notion.svg"; // Map skill IDs to icons const SKILL_ICONS: Record = { - telegram: Telegram, - email: , - notion: Notion, + telegram: Telegram, + email: , + notion: Notion, github: ( - + ), otter: ( - + ), @@ -26,92 +27,83 @@ const SKILL_ICONS: Record = { // Default icon for unknown skills const DefaultIcon = () => ( -
- +
+
); -// Status badge colors with better contrast -const STATUS_COLORS: Record = { +// Status display text and colors +const STATUS_DISPLAY: Record = { connected: { - bg: "bg-sage-500/30", - text: "text-sage-100", - border: "border-sage-500/50", + text: "Connected", + color: "text-sage-400", }, connecting: { - bg: "bg-amber-500/30", - text: "text-amber-100", - border: "border-amber-500/50", + text: "Connecting", + color: "text-amber-400", }, not_authenticated: { - bg: "bg-amber-500/30", - text: "text-amber-100", - border: "border-amber-500/50", + text: "Not Auth", + color: "text-amber-400", }, disconnected: { - bg: "bg-stone-600/40", - text: "text-stone-200", - border: "border-stone-500/60", + text: "Disconnected", + color: "text-stone-400", }, error: { - bg: "bg-coral-500/30", - text: "text-coral-100", - border: "border-coral-500/50", + text: "Error", + color: "text-coral-400", }, offline: { - bg: "bg-stone-600/40", - text: "text-stone-200", - border: "border-stone-500/60", + text: "Offline", + color: "text-stone-500", }, setup_required: { - bg: "bg-primary-500/30", - text: "text-primary-100", - border: "border-primary-500/50", + text: "Setup", + color: "text-primary-400", }, }; -interface SkillCardProps { +interface SkillRowProps { skillId: string; name: string; - description: string; icon?: React.ReactElement; - onConnect: () => void; + onConnect: (e: React.MouseEvent) => void; } -function SkillCard({ skillId, name, description: _description, icon, onConnect }: SkillCardProps) { +function SkillRow({ skillId, name, icon, onConnect }: SkillRowProps) { const connectionStatus = useSkillConnectionStatus(skillId); - const statusConfig = STATUS_COLORS[connectionStatus] || STATUS_COLORS.offline; + const statusDisplay = STATUS_DISPLAY[connectionStatus] || STATUS_DISPLAY.offline; return ( - + + + {statusDisplay.text} + + + + + + + ); } @@ -132,68 +124,132 @@ interface SkillsCatalog { skills: SkillCatalogEntry[]; } +interface SkillListEntry { + id: string; + name: string; + description: string; + icon?: React.ReactElement; + hasSetup: boolean; +} + +// Helper function to derive connection status (same logic as in hooks.ts) +function deriveConnectionStatus( + lifecycleStatus: string | undefined, + setupComplete: boolean | undefined, + skillState: Record | undefined, +): SkillConnectionStatus { + if (!lifecycleStatus || lifecycleStatus === "installed") { + return "offline"; + } + if (lifecycleStatus === "error") { + return "error"; + } + if (lifecycleStatus === "setup_required" || lifecycleStatus === "setup_in_progress") { + return "setup_required"; + } + if (lifecycleStatus === "starting") { + return "connecting"; + } + const hostState = skillState as SkillHostConnectionState | undefined; + if (!hostState) { + return lifecycleStatus === "ready" ? "connecting" : "connecting"; + } + const connStatus = hostState.connection_status; + const authStatus = hostState.auth_status; + if (connStatus === "error" || authStatus === "error") { + return "error"; + } + if (connStatus === "connected" && authStatus === "authenticated") { + return "connected"; + } + if (connStatus === "connecting" || authStatus === "authenticating") { + return "connecting"; + } + if (connStatus === "connected" && authStatus === "not_authenticated") { + return "not_authenticated"; + } + if (connStatus === "disconnected") { + return setupComplete ? "disconnected" : "setup_required"; + } + return "connecting"; +} + +// Priority order for sorting (lower number = higher priority) +const STATUS_PRIORITY: Record = { + connected: 1, + connecting: 2, + not_authenticated: 3, + disconnected: 4, + setup_required: 5, + offline: 6, + error: 7, +}; + export default function SkillsGrid() { - const [skillsList, setSkillsList] = useState>([]); + const [skillsList, setSkillsList] = useState([]); const [loading, setLoading] = useState(true); const [setupModalOpen, setSetupModalOpen] = useState(false); + const [managementModalOpen, setManagementModalOpen] = useState(false); const [activeSkillId, setActiveSkillId] = useState(null); const [activeSkillName, setActiveSkillName] = useState(""); + const [activeSkillDescription, setActiveSkillDescription] = useState(""); + const [activeSkillHasSetup, setActiveSkillHasSetup] = useState(false); + + // Get Redux state for sorting + const skillsState = useAppSelector((state) => state.skills.skills); + const skillStates = useAppSelector((state) => state.skills.skillStates); useEffect(() => { - // Load skills catalog from the skills repo - // The file should be copied to public/skills-catalog.json during build + // Load skills catalog from the local skills directory via Rust. + // In dev: reads from the submodule. In prod: reads from ~/.alphahuman/skills/. const loadSkillsCatalog = async () => { try { - // Try to load from public folder (served at root in Vite) - const response = await fetch("/skills-catalog.json"); - if (!response.ok) { - // Fallback: try skills submodule path - const fallbackResponse = await fetch("/skills/skills-catalog.json"); - if (!fallbackResponse.ok) { - console.warn("Could not load skills-catalog.json. Make sure it's copied to public/ folder."); - setLoading(false); - return; - } - const catalog: SkillsCatalog = await fallbackResponse.json(); - processCatalog(catalog); - return; - } - const catalog: SkillsCatalog = await response.json(); - processCatalog(catalog); + const { invoke } = await import("@tauri-apps/api/core"); + const catalog: SkillsCatalog = await invoke("skill_read_catalog"); + + // Load manifests to get proper display names + const manifests = await invoke>>("skill_list_manifests"); + const manifestMap = new Map( + manifests + .filter((m): m is { id: string; name: string } => typeof m.id === "string" && typeof m.name === "string") + .map((m) => [m.id, m.name]) + ); + + processCatalog(catalog, manifestMap); } catch (error) { - console.error("Error loading skills catalog:", error); + console.warn("Could not load skills catalog from filesystem:", error); setLoading(false); } }; - const processCatalog = (catalog: SkillsCatalog) => { - // Filter skills that have setup hooks and validate skill names - const skillsWithSetup = catalog.skills.filter((skill) => { - // Skip skills with underscores in name (used for tool namespacing) + const processCatalog = (catalog: SkillsCatalog, manifestMap: Map) => { + // Validate skill names (underscores are reserved for tool namespacing) + const validSkills = catalog.skills.filter((skill) => { if (skill.name.includes("_")) { console.warn( `Skill "${skill.name}" contains underscore and will be skipped. Skill names cannot contain underscores.` ); return false; } - return ( - skill.hooks.includes("on_setup_start") && - skill.hooks.includes("on_setup_submit") && - skill.hooks.includes("on_setup_cancel") - ); + return true; }); - const processed = skillsWithSetup.map((skill) => ({ - id: skill.name, - name: skill.name.charAt(0).toUpperCase() + skill.name.slice(1), - description: skill.description, - icon: SKILL_ICONS[skill.name], - })); + const processed: SkillListEntry[] = validSkills.map((skill) => { + const skillId = skill.name; + // Use manifest name if available, otherwise capitalize the ID + const displayName = manifestMap.get(skillId) || skill.name.charAt(0).toUpperCase() + skill.name.slice(1); + + return { + id: skillId, + name: displayName, + description: skill.description, + icon: SKILL_ICONS[skillId], + hasSetup: + skill.hooks.includes("on_setup_start") && + skill.hooks.includes("on_setup_submit") && + skill.hooks.includes("on_setup_cancel"), + }; + }); setSkillsList(processed); setLoading(false); @@ -202,32 +258,91 @@ export default function SkillsGrid() { loadSkillsCatalog(); }, []); + // Sort skills by connection status (connected first) + const sortedSkillsList = useMemo(() => { + return [...skillsList].sort((a, b) => { + const skillA = skillsState[a.id]; + const skillB = skillsState[b.id]; + const stateA = skillStates[a.id]; + const stateB = skillStates[b.id]; + + const statusA = deriveConnectionStatus( + skillA?.status, + skillA?.setupComplete, + stateA, + ); + const statusB = deriveConnectionStatus( + skillB?.status, + skillB?.setupComplete, + stateB, + ); + + const priorityA = STATUS_PRIORITY[statusA] ?? 999; + const priorityB = STATUS_PRIORITY[statusB] ?? 999; + + // If same priority, sort alphabetically by name + if (priorityA === priorityB) { + return a.name.localeCompare(b.name); + } + + return priorityA - priorityB; + }); + }, [skillsList, skillsState, skillStates]); + // If loading or no skills, don't render if (loading || skillsList.length === 0) { return null; } - const handleConnect = (skillId: string, skillName: string) => { - setActiveSkillId(skillId); - setActiveSkillName(skillName); + const handleConnect = (skill: SkillListEntry) => { + setActiveSkillId(skill.id); + setActiveSkillName(skill.name); + setActiveSkillDescription(skill.description); + setActiveSkillHasSetup(skill.hasSetup); setSetupModalOpen(true); }; return ( <> -
-

Available Skills

-
- {skillsList.map((skill) => ( - handleConnect(skill.id, skill.name)} - /> - ))} +
+

Available Skills

+
setManagementModalOpen(true)} + > +
+ + + + + + + + + + {sortedSkillsList.map((skill) => ( + { + e.stopPropagation(); + handleConnect(skill); + }} + /> + ))} + +
+ Skill + + Status +
+
+ {/* Hover overlay */} +
+ Click to manage skills +
@@ -236,12 +351,88 @@ export default function SkillsGrid() { { setSetupModalOpen(false); setActiveSkillId(null); }} /> )} + + {/* Skills Management Modal */} + {managementModalOpen && ( +
setManagementModalOpen(false)} + > +
e.stopPropagation()} + > + {/* Sticky Header */} +
+

Manage Skills

+ +
+ {/* Scrollable Content */} +
+
+ {sortedSkillsList.map((skill) => { + const skillState = skillsState[skill.id]; + const stateData = skillStates[skill.id]; + const connectionStatus = deriveConnectionStatus( + skillState?.status, + skillState?.setupComplete, + stateData, + ); + const statusDisplay = STATUS_DISPLAY[connectionStatus] || STATUS_DISPLAY.offline; + + return ( +
+
+
+ {skill.icon || } +
+
+
+
{skill.name}
+ {statusDisplay.text} +
+
{skill.description}
+
+
+ +
+ ); + })} +
+
+
+
+ )} ); } diff --git a/src/components/skills/SkillManagementPanel.tsx b/src/components/skills/SkillManagementPanel.tsx new file mode 100644 index 000000000..664ce0017 --- /dev/null +++ b/src/components/skills/SkillManagementPanel.tsx @@ -0,0 +1,194 @@ +/** + * Management panel shown when clicking a connected (setupComplete) skill. + * Displays connection status, configurable options, and action buttons. + */ + +import { useState, useEffect, useCallback } from "react"; +import { useAppSelector } from "../../store/hooks"; +import { + useSkillConnectionStatus, + useSkillConnectionInfo, +} from "../../lib/skills/hooks"; +import { skillManager } from "../../lib/skills/manager"; +import type { + SkillOptionDefinition, +} from "../../lib/skills/types"; + +interface SkillManagementPanelProps { + skillId: string; + onClose: () => void; + /** If provided, shows a "Re-run Setup" button. Omit for skills without setup. */ + onReconfigure?: () => void; +} + + +export default function SkillManagementPanel({ + skillId, + onClose, + onReconfigure, +}: SkillManagementPanelProps) { + const skill = useAppSelector((state) => state.skills.skills[skillId]); + const connectionStatus = useSkillConnectionStatus(skillId); + const connectionInfo = useSkillConnectionInfo(skillId); + + const [options, setOptions] = useState([]); + const [togglingOption, setTogglingOption] = useState(null); + const [restarting, setRestarting] = useState(false); + const [disconnecting, setDisconnecting] = useState(false); + const [confirmDisconnect, setConfirmDisconnect] = useState(false); + + // Load options from the skill process + useEffect(() => { + let cancelled = false; + async function load() { + if (!skillManager.isSkillRunning(skillId)) return; + try { + const opts = await skillManager.listOptions(skillId); + if (!cancelled) setOptions(opts); + } catch { + // Skill may not support options + } + } + load(); + return () => { + cancelled = true; + }; + }, [skillId, connectionStatus]); + + const handleToggleOption = useCallback( + async (name: string, currentValue: unknown) => { + setTogglingOption(name); + try { + const newValue = !currentValue; + await skillManager.setOption(skillId, name, newValue); + // Refresh options to get updated values + const opts = await skillManager.listOptions(skillId); + setOptions(opts); + } catch (err) { + console.error("[SkillManagementPanel] Toggle option failed:", err); + } finally { + setTogglingOption(null); + } + }, + [skillId], + ); + + const handleRestart = useCallback(async () => { + if (!skill?.manifest) return; + setRestarting(true); + try { + await skillManager.stopSkill(skillId); + await skillManager.startSkill(skill.manifest); + } catch (err) { + console.error("[SkillManagementPanel] Restart failed:", err); + } finally { + setRestarting(false); + } + }, [skillId, skill?.manifest]); + + const handleDisconnect = useCallback(async () => { + setDisconnecting(true); + try { + await skillManager.disconnectSkill(skillId); + onClose(); + } catch (err) { + console.error("[SkillManagementPanel] Disconnect failed:", err); + setDisconnecting(false); + } + }, [skillId, onClose]); + + return ( +
+ {/* Error message */} + {connectionInfo.error && ( +
+ {connectionInfo.error} +
+ )} + + {/* Options */} + {options.length > 0 && ( +
+
+ Options +
+ {options.map((opt) => ( +
+
+
+ {opt.label} +
+ {opt.description && ( +
+ {opt.description} +
+ )} +
+ {opt.type === "boolean" && ( + + )} +
+ ))} +
+ )} + + {/* Action buttons — single row */} +
+ {!confirmDisconnect ? ( +
+ + {onReconfigure && ( + + )} + +
+ ) : ( +
+ + +
+ )} +
+
+ ); +} diff --git a/src/components/skills/SkillSetupModal.tsx b/src/components/skills/SkillSetupModal.tsx index cfca99edd..4233773c5 100644 --- a/src/components/skills/SkillSetupModal.tsx +++ b/src/components/skills/SkillSetupModal.tsx @@ -1,24 +1,39 @@ /** - * Modal wrapper for the skill setup wizard. + * Modal wrapper for the skill setup wizard or management panel. + * Shows management view for connected skills, setup wizard otherwise. * Uses createPortal like the settings modal system. */ -import { useEffect, useRef } from "react"; +import { useState, useEffect, useRef } from "react"; import { createPortal } from "react-dom"; +import { useAppSelector } from "../../store/hooks"; import SkillSetupWizard from "./SkillSetupWizard"; +import SkillManagementPanel from "./SkillManagementPanel"; interface SkillSetupModalProps { skillId: string; skillName: string; + skillDescription: string; + /** Whether this skill has interactive setup hooks. */ + hasSetup?: boolean; onClose: () => void; } export default function SkillSetupModal({ skillId, skillName, + skillDescription, + hasSetup = true, onClose, }: SkillSetupModalProps) { const modalRef = useRef(null); + const setupComplete = useAppSelector( + (state) => state.skills.skills[skillId]?.setupComplete, + ); + // Skills without setup hooks always go straight to manage mode. + const [mode, setMode] = useState<"manage" | "setup">( + !hasSetup || setupComplete ? "manage" : "setup", + ); // Handle escape key useEffect(() => { @@ -51,6 +66,9 @@ export default function SkillSetupModal({ } }; + const headerTitle = + mode === "manage" ? `Manage ${skillName}` : `Connect ${skillName}`; + const modalContent = (
e.stopPropagation()} > {/* Header */} -
-

- Connect {skillName} -

- + + + + +
- {/* Wizard content */} + {/* Content */}
- + {mode === "manage" ? ( + setMode("setup") : undefined} + /> + ) : ( + setMode("manage") : onClose} + /> + )}
diff --git a/src/index.css b/src/index.css index bff9fc0f0..800ea18f2 100644 --- a/src/index.css +++ b/src/index.css @@ -289,6 +289,35 @@ } } + /* Modal animations */ + .animate-fade-in { + animation: fadeIn 0.2s ease-out forwards; + } + + .animate-slide-up { + animation: slideUp 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94) forwards; + } + + @keyframes fadeIn { + from { + opacity: 0; + } + to { + opacity: 1; + } + } + + @keyframes slideUp { + from { + opacity: 0; + transform: translateY(20px); + } + to { + opacity: 1; + transform: translateY(0); + } + } + /* Safe area padding for mobile devices */ .safe-area-padding { padding-top: env(safe-area-inset-top); @@ -296,6 +325,52 @@ padding-left: env(safe-area-inset-left); padding-right: env(safe-area-inset-right); } + + /* Skills table container */ + .skills-table-container { + position: relative; + max-height: calc(3 * 2.5rem + 2.5rem + 1px); /* Header + 3 rows + border */ + overflow: hidden; + display: flex; + flex-direction: column; + } + + .skills-table-scroll { + overflow-y: auto; + overflow-x: hidden; + max-height: calc(3 * 2.5rem + 2.5rem + 1px); + } + + .skills-table-header { + position: sticky; + top: 0; + z-index: 10; + background: rgba(255, 255, 255, 0.1); + backdrop-filter: blur(1px); + } + + /* Gradient fade overlay at bottom */ + .skills-table-container::after { + content: ""; + position: absolute; + bottom: 0; + left: 0; + right: 0; + height: 50px; + background: linear-gradient(to bottom, transparent, rgba(255, 255, 255, 0.15)); + pointer-events: none; + z-index: 10; + } + + /* Hover overlay */ + .skills-table-overlay { + z-index: 20; + } + + .skills-table-container:hover .skills-table-overlay { + opacity: 1; + pointer-events: auto; + } } /* Dark mode support (optional) */ diff --git a/src/lib/skills/manager.ts b/src/lib/skills/manager.ts index d797a1ef8..4f6de44ff 100644 --- a/src/lib/skills/manager.ts +++ b/src/lib/skills/manager.ts @@ -12,6 +12,7 @@ import type { SetupStep, SetupResult, SkillToolDefinition, + SkillOptionDefinition, } from "./types"; import { store } from "../../store"; import { @@ -20,6 +21,7 @@ import { setSkillError, setSkillSetupComplete, setSkillTools, + setSkillState, } from "../../store/skillsSlice"; import { TELEGRAM_API_ID, TELEGRAM_API_HASH } from "../../utils/config"; @@ -198,6 +200,30 @@ class SkillManager { return runtime.listTools(); } + /** + * List runtime-configurable options for a running skill. + */ + async listOptions(skillId: string): Promise { + const runtime = this.runtimes.get(skillId); + if (!runtime) { + throw new Error(`Skill ${skillId} is not running`); + } + return runtime.listOptions(); + } + + /** + * Set a single option on a running skill. + */ + async setOption(skillId: string, name: string, value: unknown): Promise { + const runtime = this.runtimes.get(skillId); + if (!runtime) { + throw new Error(`Skill ${skillId} is not running`); + } + await runtime.setOption(name, value); + // Refresh tools list since tool_filter options can change available tools + await this.activateSkill(skillId); + } + /** * Forward session start to all ready skills. */ @@ -228,6 +254,15 @@ class SkillManager { } } + /** + * Disconnect a skill — stop it and reset setup state. + */ + async disconnectSkill(skillId: string): Promise { + await this.stopSkill(skillId); + store.dispatch(setSkillSetupComplete({ skillId, complete: false })); + store.dispatch(setSkillState({ skillId, state: {} })); + } + /** * Stop a specific skill. */ diff --git a/src/lib/skills/runtime.ts b/src/lib/skills/runtime.ts index ad48893ee..b97d5212f 100644 --- a/src/lib/skills/runtime.ts +++ b/src/lib/skills/runtime.ts @@ -10,6 +10,7 @@ import type { SetupStep, SetupResult, SkillToolDefinition, + SkillOptionDefinition, } from "./types"; import { getSkillModulePath } from "./paths"; @@ -146,6 +147,23 @@ export class SkillRuntime { return result.tools; } + /** + * List runtime-configurable options with current values. + */ + async listOptions(): Promise { + const result = await this.transport.request<{ + options: SkillOptionDefinition[]; + }>("options/list"); + return result.options; + } + + /** + * Set a single option value. + */ + async setOption(name: string, value: unknown): Promise { + await this.transport.request("options/set", { name, value }); + } + /** * Call a tool by name with arguments. */ diff --git a/src/lib/skills/types.ts b/src/lib/skills/types.ts index 6ce6e623c..9a1abb021 100644 --- a/src/lib/skills/types.ts +++ b/src/lib/skills/types.ts @@ -105,6 +105,23 @@ export interface SkillToolResult { isError: boolean; } +// --------------------------------------------------------------------------- +// Skill Options +// --------------------------------------------------------------------------- + +export interface SkillOptionDefinition { + name: string; + type: "boolean" | "text" | "number" | "select"; + label: string; + description?: string | null; + default?: string | number | boolean | null; + options?: SetupFieldOption[] | null; + group?: string | null; + toolFilter?: string[] | null; + /** Current value (returned by options/list) */ + value?: string | number | boolean | null; +} + // --------------------------------------------------------------------------- // Skill Status // --------------------------------------------------------------------------- diff --git a/src/pages/Home.tsx b/src/pages/Home.tsx index 2d5378d2b..694973503 100644 --- a/src/pages/Home.tsx +++ b/src/pages/Home.tsx @@ -27,6 +27,13 @@ const Home = () => { navigate("/settings"); }; + const handleUpgrade = () => { + navigate("/settings/billing"); + }; + + const currentPlan = user?.subscription?.plan || "FREE"; + const showUpgradeCTA = currentPlan === "FREE"; + return (
{/* Content overlay */} @@ -34,6 +41,51 @@ const Home = () => { {/* Main content */}
+ {/* Upgrade CTA */} + {showUpgradeCTA && ( + + )} + {/* Weather card */}
{/* Greeting */} @@ -53,8 +105,7 @@ const Home = () => {
- {/* Skills Grid */} - + {/* Action buttons */}
@@ -90,6 +141,9 @@ const Home = () => {
+ + {/* Skills Grid */} +
diff --git a/src/providers/SkillProvider.tsx b/src/providers/SkillProvider.tsx index 5514536d7..0f7b950f9 100644 --- a/src/providers/SkillProvider.tsx +++ b/src/providers/SkillProvider.tsx @@ -1,41 +1,72 @@ /** * Skill Provider — discovers and manages skill lifecycles. * - * On mount (when authenticated): discovers skills from the submodule, - * registers them in Redux, and auto-starts skills with completed setup. - * On unmount: stops all running skills. + * On mount (when authenticated): discovers skills from the local skills + * directory (dev: submodule, prod: ~/.alphahuman/skills/), registers them + * in Redux, and auto-starts skills with completed setup. + * + * In production: + * - If no local skills exist, downloads from GitHub before discovery. + * - On every startup, checks for updates (respects a 24-hour cooldown on + * the Rust side). If a newer commit is available, re-downloads and + * re-discovers. */ import { useEffect, useRef, type ReactNode } from "react"; import { useAppSelector } from "../store/hooks"; import { skillManager } from "../lib/skills/manager"; import type { SkillManifest } from "../lib/skills/types"; +import { + IS_DEV, + SKILLS_GITHUB_REPO, + SKILLS_GITHUB_TOKEN, +} from "../utils/config"; -// Hard-coded manifest for the telegram skill (discovered from submodule). -// In production, this would be dynamically read from manifest.json files. -const KNOWN_SKILLS: SkillManifest[] = [ - { - id: "telegram", - name: "Telegram", - version: "2.0.0", - description: - "Telegram integration via Telethon MTProto — 75+ tools for chats, messages, contacts, admin, media, and settings.", - runtime: "python", - entry: "__main__.py", - tick_interval: 1_200_000, - env: ["TELEGRAM_API_ID", "TELEGRAM_API_HASH"], - dependencies: [ - "telethon>=1.36.0", - "mcp>=1.0.0", - "pydantic>=2.0", - "aiosqlite>=0.20.0", - ], - setup: { - required: true, - label: "Connect Telegram", - }, - }, -]; +// --------------------------------------------------------------------------- +// Helpers (all lazy-import @tauri-apps/api/core to avoid loading before IPC) +// --------------------------------------------------------------------------- + +async function discoverSkills(): Promise { + const { invoke } = await import("@tauri-apps/api/core"); + const raw = await invoke[]>("skill_list_manifests"); + return raw as unknown as SkillManifest[]; +} + +async function syncSkillsFromGithub(): Promise { + const { invoke } = await import("@tauri-apps/api/core"); + await invoke("skill_sync_repo", { + repo: SKILLS_GITHUB_REPO, + githubToken: SKILLS_GITHUB_TOKEN, + }); +} + +async function catalogExists(): Promise { + try { + const { invoke } = await import("@tauri-apps/api/core"); + return await invoke("skill_catalog_exists"); + } catch { + return false; + } +} + +interface UpdateCheckResult { + needs_update: boolean; + skipped?: boolean; + local_sha?: string | null; + remote_sha?: string; +} + +async function checkForUpdates(): Promise { + const { invoke } = await import("@tauri-apps/api/core"); + return invoke("skill_check_for_updates", { + repo: SKILLS_GITHUB_REPO, + githubToken: SKILLS_GITHUB_TOKEN, + }); +} + +// --------------------------------------------------------------------------- +// Provider +// --------------------------------------------------------------------------- export default function SkillProvider({ children }: { children: ReactNode }) { const { token } = useAppSelector((state) => state.auth); @@ -47,23 +78,59 @@ export default function SkillProvider({ children }: { children: ReactNode }) { if (initRef.current) return; initRef.current = true; - // Register known skills - for (const manifest of KNOWN_SKILLS) { - skillManager.registerSkill(manifest); - } - - // Auto-start skills that have completed setup - for (const manifest of KNOWN_SKILLS) { - const existing = skillsState[manifest.id]; - if (existing?.setupComplete) { - skillManager.startSkill(manifest).catch((err) => { - console.error(`[SkillProvider] Failed to start ${manifest.id}:`, err); - }); + const registerAndStart = async (manifests: SkillManifest[]) => { + for (const manifest of manifests) { + skillManager.registerSkill(manifest); } - } + for (const manifest of manifests) { + const existing = skillsState[manifest.id]; + if (existing?.setupComplete) { + skillManager.startSkill(manifest).catch((err) => { + console.error( + `[SkillProvider] Failed to start ${manifest.id}:`, + err, + ); + }); + } + } + }; + + const init = async () => { + try { + if (!IS_DEV) { + // First-time install: no skills at all yet + const exists = await catalogExists(); + if (!exists) { + console.log( + "[SkillProvider] No local skills found, syncing from GitHub...", + ); + try { + await syncSkillsFromGithub(); + } catch (syncErr) { + console.error( + "[SkillProvider] Failed to sync skills from GitHub:", + syncErr, + ); + } + } + } + + // Discover and start whatever is on disk right now + const manifests = await discoverSkills(); + await registerAndStart(manifests); + + // Background: check for updates (non-blocking) + if (!IS_DEV) { + checkForSkillUpdates(registerAndStart); + } + } catch (err) { + console.error("[SkillProvider] Failed to discover skills:", err); + } + }; + + init(); return () => { - // Cleanup on unmount skillManager.stopAll().catch(console.error); initRef.current = false; }; @@ -71,3 +138,42 @@ export default function SkillProvider({ children }: { children: ReactNode }) { return <>{children}; } + +/** + * Runs in the background after initial discovery. Asks the Rust side + * whether a newer commit exists on GitHub (respects 24h cooldown). + * If yes, re-syncs and re-discovers skills. + */ +async function checkForSkillUpdates( + onNewSkills: (manifests: SkillManifest[]) => Promise, +) { + try { + const result = await checkForUpdates(); + + if (result.skipped) { + return; // Cooldown still active, nothing to do + } + + if (!result.needs_update) { + console.log("[SkillProvider] Skills are up to date"); + return; + } + + console.log( + `[SkillProvider] Skills update available: ${result.local_sha?.slice(0, 8) ?? "none"} → ${result.remote_sha?.slice(0, 8)}`, + ); + + // Stop all running skills before re-syncing + await skillManager.stopAll(); + + await syncSkillsFromGithub(); + + // Re-discover and re-start + const manifests = await discoverSkills(); + await onNewSkills(manifests); + + console.log("[SkillProvider] Skills updated and reloaded"); + } catch (err) { + console.error("[SkillProvider] Background update check failed:", err); + } +} diff --git a/src/utils/config.ts b/src/utils/config.ts index 9fbe0b133..8d8bd994c 100644 --- a/src/utils/config.ts +++ b/src/utils/config.ts @@ -16,3 +16,9 @@ export const TELEGRAM_API_HASH = export const IS_DEV = Boolean(import.meta.env.DEV) || import.meta.env.MODE === "development"; + +export const SKILLS_GITHUB_REPO = + import.meta.env.VITE_SKILLS_GITHUB_REPO || "alphahumanxyz/skills"; + +export const SKILLS_GITHUB_TOKEN = + import.meta.env.VITE_SKILLS_GITHUB_TOKEN || undefined;