diff --git a/skills b/skills index 66ad4d7e3..66ec30016 160000 --- a/skills +++ b/skills @@ -1 +1 @@ -Subproject commit 66ad4d7e3fd00122f24532e474d8b664b62bbd30 +Subproject commit 66ec30016fe3811a5d945a24f3b6e06e165069d4 diff --git a/src-tauri/src/runtime/types.rs b/src-tauri/src/runtime/types.rs index 6768cd126..df5e38340 100644 --- a/src-tauri/src/runtime/types.rs +++ b/src-tauri/src/runtime/types.rs @@ -185,6 +185,9 @@ pub struct UnifiedSkillEntry { pub description: String, /// Tools exposed by this skill. pub tools: Vec, + /// Setup configuration from manifest.json (e.g. whether setup is required). + #[serde(skip_serializing_if = "Option::is_none")] + pub setup: Option, } /// Standardized result returned by any skill execution in the unified registry. diff --git a/src-tauri/src/unified_skills/mod.rs b/src-tauri/src/unified_skills/mod.rs index 5e9380c26..87137f2f1 100644 --- a/src-tauri/src/unified_skills/mod.rs +++ b/src-tauri/src/unified_skills/mod.rs @@ -91,6 +91,7 @@ impl UnifiedSkillRegistry { version: manifest.version.clone().unwrap_or_else(|| "0.1.0".to_string()), description: manifest.description.clone().unwrap_or_default(), tools, + setup: manifest.setup.clone(), }); } } @@ -126,6 +127,7 @@ impl UnifiedSkillRegistry { version: skill.version.clone(), description: skill.description.clone(), tools, + setup: None, }); } @@ -208,6 +210,7 @@ impl UnifiedSkillRegistry { version: "1.0.0".to_string(), description: spec.description, tools: vec![], + setup: None, }) } "openclaw" => { @@ -221,6 +224,7 @@ impl UnifiedSkillRegistry { version: "1.0.0".to_string(), description: spec.description, tools: vec![], + setup: None, }) } other => Err(format!("Unknown skill_type: '{other}'. Use 'alphahuman' or 'openclaw'.")), diff --git a/src/components/SkillsGrid.tsx b/src/components/SkillsGrid.tsx index 9061d90d0..e49154512 100644 --- a/src/components/SkillsGrid.tsx +++ b/src/components/SkillsGrid.tsx @@ -19,16 +19,24 @@ import SkillSetupModal from './skills/SkillSetupModal'; /** Normalize a raw unified registry entry into a SkillListEntry for display. */ function normalizeUnifiedEntry(e: Record): SkillListEntry { - const setup = e.setup as Record | undefined; + const setup = e.setup as { required?: boolean; oauth?: unknown } | undefined; + // Treat both interactive setup steps and OAuth-only flows as "has setup" + // so that clicking a skill (e.g. Gmail) opens the connection/setup wizard + // instead of jumping straight to the management panel. + const hasSetup = + !!setup && + (setup.required === true || + // OAuth config means we still need a connection step in the wizard + !!setup.oauth); + return { id: e.id as string, name: - (e.name as string) || - (e.id as string).charAt(0).toUpperCase() + (e.id as string).slice(1), + (e.name as string) || (e.id as string).charAt(0).toUpperCase() + (e.id as string).slice(1), description: (e.description as string) || '', icon: SKILL_ICONS[e.id as string], ignoreInProduction: (e.ignoreInProduction as boolean) ?? false, - hasSetup: !!(setup && setup.required), + hasSetup, skill_type: (e.skill_type as 'alphahuman' | 'openclaw') ?? 'alphahuman', }; } @@ -47,9 +55,7 @@ function SkillTypeBadge({ type }: { type?: string }) { return ( {type} @@ -149,14 +155,19 @@ export default function SkillsGrid() { const processed: SkillListEntry[] = manifests .filter(m => !(m.id as string).includes('_')) .map(m => { - const setup = m.setup as Record | undefined; + const setup = m.setup as { required?: boolean; oauth?: unknown } | undefined; + const hasSetup = + !!setup && + (setup.required === true || + // OAuth-only skills still need a setup/connect flow + !!setup.oauth); return { id: m.id as string, name: (m.name as string) || (m.id as string), description: (m.description as string) || '', icon: SKILL_ICONS[m.id as string], ignoreInProduction: (m.ignoreInProduction as boolean) ?? false, - hasSetup: !!(setup && setup.required), + hasSetup, skill_type: 'alphahuman' as const, }; }) @@ -183,7 +194,6 @@ export default function SkillsGrid() { }; detectMobile(); refreshSkills(); - // eslint-disable-next-line react-hooks/exhaustive-deps }, []); // Sort skills by connection status (connected first) @@ -270,11 +280,7 @@ export default function SkillsGrid() { }} className="text-xs text-primary-400 hover:text-primary-300 transition-colors flex items-center gap-1"> {/* Sparkle / robot icon */} - + Generating… ) : ( <> - + setSelfEvolveOpen(false)} - onSkillCreated={refreshSkills} - /> + setSelfEvolveOpen(false)} onSkillCreated={refreshSkills} /> )} {/* Skills Management Modal */} diff --git a/src/components/skills/shared.tsx b/src/components/skills/shared.tsx index 253367920..a934cce1f 100644 --- a/src/components/skills/shared.tsx +++ b/src/components/skills/shared.tsx @@ -92,6 +92,7 @@ export function SkillActionButton({ version: '0.0.0', description: skill.description, runtime: 'quickjs', + setup: skill.hasSetup ? { required: true } : undefined, }); if (skill.hasSetup) { onOpenModal(); diff --git a/src/lib/gmail/services/metadataSync.ts b/src/lib/gmail/services/metadataSync.ts index d7fac3c15..7efc2528d 100644 --- a/src/lib/gmail/services/metadataSync.ts +++ b/src/lib/gmail/services/metadataSync.ts @@ -9,43 +9,26 @@ const INTEGRATION_METADATA_SYNC_EVENT = 'integration:metadata-sync'; const PROVIDER_GOOGLE = 'gmail'; /** Gmail profile shape from skill state (snake_case). */ -interface GmailProfileLike { +export interface GmailProfileLike { email_address: string; messages_total: number; threads_total: number; history_id: string; } -/** Single email summary from skill state. */ -interface GmailEmailSummaryLike { - id: string; - threadId: string; - snippet?: string; - subject?: string; - from?: string; - date?: string; -} - -/** Gmail skill state slice we care about for metadata sync. */ -export interface GmailStateForSync { - profile?: GmailProfileLike | null; - emails?: GmailEmailSummaryLike[] | null; -} - /** * Emit `integration:metadata-sync` with Gmail profile and emails so the * backend can merge them into the user's Google OAuth integration. * No-op when profile is missing or not in Tauri. */ -export function syncGmailMetadataToBackend(gmailState: GmailStateForSync | undefined): void { - if (!gmailState?.profile || typeof gmailState.profile !== 'object') return; +export function syncGmailMetadataToBackend(gmailState: GmailProfileLike | undefined): void { + if (!gmailState) return; - const profile = gmailState.profile as GmailProfileLike; const metadata: Record = { - email_address: profile.email_address, - messages_total: profile.messages_total, - threads_total: profile.threads_total, - history_id: profile.history_id, + email_address: gmailState.email_address, + messages_total: gmailState.messages_total, + threads_total: gmailState.threads_total, + history_id: gmailState.history_id, }; // if (Array.isArray(gmailState.emails) && gmailState.emails.length > 0) { diff --git a/src/lib/skills/manager.ts b/src/lib/skills/manager.ts index f8bf90598..f407ad6bf 100644 --- a/src/lib/skills/manager.ts +++ b/src/lib/skills/manager.ts @@ -81,7 +81,14 @@ class SkillManager { // Dead runtime — clean up this.runtimes.delete(skillId); } - +// Ensure the skill is registered in Redux before dispatching status updates. + // Self-evolved skills are started directly by the Rust engine and never go + // through registerSkill(), so state.skills[skillId] is undefined. Every + // setSkillStatus / setSkillSetupComplete / setSkillTools reducer silently + // no-ops when the key is missing, making the Enable button appear broken. + if (!store.getState().skills.skills[skillId]) { + store.dispatch(addSkill({ manifest })); + } store.dispatch(setSkillStatus({ skillId, status: "starting" })); const runtime = new SkillRuntime(manifest); diff --git a/src/providers/SkillProvider.tsx b/src/providers/SkillProvider.tsx index 5b4eec6e2..e9969fca5 100644 --- a/src/providers/SkillProvider.tsx +++ b/src/providers/SkillProvider.tsx @@ -9,18 +9,13 @@ import { listen } from '@tauri-apps/api/event'; import { type ReactNode, useEffect, useRef } from 'react'; import { - type GmailStateForSync, + type GmailProfileLike, syncGmailMetadataToBackend, } from '../lib/gmail/services/metadataSync'; import { skillManager } from '../lib/skills/manager'; import type { SkillManifest } from '../lib/skills/types'; import { buildManualSentryEvent, enqueueError } from '../services/errorReportQueue'; -import { - type GmailEmailSummary, - type GmailProfile, - setGmailEmails, - setGmailProfile, -} from '../store/gmailSlice'; +import { type GmailProfile, setGmailProfile } from '../store/gmailSlice'; import { useAppDispatch, useAppSelector } from '../store/hooks'; import { setSkillError, setSkillState, setSkillStatus } from '../store/skillsSlice'; import { DEV_AUTO_LOAD_SKILL, IS_DEV } from '../utils/config'; @@ -88,12 +83,7 @@ function syncGmailStateToSlice( : null ) ); - dispatch( - setGmailEmails( - Array.isArray(gmailState.emails) ? (gmailState.emails as GmailEmailSummary[]) : [] - ) - ); - syncGmailMetadataToBackend(gmailState.profile as GmailStateForSync); + syncGmailMetadataToBackend(gmailState.profile as GmailProfileLike); } export default function SkillProvider({ children }: { children: ReactNode }) { diff --git a/src/utils/desktopDeepLinkListener.ts b/src/utils/desktopDeepLinkListener.ts index acd27bd38..ecce1b63d 100644 --- a/src/utils/desktopDeepLinkListener.ts +++ b/src/utils/desktopDeepLinkListener.ts @@ -161,8 +161,7 @@ const handleOAuthDeepLink = async (parsed: URL) => { try { keyForBackend = hexToBase64(trimmedHex); } catch (e) { - const msg = - '[DeepLink] Cannot fetch integration tokens: encryption key conversion failed'; + const msg = '[DeepLink] Cannot fetch integration tokens: encryption key conversion failed'; console.error(msg, { userId, encryptionKeyHex, error: e }); const err = e instanceof Error ? e : new Error(String(e)); enqueueError({ @@ -222,16 +221,15 @@ const handleOAuthDeepLink = async (parsed: URL) => { // For Gmail, pass decrypted access token so the skill uses it instead of the proxy let extraCredential: { accessToken?: string } | undefined; - if (skillId === 'gmail') { - try { - const decryptedJson = await decryptIntegrationTokens(response.data.encrypted, trimmedHex); - const payload = JSON.parse(decryptedJson) as IntegrationTokensPayload; - if (payload.accessToken) { - extraCredential = { accessToken: payload.accessToken }; - } - } catch (e) { - console.warn('[DeepLink] Could not decrypt Gmail token for skill:', e); + + try { + const decryptedJson = await decryptIntegrationTokens(response.data.encrypted, trimmedHex); + const payload = JSON.parse(decryptedJson) as IntegrationTokensPayload; + if (payload.accessToken) { + extraCredential = { accessToken: payload.accessToken }; } + } catch (e) { + console.warn('[DeepLink] Could not decrypt Gmail token for skill:', e); } await skillManager.notifyOAuthComplete(skillId, integrationId, undefined, extraCredential); diff --git a/src/utils/tauriSocket.ts b/src/utils/tauriSocket.ts index d1ef14107..aa52f9348 100644 --- a/src/utils/tauriSocket.ts +++ b/src/utils/tauriSocket.ts @@ -27,7 +27,14 @@ export const isTauri = (): boolean => { const isTauriEnv = coreIsTauri(); const windowTauri = typeof window !== 'undefined' ? !!window.__TAURI__ : 'undefined'; const userAgent = typeof navigator !== 'undefined' ? navigator.userAgent : 'undefined'; - console.log('[TauriSocket] isTauri() check:', isTauriEnv, 'window.__TAURI__:', windowTauri, 'userAgent:', userAgent); + console.log( + '[TauriSocket] isTauri() check:', + isTauriEnv, + 'window.__TAURI__:', + windowTauri, + 'userAgent:', + userAgent + ); return isTauriEnv; };