From cd9ebcd9277d815757d1f9247d5eae95c64051b3 Mon Sep 17 00:00:00 2001 From: Steven Enamakel <31011319+senamakel@users.noreply.github.com> Date: Sun, 1 Feb 2026 09:26:32 +0530 Subject: [PATCH] Feat/skills advanced (#12) * Update skills submodule and enhance skill management interface - Updated the skills submodule to the latest commit, ensuring alignment with project dependencies. - Refactored the SkillSetupModal to conditionally render either the SkillSetupWizard or SkillManagementPanel based on the skill's setup status, improving user experience. - Introduced the SkillManagementPanel component to manage connected skills, allowing users to view connection status and configurable options. - Enhanced the package-and-publish workflow by updating the release name for better clarity. These changes improve the overall functionality and maintainability of the skill management system. * Remove skills catalog JSON and refactor skills loading mechanism - Deleted the `skills-catalog.json` file from the public directory, as it is no longer needed. - Updated the `SkillsGrid` component to load the skills catalog directly from the local skills directory via Rust, improving the skills management process. - Enhanced the `SkillProvider` to discover skills from the local directory and sync from GitHub if no local skills are found, streamlining the skill lifecycle management. These changes improve the overall efficiency and maintainability of the skill management system. * Update skills submodule and enhance skill management features - Updated the skills submodule to the latest commit, ensuring alignment with project dependencies. - Modified the `ConnectionIndicator` component's description for clarity. - Refactored the `SkillsGrid` component to improve type definitions and streamline skill processing, including the addition of a `hasSetup` property for better skill management. - Enhanced the `SkillManagementPanel` to conditionally render the "Re-run Setup" button based on the presence of setup hooks. - Updated the `SkillSetupModal` to handle skills without setup hooks more effectively. - Implemented a background update check in the `SkillProvider` to ensure skills are kept up to date with the latest changes from GitHub. These changes improve the overall functionality and maintainability of the skill management system. * Enhance skill management interface and update dependencies - Added new CSS styles for the skills table, improving layout and user experience. - Updated the SkillsGrid component to utilize new props and improve type definitions. - Enhanced the SkillSetupModal to include skill descriptions for better context. - Updated the Home component to include a call-to-action for upgrading to premium plans. - Added new entries to .gitignore for mypy and ruff caches to streamline development. These changes improve the overall functionality and maintainability of the skill management system. --- .github/workflows/package-and-publish.yml | 4 +- .gitignore | 2 + public/skills-catalog.json | 139 ------ skills | 2 +- src-tauri/Cargo.lock | 48 +- src-tauri/Cargo.toml | 4 + src-tauri/src/commands/skills.rs | 361 ++++++++++++++- src-tauri/src/lib.rs | 5 + src/components/ConnectionIndicator.tsx | 2 +- src/components/SkillsGrid.tsx | 425 +++++++++++++----- .../skills/SkillManagementPanel.tsx | 194 ++++++++ src/components/skills/SkillSetupModal.tsx | 101 +++-- src/index.css | 75 ++++ src/lib/skills/manager.ts | 35 ++ src/lib/skills/runtime.ts | 18 + src/lib/skills/types.ts | 17 + src/pages/Home.tsx | 58 ++- src/providers/SkillProvider.tsx | 190 ++++++-- src/utils/config.ts | 6 + 19 files changed, 1346 insertions(+), 340 deletions(-) delete mode 100644 public/skills-catalog.json create mode 100644 src/components/skills/SkillManagementPanel.tsx 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;