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.
This commit is contained in:
Steven Enamakel
2026-02-01 09:26:32 +05:30
committed by GitHub
parent e2833f27e0
commit cd9ebcd927
19 changed files with 1346 additions and 340 deletions
+2 -2
View File
@@ -10,7 +10,7 @@ name: Package and publish
on: on:
push: push:
branches: ["master","main"] branches: ["master", "main"]
workflow_dispatch: workflow_dispatch:
inputs: inputs:
forceRelease: forceRelease:
@@ -45,7 +45,7 @@ jobs:
run: | run: |
PACKAGE_VERSION=$(grep -m1 '^version' tauri/Cargo.toml | sed -E 's/.*"([^"]+)".*/\1/') PACKAGE_VERSION=$(grep -m1 '^version' tauri/Cargo.toml | sed -E 's/.*"([^"]+)".*/\1/')
TAG_NAME="air_v${PACKAGE_VERSION}" 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 "package-version=$PACKAGE_VERSION" >> $GITHUB_OUTPUT
echo "tag-name=$TAG_NAME" >> $GITHUB_OUTPUT echo "tag-name=$TAG_NAME" >> $GITHUB_OUTPUT
echo "release-name=$RELEASE_NAME" >> $GITHUB_OUTPUT echo "release-name=$RELEASE_NAME" >> $GITHUB_OUTPUT
+2
View File
@@ -32,3 +32,5 @@ dist-ssr
*.sw? *.sw?
references/ references/
src-tauri/runtime-skill-* src-tauri/runtime-skill-*
.mypy_cache
.ruff_cache
-139
View File
@@ -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"
}
]
}
+1 -1
Submodule skills updated: 87bb3adab1...760019ff9a
+46 -2
View File
@@ -1159,6 +1159,17 @@ dependencies = [
"rustc_version", "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]] [[package]]
name = "find-msvc-tools" name = "find-msvc-tools"
version = "0.1.8" version = "0.1.8"
@@ -2247,6 +2258,7 @@ checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616"
dependencies = [ dependencies = [
"bitflags 2.10.0", "bitflags 2.10.0",
"libc", "libc",
"redox_syscall 0.7.0",
] ]
[[package]] [[package]]
@@ -2869,7 +2881,7 @@ checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"libc", "libc",
"redox_syscall", "redox_syscall 0.5.18",
"smallvec", "smallvec",
"windows-link 0.2.1", "windows-link 0.2.1",
] ]
@@ -3383,6 +3395,15 @@ dependencies = [
"bitflags 2.10.0", "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]] [[package]]
name = "redox_users" name = "redox_users"
version = "0.4.6" version = "0.4.6"
@@ -3998,7 +4019,7 @@ dependencies = [
"objc2-foundation", "objc2-foundation",
"objc2-quartz-core", "objc2-quartz-core",
"raw-window-handle", "raw-window-handle",
"redox_syscall", "redox_syscall 0.5.18",
"tracing", "tracing",
"wasm-bindgen", "wasm-bindgen",
"web-sys", "web-sys",
@@ -4212,6 +4233,17 @@ dependencies = [
"syn 2.0.114", "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]] [[package]]
name = "target-lexicon" name = "target-lexicon"
version = "0.12.16" version = "0.12.16"
@@ -4278,6 +4310,7 @@ dependencies = [
"base64 0.22.1", "base64 0.22.1",
"dirs 5.0.1", "dirs 5.0.1",
"env_logger", "env_logger",
"flate2",
"keyring", "keyring",
"log", "log",
"once_cell", "once_cell",
@@ -4287,6 +4320,7 @@ dependencies = [
"serde", "serde",
"serde_json", "serde_json",
"sha2", "sha2",
"tar",
"tauri", "tauri",
"tauri-build", "tauri-build",
"tauri-plugin-autostart", "tauri-plugin-autostart",
@@ -5976,6 +6010,16 @@ dependencies = [
"pkg-config", "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]] [[package]]
name = "yoke" name = "yoke"
version = "0.8.1" version = "0.8.1"
+4
View File
@@ -47,6 +47,10 @@ parking_lot = "0.12"
log = "0.4" log = "0.4"
env_logger = "0.11" env_logger = "0.11"
# Archive extraction (for downloading skills repo without git)
tar = "0.4"
flate2 = "1"
# AI module dependencies # AI module dependencies
base64 = "0.22" base64 = "0.22"
aes-gcm = "0.10" aes-gcm = "0.10"
+360 -1
View File
@@ -1,9 +1,111 @@
//! Skill data I/O commands. //! Skill data I/O commands.
//! //!
//! Provides filesystem operations for skill data directories, //! 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::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<String>,
/// Unix epoch seconds of the last remote check.
last_checked_at: Option<u64>,
/// The repo that was synced (owner/repo).
repo: Option<String>,
/// The branch that was synced.
branch: Option<String>,
}
fn sync_meta_path() -> Result<PathBuf, String> {
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<String, String> {
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`). /// Absolute path to the skills working directory (cwd for `python -m skills.xxx`).
/// In dev: project root's `skills/` (submodule). In prod: `~/.alphahuman/skills/`. /// In dev: project root's `skills/` (submodule). In prod: `~/.alphahuman/skills/`.
@@ -200,3 +302,260 @@ pub async fn skill_list_manifests() -> Result<Vec<serde_json::Value>, String> {
Ok(manifests) 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<serde_json::Value, String> {
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<String>,
branch: Option<String>,
github_token: Option<String>,
) -> 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<bool, String> {
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<String>,
branch: Option<String>,
github_token: Option<String>,
force: Option<bool>,
) -> Result<serde_json::Value, String> {
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<Option<String>, 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)
}
+5
View File
@@ -233,6 +233,11 @@ pub fn run() {
skill_list_manifests, skill_list_manifests,
skill_cwd, skill_cwd,
skill_venv_site_packages, skill_venv_site_packages,
skill_read_catalog,
skill_sync_repo,
skill_catalog_exists,
skill_check_for_updates,
skill_read_icon,
]) ])
.build(tauri::generate_context!()) .build(tauri::generate_context!())
.expect("error while building tauri application") .expect("error while building tauri application")
+1 -1
View File
@@ -9,7 +9,7 @@ interface ConnectionIndicatorProps {
const ConnectionIndicator = ({ const ConnectionIndicator = ({
status: overrideStatus, 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 = "", className = "",
}: ConnectionIndicatorProps) => { }: ConnectionIndicatorProps) => {
// Use socket store status, but allow override via props // Use socket store status, but allow override via props
+308 -117
View File
@@ -1,6 +1,7 @@
import { useState, useEffect } from "react"; import { useState, useEffect, useMemo } from "react";
import { useSkillConnectionStatus } from "../lib/skills/hooks"; 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 SkillSetupModal from "./skills/SkillSetupModal";
import TelegramIcon from "../assets/icons/telegram.svg"; import TelegramIcon from "../assets/icons/telegram.svg";
@@ -9,16 +10,16 @@ import NotionIcon from "../assets/icons/notion.svg";
// Map skill IDs to icons // Map skill IDs to icons
const SKILL_ICONS: Record<string, React.ReactElement> = { const SKILL_ICONS: Record<string, React.ReactElement> = {
telegram: <img src={TelegramIcon} alt="Telegram" className="w-8 h-8" />, telegram: <img src={TelegramIcon} alt="Telegram" className="w-5 h-5" />,
email: <GoogleIcon className="w-8 h-8" />, email: <GoogleIcon className="w-5 h-5" />,
notion: <img src={NotionIcon} alt="Notion" className="w-8 h-8" />, notion: <img src={NotionIcon} alt="Notion" className="w-5 h-5" />,
github: ( github: (
<svg className="w-8 h-8" fill="currentColor" viewBox="0 0 24 24"> <svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" /> <path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
</svg> </svg>
), ),
otter: ( otter: (
<svg className="w-8 h-8" fill="currentColor" viewBox="0 0 24 24"> <svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z" /> <path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z" />
</svg> </svg>
), ),
@@ -26,92 +27,83 @@ const SKILL_ICONS: Record<string, React.ReactElement> = {
// Default icon for unknown skills // Default icon for unknown skills
const DefaultIcon = () => ( const DefaultIcon = () => (
<div className="w-8 h-8 rounded-full bg-primary-500/20 flex items-center justify-center"> <div className="w-5 h-5 rounded-full bg-primary-500/20 flex items-center justify-center">
<svg className="w-4 h-4 text-primary-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="w-3 h-3 text-primary-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg> </svg>
</div> </div>
); );
// Status badge colors with better contrast // Status display text and colors
const STATUS_COLORS: Record<SkillConnectionStatus, { bg: string; text: string; border: string }> = { const STATUS_DISPLAY: Record<SkillConnectionStatus, { text: string; color: string }> = {
connected: { connected: {
bg: "bg-sage-500/30", text: "Connected",
text: "text-sage-100", color: "text-sage-400",
border: "border-sage-500/50",
}, },
connecting: { connecting: {
bg: "bg-amber-500/30", text: "Connecting",
text: "text-amber-100", color: "text-amber-400",
border: "border-amber-500/50",
}, },
not_authenticated: { not_authenticated: {
bg: "bg-amber-500/30", text: "Not Auth",
text: "text-amber-100", color: "text-amber-400",
border: "border-amber-500/50",
}, },
disconnected: { disconnected: {
bg: "bg-stone-600/40", text: "Disconnected",
text: "text-stone-200", color: "text-stone-400",
border: "border-stone-500/60",
}, },
error: { error: {
bg: "bg-coral-500/30", text: "Error",
text: "text-coral-100", color: "text-coral-400",
border: "border-coral-500/50",
}, },
offline: { offline: {
bg: "bg-stone-600/40", text: "Offline",
text: "text-stone-200", color: "text-stone-500",
border: "border-stone-500/60",
}, },
setup_required: { setup_required: {
bg: "bg-primary-500/30", text: "Setup",
text: "text-primary-100", color: "text-primary-400",
border: "border-primary-500/50",
}, },
}; };
interface SkillCardProps { interface SkillRowProps {
skillId: string; skillId: string;
name: string; name: string;
description: string;
icon?: React.ReactElement; 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 connectionStatus = useSkillConnectionStatus(skillId);
const statusConfig = STATUS_COLORS[connectionStatus] || STATUS_COLORS.offline; const statusDisplay = STATUS_DISPLAY[connectionStatus] || STATUS_DISPLAY.offline;
return ( return (
<button <tr
onClick={onConnect} onClick={onConnect}
className="glass rounded-lg p-2.5 hover:bg-stone-800/30 transition-all duration-200 text-left focus:outline-none focus:ring-2 focus:ring-primary-500/50 group" className="skill-row group hover:bg-stone-800/20 transition-all duration-300 cursor-pointer border-b border-stone-800/30 last:border-0"
> >
<div className="flex flex-col items-center space-y-1.5"> <td className="py-2.5 px-3">
{/* Icon */} <div className="flex items-center gap-3">
<div className="w-8 h-8 flex items-center justify-center text-white opacity-80 group-hover:opacity-100 transition-opacity"> <div className="w-5 h-5 flex items-center justify-center text-white opacity-70 group-hover:opacity-100 transition-opacity flex-shrink-0">
{icon || <DefaultIcon />} {icon || <DefaultIcon />}
</div>
<span className="text-sm text-white font-medium">{name}</span>
</div> </div>
</td>
{/* Name */} <td className="py-2.5 px-3 text-right">
<div className="text-center w-full"> <span className={`text-xs ${statusDisplay.color}`}>{statusDisplay.text}</span>
<div className="font-medium text-xs text-white">{name}</div> </td>
</div> <td className="py-2.5 px-3 w-8">
<svg
{/* Status badge */} className="w-4 h-4 text-stone-500 group-hover:text-stone-300 transition-colors"
<div className={`px-1.5 py-0.5 text-[10px] font-medium rounded-full border ${statusConfig.bg} ${statusConfig.text} ${statusConfig.border}`}> fill="none"
{connectionStatus === "connected" && "Connected"} stroke="currentColor"
{connectionStatus === "connecting" && "Connecting..."} viewBox="0 0 24 24"
{connectionStatus === "not_authenticated" && "Not Auth"} >
{connectionStatus === "disconnected" && "Disconnected"} <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
{connectionStatus === "error" && "Error"} </svg>
{connectionStatus === "offline" && "Offline"} </td>
{connectionStatus === "setup_required" && "Setup"} </tr>
</div>
</div>
</button>
); );
} }
@@ -132,68 +124,132 @@ interface SkillsCatalog {
skills: SkillCatalogEntry[]; 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<string, unknown> | 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<SkillConnectionStatus, number> = {
connected: 1,
connecting: 2,
not_authenticated: 3,
disconnected: 4,
setup_required: 5,
offline: 6,
error: 7,
};
export default function SkillsGrid() { export default function SkillsGrid() {
const [skillsList, setSkillsList] = useState<Array<{ const [skillsList, setSkillsList] = useState<SkillListEntry[]>([]);
id: string;
name: string;
description: string;
icon?: React.ReactElement;
}>>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [setupModalOpen, setSetupModalOpen] = useState(false); const [setupModalOpen, setSetupModalOpen] = useState(false);
const [managementModalOpen, setManagementModalOpen] = useState(false);
const [activeSkillId, setActiveSkillId] = useState<string | null>(null); const [activeSkillId, setActiveSkillId] = useState<string | null>(null);
const [activeSkillName, setActiveSkillName] = useState<string>(""); const [activeSkillName, setActiveSkillName] = useState<string>("");
const [activeSkillDescription, setActiveSkillDescription] = useState<string>("");
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(() => { useEffect(() => {
// Load skills catalog from the skills repo // Load skills catalog from the local skills directory via Rust.
// The file should be copied to public/skills-catalog.json during build // In dev: reads from the submodule. In prod: reads from ~/.alphahuman/skills/.
const loadSkillsCatalog = async () => { const loadSkillsCatalog = async () => {
try { try {
// Try to load from public folder (served at root in Vite) const { invoke } = await import("@tauri-apps/api/core");
const response = await fetch("/skills-catalog.json"); const catalog: SkillsCatalog = await invoke("skill_read_catalog");
if (!response.ok) {
// Fallback: try skills submodule path // Load manifests to get proper display names
const fallbackResponse = await fetch("/skills/skills-catalog.json"); const manifests = await invoke<Array<Record<string, unknown>>>("skill_list_manifests");
if (!fallbackResponse.ok) { const manifestMap = new Map(
console.warn("Could not load skills-catalog.json. Make sure it's copied to public/ folder."); manifests
setLoading(false); .filter((m): m is { id: string; name: string } => typeof m.id === "string" && typeof m.name === "string")
return; .map((m) => [m.id, m.name])
} );
const catalog: SkillsCatalog = await fallbackResponse.json();
processCatalog(catalog); processCatalog(catalog, manifestMap);
return;
}
const catalog: SkillsCatalog = await response.json();
processCatalog(catalog);
} catch (error) { } catch (error) {
console.error("Error loading skills catalog:", error); console.warn("Could not load skills catalog from filesystem:", error);
setLoading(false); setLoading(false);
} }
}; };
const processCatalog = (catalog: SkillsCatalog) => { const processCatalog = (catalog: SkillsCatalog, manifestMap: Map<string, string>) => {
// Filter skills that have setup hooks and validate skill names // Validate skill names (underscores are reserved for tool namespacing)
const skillsWithSetup = catalog.skills.filter((skill) => { const validSkills = catalog.skills.filter((skill) => {
// Skip skills with underscores in name (used for tool namespacing)
if (skill.name.includes("_")) { if (skill.name.includes("_")) {
console.warn( console.warn(
`Skill "${skill.name}" contains underscore and will be skipped. Skill names cannot contain underscores.` `Skill "${skill.name}" contains underscore and will be skipped. Skill names cannot contain underscores.`
); );
return false; return false;
} }
return ( return true;
skill.hooks.includes("on_setup_start") &&
skill.hooks.includes("on_setup_submit") &&
skill.hooks.includes("on_setup_cancel")
);
}); });
const processed = skillsWithSetup.map((skill) => ({ const processed: SkillListEntry[] = validSkills.map((skill) => {
id: skill.name, const skillId = skill.name;
name: skill.name.charAt(0).toUpperCase() + skill.name.slice(1), // Use manifest name if available, otherwise capitalize the ID
description: skill.description, const displayName = manifestMap.get(skillId) || skill.name.charAt(0).toUpperCase() + skill.name.slice(1);
icon: SKILL_ICONS[skill.name],
})); 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); setSkillsList(processed);
setLoading(false); setLoading(false);
@@ -202,32 +258,91 @@ export default function SkillsGrid() {
loadSkillsCatalog(); 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 or no skills, don't render
if (loading || skillsList.length === 0) { if (loading || skillsList.length === 0) {
return null; return null;
} }
const handleConnect = (skillId: string, skillName: string) => { const handleConnect = (skill: SkillListEntry) => {
setActiveSkillId(skillId); setActiveSkillId(skill.id);
setActiveSkillName(skillName); setActiveSkillName(skill.name);
setActiveSkillDescription(skill.description);
setActiveSkillHasSetup(skill.hasSetup);
setSetupModalOpen(true); setSetupModalOpen(true);
}; };
return ( return (
<> <>
<div className="animate-fade-up mt-4 mb-8"> <div className="animate-fade-up mt-4 mb-8 relative">
<h3 className="text-sm font-semibold text-white mb-4 px-1 text-center opacity-80">Available Skills</h3> <h3 className="text-sm font-semibold text-white mb-3 px-1 opacity-80 text-center">Available Skills</h3>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2"> <div
{skillsList.map((skill) => ( className="glass rounded-xl overflow-hidden skills-table-container relative cursor-pointer"
<SkillCard onClick={() => setManagementModalOpen(true)}
key={skill.id} >
skillId={skill.id} <div className="skills-table-scroll">
name={skill.name} <table className="w-full">
description={skill.description} <thead className="skills-table-header">
icon={skill.icon} <tr className="border-b border-stone-800/30">
onConnect={() => handleConnect(skill.id, skill.name)} <th className="py-2 px-3 text-left">
/> <span className="text-xs font-medium text-stone-400 uppercase tracking-wider">Skill</span>
))} </th>
<th className="py-2 px-3 text-right">
<span className="text-xs font-medium text-stone-400 uppercase tracking-wider">Status</span>
</th>
<th className="py-2 px-3 w-8"></th>
</tr>
</thead>
<tbody className="skills-table-body">
{sortedSkillsList.map((skill) => (
<SkillRow
key={skill.id}
skillId={skill.id}
name={skill.name}
icon={skill.icon}
onConnect={(e) => {
e.stopPropagation();
handleConnect(skill);
}}
/>
))}
</tbody>
</table>
</div>
{/* Hover overlay */}
<div className="skills-table-overlay absolute inset-0 bg-black/80 flex items-center justify-center rounded-xl opacity-0 transition-opacity duration-200 pointer-events-none">
<span className="text-sm font-medium text-white">Click to manage skills</span>
</div>
</div> </div>
</div> </div>
@@ -236,12 +351,88 @@ export default function SkillsGrid() {
<SkillSetupModal <SkillSetupModal
skillId={activeSkillId} skillId={activeSkillId}
skillName={activeSkillName} skillName={activeSkillName}
skillDescription={activeSkillDescription}
hasSetup={activeSkillHasSetup}
onClose={() => { onClose={() => {
setSetupModalOpen(false); setSetupModalOpen(false);
setActiveSkillId(null); setActiveSkillId(null);
}} }}
/> />
)} )}
{/* Skills Management Modal */}
{managementModalOpen && (
<div
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 animate-fade-in"
onClick={() => setManagementModalOpen(false)}
>
<div
className="bg-stone-900 rounded-2xl max-w-2xl w-full max-h-[80vh] shadow-large border border-stone-700/50 flex flex-col overflow-hidden animate-slide-up"
onClick={(e) => e.stopPropagation()}
>
{/* Sticky Header */}
<div className="flex items-center justify-between p-6 pb-4 border-b border-stone-700/50 flex-shrink-0 bg-stone-900">
<h2 className="text-xl font-semibold text-white">Manage Skills</h2>
<button
onClick={() => setManagementModalOpen(false)}
className="text-stone-400 hover:text-white transition-colors"
>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/* Scrollable Content */}
<div className="overflow-y-auto flex-1 p-6 pt-4">
<div className="space-y-2">
{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 (
<div
key={skill.id}
className="flex items-center justify-between p-3 rounded-lg bg-stone-800/30 border border-stone-700/30 hover:bg-stone-800/50 transition-colors"
>
<div className="flex items-center gap-3 flex-1 min-w-0">
<div className="w-8 h-8 flex items-center justify-center text-white opacity-70 flex-shrink-0">
{skill.icon || <DefaultIcon />}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<div className="text-sm font-medium text-white">{skill.name}</div>
<span className={`text-xs ${statusDisplay.color}`}>{statusDisplay.text}</span>
</div>
<div className="text-xs text-stone-400">{skill.description}</div>
</div>
</div>
<button
onClick={(e) => {
e.stopPropagation();
setActiveSkillId(skill.id);
setActiveSkillName(skill.name);
setActiveSkillDescription(skill.description);
setActiveSkillHasSetup(skill.hasSetup);
setSetupModalOpen(true);
}}
className="px-4 py-1.5 text-xs font-medium text-primary-300 bg-primary-500/10 border border-primary-500/30 rounded-lg hover:bg-primary-500/20 transition-colors flex-shrink-0 ml-3"
>
Configure
</button>
</div>
);
})}
</div>
</div>
</div>
</div>
)}
</> </>
); );
} }
@@ -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<SkillOptionDefinition[]>([]);
const [togglingOption, setTogglingOption] = useState<string | null>(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 (
<div className="space-y-4">
{/* Error message */}
{connectionInfo.error && (
<div className="text-xs text-coral-400 bg-coral-500/10 border border-coral-500/20 rounded-lg px-3 py-2 break-words">
{connectionInfo.error}
</div>
)}
{/* Options */}
{options.length > 0 && (
<div className="space-y-1">
<div className="text-xs font-medium text-stone-400 px-0.5 mb-2">
Options
</div>
{options.map((opt) => (
<div
key={opt.name}
className="flex items-center justify-between rounded-lg bg-stone-800/40 border border-stone-700/40 px-3 py-2.5"
>
<div className="min-w-0 mr-3">
<div className="text-xs font-medium text-stone-200">
{opt.label}
</div>
{opt.description && (
<div className="text-[11px] text-stone-500 mt-0.5 line-clamp-1">
{opt.description}
</div>
)}
</div>
{opt.type === "boolean" && (
<button
onClick={() => handleToggleOption(opt.name, opt.value)}
disabled={togglingOption === opt.name}
className={`relative flex-shrink-0 w-9 h-5 rounded-full transition-colors duration-200 ${opt.value ? "bg-primary-500" : "bg-stone-600"
} ${togglingOption === opt.name ? "opacity-50" : ""}`}
>
<span
className={`absolute top-0.5 left-0.5 w-4 h-4 bg-white rounded-full shadow transition-transform duration-200 ${opt.value ? "translate-x-4" : "translate-x-0"
}`}
/>
</button>
)}
</div>
))}
</div>
)}
{/* Action buttons — single row */}
<div className="pt-1">
{!confirmDisconnect ? (
<div className="flex space-x-2">
<button
onClick={handleRestart}
disabled={restarting}
className="flex-1 px-3 py-2 text-xs font-medium text-white bg-stone-700/60 border border-stone-600/50 rounded-xl hover:bg-stone-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{restarting ? "Restarting..." : "Restart"}
</button>
{onReconfigure && (
<button
onClick={onReconfigure}
className="flex-1 px-3 py-2 text-xs font-medium text-primary-300 bg-primary-500/10 border border-primary-500/30 rounded-xl hover:bg-primary-500/20 transition-colors"
>
Re-run Setup
</button>
)}
<button
onClick={() => setConfirmDisconnect(true)}
className="flex-1 px-3 py-2 text-xs font-medium text-coral-400 bg-coral-500/10 border border-coral-500/30 rounded-xl hover:bg-coral-500/20 transition-colors"
>
Disconnect
</button>
</div>
) : (
<div className="flex space-x-2">
<button
onClick={() => setConfirmDisconnect(false)}
className="flex-1 px-3 py-2 text-xs font-medium text-stone-400 bg-stone-800/50 border border-stone-700 rounded-xl hover:bg-stone-800 transition-colors"
>
Cancel
</button>
<button
onClick={handleDisconnect}
disabled={disconnecting}
className="flex-1 px-3 py-2 text-xs font-medium text-white bg-coral-500 rounded-xl hover:bg-coral-600 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{disconnecting ? "Disconnecting..." : "Confirm Disconnect"}
</button>
</div>
)}
</div>
</div>
);
}
+68 -33
View File
@@ -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. * Uses createPortal like the settings modal system.
*/ */
import { useEffect, useRef } from "react"; import { useState, useEffect, useRef } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { useAppSelector } from "../../store/hooks";
import SkillSetupWizard from "./SkillSetupWizard"; import SkillSetupWizard from "./SkillSetupWizard";
import SkillManagementPanel from "./SkillManagementPanel";
interface SkillSetupModalProps { interface SkillSetupModalProps {
skillId: string; skillId: string;
skillName: string; skillName: string;
skillDescription: string;
/** Whether this skill has interactive setup hooks. */
hasSetup?: boolean;
onClose: () => void; onClose: () => void;
} }
export default function SkillSetupModal({ export default function SkillSetupModal({
skillId, skillId,
skillName, skillName,
skillDescription,
hasSetup = true,
onClose, onClose,
}: SkillSetupModalProps) { }: SkillSetupModalProps) {
const modalRef = useRef<HTMLDivElement>(null); const modalRef = useRef<HTMLDivElement>(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 // Handle escape key
useEffect(() => { useEffect(() => {
@@ -51,6 +66,9 @@ export default function SkillSetupModal({
} }
}; };
const headerTitle =
mode === "manage" ? `Manage ${skillName}` : `Connect ${skillName}`;
const modalContent = ( const modalContent = (
<div <div
className="fixed inset-0 z-[9999] bg-black/50 backdrop-blur-sm flex items-center justify-center p-4" className="fixed inset-0 z-[9999] bg-black/50 backdrop-blur-sm flex items-center justify-center p-4"
@@ -61,7 +79,7 @@ export default function SkillSetupModal({
> >
<div <div
ref={modalRef} ref={modalRef}
className="glass rounded-3xl shadow-large w-full max-w-[460px] overflow-hidden animate-fade-up focus:outline-none focus:ring-0" className="bg-stone-900 border border-stone-600 rounded-3xl shadow-large w-full max-w-[460px] overflow-hidden animate-fade-up focus:outline-none focus:ring-0"
style={{ style={{
animationDuration: "200ms", animationDuration: "200ms",
animationTimingFunction: "cubic-bezier(0.25, 0.46, 0.45, 0.94)", animationTimingFunction: "cubic-bezier(0.25, 0.46, 0.45, 0.94)",
@@ -71,40 +89,57 @@ export default function SkillSetupModal({
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
{/* Header */} {/* Header */}
<div className="flex items-center justify-between p-4 border-b border-stone-700/50"> <div className="p-4 border-b border-stone-700/50">
<h2 <div className="flex items-start justify-between">
id="skill-setup-title" <div className="flex-1 min-w-0 pr-2">
className="text-base font-semibold text-white" <h2
> id="skill-setup-title"
Connect {skillName} className="text-base font-semibold text-white"
</h2> >
<button {headerTitle}
onClick={onClose} </h2>
className="p-1 text-stone-400 hover:text-white transition-colors rounded-lg hover:bg-stone-700/50" {skillDescription && (
> <p className="text-xs text-stone-400 mt-1.5 line-clamp-2">
<svg {skillDescription}
className="w-5 h-5" </p>
fill="none" )}
stroke="currentColor" </div>
viewBox="0 0 24 24" <button
onClick={onClose}
className="p-1 text-stone-400 hover:text-white transition-colors rounded-lg hover:bg-stone-700/50 flex-shrink-0"
> >
<path <svg
strokeLinecap="round" className="w-5 h-5"
strokeLinejoin="round" fill="none"
strokeWidth={2} stroke="currentColor"
d="M6 18L18 6M6 6l12 12" viewBox="0 0 24 24"
/> >
</svg> <path
</button> strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
</div> </div>
{/* Wizard content */} {/* Content */}
<div className="p-4"> <div className="p-4">
<SkillSetupWizard {mode === "manage" ? (
skillId={skillId} <SkillManagementPanel
onComplete={onClose} skillId={skillId}
onCancel={onClose} onClose={onClose}
/> onReconfigure={hasSetup ? () => setMode("setup") : undefined}
/>
) : (
<SkillSetupWizard
skillId={skillId}
onComplete={onClose}
onCancel={setupComplete ? () => setMode("manage") : onClose}
/>
)}
</div> </div>
</div> </div>
</div> </div>
+75
View File
@@ -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 for mobile devices */
.safe-area-padding { .safe-area-padding {
padding-top: env(safe-area-inset-top); padding-top: env(safe-area-inset-top);
@@ -296,6 +325,52 @@
padding-left: env(safe-area-inset-left); padding-left: env(safe-area-inset-left);
padding-right: env(safe-area-inset-right); 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) */ /* Dark mode support (optional) */
+35
View File
@@ -12,6 +12,7 @@ import type {
SetupStep, SetupStep,
SetupResult, SetupResult,
SkillToolDefinition, SkillToolDefinition,
SkillOptionDefinition,
} from "./types"; } from "./types";
import { store } from "../../store"; import { store } from "../../store";
import { import {
@@ -20,6 +21,7 @@ import {
setSkillError, setSkillError,
setSkillSetupComplete, setSkillSetupComplete,
setSkillTools, setSkillTools,
setSkillState,
} from "../../store/skillsSlice"; } from "../../store/skillsSlice";
import { TELEGRAM_API_ID, TELEGRAM_API_HASH } from "../../utils/config"; import { TELEGRAM_API_ID, TELEGRAM_API_HASH } from "../../utils/config";
@@ -198,6 +200,30 @@ class SkillManager {
return runtime.listTools(); return runtime.listTools();
} }
/**
* List runtime-configurable options for a running skill.
*/
async listOptions(skillId: string): Promise<SkillOptionDefinition[]> {
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<void> {
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. * 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<void> {
await this.stopSkill(skillId);
store.dispatch(setSkillSetupComplete({ skillId, complete: false }));
store.dispatch(setSkillState({ skillId, state: {} }));
}
/** /**
* Stop a specific skill. * Stop a specific skill.
*/ */
+18
View File
@@ -10,6 +10,7 @@ import type {
SetupStep, SetupStep,
SetupResult, SetupResult,
SkillToolDefinition, SkillToolDefinition,
SkillOptionDefinition,
} from "./types"; } from "./types";
import { getSkillModulePath } from "./paths"; import { getSkillModulePath } from "./paths";
@@ -146,6 +147,23 @@ export class SkillRuntime {
return result.tools; return result.tools;
} }
/**
* List runtime-configurable options with current values.
*/
async listOptions(): Promise<SkillOptionDefinition[]> {
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<void> {
await this.transport.request("options/set", { name, value });
}
/** /**
* Call a tool by name with arguments. * Call a tool by name with arguments.
*/ */
+17
View File
@@ -105,6 +105,23 @@ export interface SkillToolResult {
isError: boolean; 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 // Skill Status
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
+56 -2
View File
@@ -27,6 +27,13 @@ const Home = () => {
navigate("/settings"); navigate("/settings");
}; };
const handleUpgrade = () => {
navigate("/settings/billing");
};
const currentPlan = user?.subscription?.plan || "FREE";
const showUpgradeCTA = currentPlan === "FREE";
return ( return (
<div className="min-h-screen relative overflow-hidden"> <div className="min-h-screen relative overflow-hidden">
{/* Content overlay */} {/* Content overlay */}
@@ -34,6 +41,51 @@ const Home = () => {
{/* Main content */} {/* Main content */}
<div className="flex-1 flex items-center justify-center p-4"> <div className="flex-1 flex items-center justify-center p-4">
<div className="max-w-md w-full"> <div className="max-w-md w-full">
{/* Upgrade CTA */}
{showUpgradeCTA && (
<button
onClick={handleUpgrade}
className="glass rounded-3xl p-4 shadow-large animate-fade-up mb-4 w-full text-left hover:bg-stone-800/30 transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-primary-500/50 group"
>
<div className="flex items-center justify-between">
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<svg
className="w-5 h-5 text-primary-500"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M13 10V3L4 14h7v7l9-11h-7z"
/>
</svg>
<span className="font-semibold text-sm">Upgrade to Premium</span>
</div>
<p className="text-xs opacity-70">
Unlock advanced features and unlimited access
</p>
</div>
<svg
className="w-5 h-5 opacity-60 group-hover:opacity-100 transition-opacity"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 5l7 7-7 7"
/>
</svg>
</div>
</button>
)}
{/* Weather card */} {/* Weather card */}
<div className="glass rounded-3xl p-4 shadow-large animate-fade-up text-center"> <div className="glass rounded-3xl p-4 shadow-large animate-fade-up text-center">
{/* Greeting */} {/* Greeting */}
@@ -53,8 +105,7 @@ const Home = () => {
</button> </button>
</div> </div>
{/* Skills Grid */}
<SkillsGrid />
{/* Action buttons */} {/* Action buttons */}
<div className="glass rounded-3xl p-0 shadow-large animate-fade-up mt-4 overflow-hidden"> <div className="glass rounded-3xl p-0 shadow-large animate-fade-up mt-4 overflow-hidden">
@@ -90,6 +141,9 @@ const Home = () => {
</div> </div>
</button> </button>
</div> </div>
{/* Skills Grid */}
<SkillsGrid />
</div> </div>
</div> </div>
</div> </div>
+148 -42
View File
@@ -1,41 +1,72 @@
/** /**
* Skill Provider — discovers and manages skill lifecycles. * Skill Provider — discovers and manages skill lifecycles.
* *
* On mount (when authenticated): discovers skills from the submodule, * On mount (when authenticated): discovers skills from the local skills
* registers them in Redux, and auto-starts skills with completed setup. * directory (dev: submodule, prod: ~/.alphahuman/skills/), registers them
* On unmount: stops all running skills. * 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 { useEffect, useRef, type ReactNode } from "react";
import { useAppSelector } from "../store/hooks"; import { useAppSelector } from "../store/hooks";
import { skillManager } from "../lib/skills/manager"; import { skillManager } from "../lib/skills/manager";
import type { SkillManifest } from "../lib/skills/types"; 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. // Helpers (all lazy-import @tauri-apps/api/core to avoid loading before IPC)
const KNOWN_SKILLS: SkillManifest[] = [ // ---------------------------------------------------------------------------
{
id: "telegram", async function discoverSkills(): Promise<SkillManifest[]> {
name: "Telegram", const { invoke } = await import("@tauri-apps/api/core");
version: "2.0.0", const raw = await invoke<Record<string, unknown>[]>("skill_list_manifests");
description: return raw as unknown as SkillManifest[];
"Telegram integration via Telethon MTProto — 75+ tools for chats, messages, contacts, admin, media, and settings.", }
runtime: "python",
entry: "__main__.py", async function syncSkillsFromGithub(): Promise<void> {
tick_interval: 1_200_000, const { invoke } = await import("@tauri-apps/api/core");
env: ["TELEGRAM_API_ID", "TELEGRAM_API_HASH"], await invoke("skill_sync_repo", {
dependencies: [ repo: SKILLS_GITHUB_REPO,
"telethon>=1.36.0", githubToken: SKILLS_GITHUB_TOKEN,
"mcp>=1.0.0", });
"pydantic>=2.0", }
"aiosqlite>=0.20.0",
], async function catalogExists(): Promise<boolean> {
setup: { try {
required: true, const { invoke } = await import("@tauri-apps/api/core");
label: "Connect Telegram", return await invoke<boolean>("skill_catalog_exists");
}, } catch {
}, return false;
]; }
}
interface UpdateCheckResult {
needs_update: boolean;
skipped?: boolean;
local_sha?: string | null;
remote_sha?: string;
}
async function checkForUpdates(): Promise<UpdateCheckResult> {
const { invoke } = await import("@tauri-apps/api/core");
return invoke<UpdateCheckResult>("skill_check_for_updates", {
repo: SKILLS_GITHUB_REPO,
githubToken: SKILLS_GITHUB_TOKEN,
});
}
// ---------------------------------------------------------------------------
// Provider
// ---------------------------------------------------------------------------
export default function SkillProvider({ children }: { children: ReactNode }) { export default function SkillProvider({ children }: { children: ReactNode }) {
const { token } = useAppSelector((state) => state.auth); const { token } = useAppSelector((state) => state.auth);
@@ -47,23 +78,59 @@ export default function SkillProvider({ children }: { children: ReactNode }) {
if (initRef.current) return; if (initRef.current) return;
initRef.current = true; initRef.current = true;
// Register known skills const registerAndStart = async (manifests: SkillManifest[]) => {
for (const manifest of KNOWN_SKILLS) { for (const manifest of manifests) {
skillManager.registerSkill(manifest); 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);
});
} }
} 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 () => { return () => {
// Cleanup on unmount
skillManager.stopAll().catch(console.error); skillManager.stopAll().catch(console.error);
initRef.current = false; initRef.current = false;
}; };
@@ -71,3 +138,42 @@ export default function SkillProvider({ children }: { children: ReactNode }) {
return <>{children}</>; 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<void>,
) {
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);
}
}
+6
View File
@@ -16,3 +16,9 @@ export const TELEGRAM_API_HASH =
export const IS_DEV = export const IS_DEV =
Boolean(import.meta.env.DEV) || import.meta.env.MODE === "development"; 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;