diff --git a/app/package.json b/app/package.json
index b12ce86d4..ada29e1f8 100644
--- a/app/package.json
+++ b/app/package.json
@@ -40,8 +40,7 @@
"format": "prettier --write . && cargo fmt --manifest-path ../Cargo.toml --all",
"format:check": "prettier --check . && cargo fmt --manifest-path ../Cargo.toml --all --check",
"lint": "eslint . --ext .ts,.tsx",
- "lint:fix": "eslint . --ext .ts,.tsx --fix",
- "prepare": "husky"
+ "lint:fix": "eslint . --ext .ts,.tsx --fix"
},
"dependencies": {
"@heroicons/react": "^2.2.0",
diff --git a/app/src-tauri/src/core_process.rs b/app/src-tauri/src/core_process.rs
index 1cc9c78be..cc3c7fd53 100644
--- a/app/src-tauri/src/core_process.rs
+++ b/app/src-tauri/src/core_process.rs
@@ -208,7 +208,10 @@ impl CoreProcessHandle {
);
self.shutdown().await;
- log::debug!("[core] restart: shutdown complete, checking port {}", self.port);
+ log::debug!(
+ "[core] restart: shutdown complete, checking port {}",
+ self.port
+ );
// If we never spawned the sidecar (something else was already listening), we cannot free
// the port — fail fast with a clear message instead of polling for 8s.
@@ -265,12 +268,7 @@ impl CoreProcessHandle {
}
// Wait for the process to exit so the RPC listen socket is released before restart
// checks the port (otherwise we can spuriously hit "port still in use").
- match timeout(
- Duration::from_secs(12),
- child.wait(),
- )
- .await
- {
+ match timeout(Duration::from_secs(12), child.wait()).await {
Ok(Ok(status)) => {
log::debug!("[core] child core process reaped after kill: {status}");
}
diff --git a/app/src-tauri/src/lib.rs b/app/src-tauri/src/lib.rs
index b04a2268d..2292ad43e 100644
--- a/app/src-tauri/src/lib.rs
+++ b/app/src-tauri/src/lib.rs
@@ -143,10 +143,7 @@ async fn restart_core_process(
/// Register (or re-register) the global dictation toggle hotkey.
/// Emits `dictation://toggle` to all webviews when the shortcut is pressed.
#[tauri::command]
-async fn register_dictation_hotkey(
- app: AppHandle,
- shortcut: String,
-) -> Result<(), String> {
+async fn register_dictation_hotkey(app: AppHandle, shortcut: String) -> Result<(), String> {
log::info!("[dictation] register_dictation_hotkey: shortcut={shortcut}");
let old_shortcuts = {
@@ -189,7 +186,9 @@ async fn register_dictation_hotkey(
);
}
}
- return Err(format!("Failed to unregister previous shortcut '{old}': {e}"));
+ return Err(format!(
+ "Failed to unregister previous shortcut '{old}': {e}"
+ ));
}
unregistered_old.push(old.clone());
}
diff --git a/app/src/components/intelligence/MemoryWorkspace.tsx b/app/src/components/intelligence/MemoryWorkspace.tsx
index f0a14f98c..7bd617f04 100644
--- a/app/src/components/intelligence/MemoryWorkspace.tsx
+++ b/app/src/components/intelligence/MemoryWorkspace.tsx
@@ -350,7 +350,7 @@ export function MemoryWorkspace({ onToast }: MemoryWorkspaceProps) {
-
Memory
+
Memory (EverMind)
Your AI's knowledge graph, extracted insights, and ingestion activity.
diff --git a/app/src/components/settings/SettingsHome.tsx b/app/src/components/settings/SettingsHome.tsx
index d0987c850..2ae416baa 100644
--- a/app/src/components/settings/SettingsHome.tsx
+++ b/app/src/components/settings/SettingsHome.tsx
@@ -3,6 +3,7 @@ import { useState } from 'react';
import { skillManager } from '../../lib/skills/manager';
import { useCoreState } from '../../providers/CoreStateProvider';
import { persistor } from '../../store';
+import { resetOpenHumanDataAndRestartCore } from '../../utils/tauriCommands';
import SettingsHeader from './components/SettingsHeader';
import SettingsMenuItem from './components/SettingsMenuItem';
import { useSettingsNavigation } from './hooks/useSettingsNavigation';
@@ -29,29 +30,31 @@ const SettingsHome = () => {
};
const clearAllAppData = async () => {
- try {
- await setOnboardingCompletedFlag(false);
- } catch (err) {
- console.warn('[Settings] Failed to clear onboarding_completed in config:', err);
- }
try {
await clearSession();
} catch (err) {
console.warn('[Settings] Rust logout failed during clearAllAppData:', err);
}
- await persistor.purge();
- window.localStorage.clear();
- window.sessionStorage.clear();
+ try {
+ await resetOpenHumanDataAndRestartCore();
+ } catch (err) {
+ console.warn('[Settings] Failed to reset OpenHuman data dir and restart core:', err);
+ throw err;
+ }
- // NEW: Clear skills databases (emails, chats, cached files)
+ // Best-effort cleanup for in-memory and browser-side caches that live outside the Rust core.
try {
await skillManager.clearAllSkillsData();
} catch (error) {
console.warn('Failed to clear skills data:', error);
- // Continue with logout even if skills clearing fails
+ // Continue even if skill cleanup fails because the backend reset already completed.
}
+ await persistor.purge();
+ window.localStorage.clear();
+ window.sessionStorage.clear();
+
// Complete reset - redirect to login for fresh start
window.location.hash = '/';
};
diff --git a/app/src/components/settings/panels/AccessibilityPanel.tsx b/app/src/components/settings/panels/AccessibilityPanel.tsx
index 94de6785b..6b58ab7e4 100644
--- a/app/src/components/settings/panels/AccessibilityPanel.tsx
+++ b/app/src/components/settings/panels/AccessibilityPanel.tsx
@@ -85,7 +85,6 @@ const AccessibilityPanel = () => {
}, [dispatch]);
const anyPermissionDenied =
- status?.permissions.screen_recording === 'denied' ||
status?.permissions.accessibility === 'denied' ||
status?.permissions.input_monitoring === 'denied';
@@ -119,10 +118,6 @@ const AccessibilityPanel = () => {
)}
-
void dispatch(requestAccessibilityPermission('screen_recording'))}
- disabled={isRequestingPermissions || isRestartingCore}
- className="mt-1 rounded-lg border border-primary-500/60 bg-primary-50 px-3 py-2 text-sm text-primary-600 disabled:opacity-50">
- {isRequestingPermissions ? 'Requesting…' : 'Request Screen Recording'}
-
void dispatch(requestAccessibilityPermission('accessibility'))}
disabled={isRequestingPermissions || isRestartingCore}
- className="rounded-lg border border-primary-500/60 bg-primary-50 px-3 py-2 text-sm text-primary-600 disabled:opacity-50">
+ className="mt-1 rounded-lg border border-primary-500/60 bg-primary-50 px-3 py-2 text-sm text-primary-600 disabled:opacity-50">
{isRequestingPermissions ? 'Requesting…' : 'Request Accessibility'}
{
expect(screen.getByText('Accessibility Automation')).toBeInTheDocument();
expect(screen.getByText('Permissions')).toBeInTheDocument();
expect(screen.getByText('Session')).toBeInTheDocument();
+ expect(screen.queryByText('Screen Recording')).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Start Session' })).toBeInTheDocument();
});
});
diff --git a/app/src/components/skills/SkillSetupWizard.tsx b/app/src/components/skills/SkillSetupWizard.tsx
index f889545b3..bbefdca7f 100644
--- a/app/src/components/skills/SkillSetupWizard.tsx
+++ b/app/src/components/skills/SkillSetupWizard.tsx
@@ -503,11 +503,11 @@ export default function SkillSetupWizard({
/>
-
+
Connected!
{state.message && (
- {state.message}
+
{state.message}
)}
-
+
Setup Failed
- {state.message}
+
{state.message}
Close
@@ -621,7 +621,7 @@ function LoadingView() {
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
/>
-
+
Starting setup...
@@ -664,17 +664,17 @@ function OAuthLoginView({
{/* Provider icon */}
-
{/* Title and description */}
-
+
Connect to {providerName}
-
+
{waiting
? "Waiting for authorization. Complete the login in your browser..."
: `Sign in with your ${providerName} account to connect this skill.`}
@@ -684,9 +684,9 @@ function OAuthLoginView({
{/* Login button or waiting state */}
{waiting ? (
-
+
-
+
Waiting for {providerName} authorization...
Open login page again
@@ -743,7 +743,7 @@ function OAuthLoginView({
Cancel
@@ -796,7 +796,7 @@ function ProviderIcon({ provider }: { provider: string }) {
);
case "github":
return (
-
+
({ callCoreRpc: vi.fn() }));
describe('tauriCommands', () => {
const mockIsTauri = isTauri as Mock;
+ const mockInvoke = invoke as Mock;
const mockCallCoreRpc = callCoreRpc as Mock;
let getAuthState: typeof import('../tauriCommands').getAuthState;
+ let resetOpenHumanDataAndRestartCore: typeof import('../tauriCommands').resetOpenHumanDataAndRestartCore;
let storeSession: typeof import('../tauriCommands').storeSession;
let openhumanLocalAiStatus: typeof import('../tauriCommands').openhumanLocalAiStatus;
let openhumanServiceStatus: typeof import('../tauriCommands').openhumanServiceStatus;
@@ -19,6 +21,7 @@ describe('tauriCommands', () => {
mockIsTauri.mockReturnValue(true);
const actual = await vi.importActual('../tauriCommands');
getAuthState = actual.getAuthState;
+ resetOpenHumanDataAndRestartCore = actual.resetOpenHumanDataAndRestartCore;
storeSession = actual.storeSession;
openhumanLocalAiStatus = actual.openhumanLocalAiStatus;
openhumanServiceStatus = actual.openhumanServiceStatus;
@@ -44,6 +47,13 @@ describe('tauriCommands', () => {
});
});
+ test('resetOpenHumanDataAndRestartCore invokes the destructive Tauri command', async () => {
+ await resetOpenHumanDataAndRestartCore();
+
+ expect(mockCallCoreRpc).toHaveBeenCalledWith({ method: 'openhuman.config_reset_local_data' });
+ expect(mockInvoke).toHaveBeenCalledWith('restart_core_process');
+ });
+
test('openhumanLocalAiStatus returns upgrade hint on unknown method', async () => {
mockCallCoreRpc.mockRejectedValueOnce(new Error('unknown method: openhuman.local_ai_status'));
diff --git a/app/src/utils/tauriCommands.ts b/app/src/utils/tauriCommands.ts
index e31a435ce..ff76a434b 100644
--- a/app/src/utils/tauriCommands.ts
+++ b/app/src/utils/tauriCommands.ts
@@ -200,6 +200,22 @@ export async function restartCoreProcess(): Promise {
console.debug('[core] restartCoreProcess: done');
}
+export async function resetOpenHumanDataAndRestartCore(): Promise {
+ if (!isTauri()) {
+ console.debug('[core] resetOpenHumanDataAndRestartCore: skipped — not running in Tauri');
+ return;
+ }
+ console.debug(
+ '[core] resetOpenHumanDataAndRestartCore: invoking openhuman.config_reset_local_data'
+ );
+ await callCoreRpc({ method: 'openhuman.config_reset_local_data' });
+ console.debug(
+ '[core] resetOpenHumanDataAndRestartCore: local data reset complete, restarting core'
+ );
+ await restartCoreProcess();
+ console.debug('[core] resetOpenHumanDataAndRestartCore: done');
+}
+
// --- Memory Commands ---
/**
diff --git a/package.json b/package.json
index 4d1ef3e7e..f84fb4185 100644
--- a/package.json
+++ b/package.json
@@ -17,6 +17,8 @@
"format:check": "yarn workspace openhuman-app format:check",
"lint": "yarn workspace openhuman-app lint",
"lint:fix": "yarn workspace openhuman-app lint:fix",
+ "prepare": "husky",
+ "postinstall": "husky",
"tauri": "yarn workspace openhuman-app tauri",
"test": "yarn workspace openhuman-app test",
"test:coverage": "yarn workspace openhuman-app test:coverage",
@@ -24,6 +26,9 @@
"mock:api": "node scripts/mock-api-server.mjs",
"typecheck": "yarn workspace openhuman-app compile"
},
+ "devDependencies": {
+ "husky": "^9.1.7"
+ },
"dependencies": {
"@tauri-apps/api": "2.10.1"
}
diff --git a/src/core/skills_cli.rs b/src/core/skills_cli.rs
index 4314d9ebc..79412333f 100644
--- a/src/core/skills_cli.rs
+++ b/src/core/skills_cli.rs
@@ -417,9 +417,7 @@ fn run_skills_test(args: &[String]) -> Result<()> {
// ---------------------------------------------------------------------------
fn build_skills_only_router() -> axum::Router {
- use axum::response::IntoResponse;
use axum::routing::{get, post};
- use axum::Json;
axum::Router::new()
.route("/health", get(health))
diff --git a/src/openhuman/config/ops.rs b/src/openhuman/config/ops.rs
index 261927ccc..794cd3136 100644
--- a/src/openhuman/config/ops.rs
+++ b/src/openhuman/config/ops.rs
@@ -1,6 +1,6 @@
//! JSON-RPC / CLI controller surface for persisted config and runtime flags.
-use std::path::PathBuf;
+use std::path::{Path, PathBuf};
use serde::Serialize;
use serde_json::json;
@@ -39,6 +39,86 @@ fn fallback_workspace_dir() -> PathBuf {
.join("workspace")
}
+fn default_openhuman_dir() -> PathBuf {
+ dirs::home_dir()
+ .unwrap_or_else(|| PathBuf::from("."))
+ .join(".openhuman")
+}
+
+fn active_workspace_marker_path(default_openhuman_dir: &Path) -> PathBuf {
+ default_openhuman_dir.join("active_workspace.toml")
+}
+
+fn config_openhuman_dir(config: &Config) -> PathBuf {
+ config
+ .config_path
+ .parent()
+ .map_or_else(|| PathBuf::from("."), PathBuf::from)
+}
+
+async fn reset_local_data_for_paths(
+ current_openhuman_dir: &Path,
+ default_openhuman_dir: &Path,
+) -> Result, String> {
+ let active_workspace_marker = active_workspace_marker_path(default_openhuman_dir);
+ tracing::debug!(
+ current_dir = %current_openhuman_dir.display(),
+ default_dir = %default_openhuman_dir.display(),
+ marker = %active_workspace_marker.display(),
+ "[config] reset_local_data: starting"
+ );
+
+ let mut removed_paths = Vec::new();
+
+ if active_workspace_marker.exists() {
+ tokio::fs::remove_file(&active_workspace_marker)
+ .await
+ .map_err(|e| format!("Failed to remove active workspace marker: {e}"))?;
+ tracing::debug!(
+ marker = %active_workspace_marker.display(),
+ "[config] reset_local_data: removed active workspace marker"
+ );
+ removed_paths.push(active_workspace_marker.display().to_string());
+ }
+
+ for target_dir in [current_openhuman_dir, default_openhuman_dir] {
+ if !target_dir.exists() {
+ tracing::debug!(
+ dir = %target_dir.display(),
+ "[config] reset_local_data: directory already absent"
+ );
+ continue;
+ }
+
+ tokio::fs::remove_dir_all(target_dir)
+ .await
+ .map_err(|e| format!("Failed to remove {}: {e}", target_dir.display()))?;
+ tracing::debug!(
+ dir = %target_dir.display(),
+ "[config] reset_local_data: removed directory"
+ );
+ removed_paths.push(target_dir.display().to_string());
+ }
+
+ Ok(RpcOutcome::new(
+ json!({
+ "removed_paths": removed_paths,
+ "current_openhuman_dir": current_openhuman_dir.display().to_string(),
+ "default_openhuman_dir": default_openhuman_dir.display().to_string(),
+ }),
+ vec![
+ format!(
+ "reset local data for active config dir {}",
+ current_openhuman_dir.display()
+ ),
+ format!(
+ "removed default data dir {} if present",
+ default_openhuman_dir.display()
+ ),
+ ],
+ ))
+}
+
pub fn snapshot_config_json(config: &Config) -> Result {
let value = serde_json::to_value(config).map_err(|e| e.to_string())?;
Ok(json!({
@@ -459,3 +539,46 @@ pub fn agent_server_status() -> RpcOutcome {
});
RpcOutcome::single_log(payload, "agent server status checked")
}
+
+pub async fn reset_local_data() -> Result, String> {
+ let config = load_config_with_timeout().await?;
+ let current_openhuman_dir = config_openhuman_dir(&config);
+ let default_openhuman_dir = default_openhuman_dir();
+ reset_local_data_for_paths(¤t_openhuman_dir, &default_openhuman_dir).await
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use tempfile::tempdir;
+
+ #[tokio::test]
+ async fn reset_local_data_removes_current_dir_default_dir_and_marker() {
+ let temp = tempdir().unwrap();
+ let default_openhuman_dir = temp.path().join("default-openhuman");
+ let current_openhuman_dir = temp.path().join("custom-openhuman");
+ let marker = active_workspace_marker_path(&default_openhuman_dir);
+
+ tokio::fs::create_dir_all(default_openhuman_dir.join("workspace"))
+ .await
+ .unwrap();
+ tokio::fs::create_dir_all(current_openhuman_dir.join("workspace"))
+ .await
+ .unwrap();
+ tokio::fs::write(&marker, "config_dir = '/tmp/custom-openhuman'\n")
+ .await
+ .unwrap();
+
+ let outcome = reset_local_data_for_paths(¤t_openhuman_dir, &default_openhuman_dir)
+ .await
+ .unwrap();
+
+ assert!(!current_openhuman_dir.exists());
+ assert!(!default_openhuman_dir.exists());
+ assert!(outcome
+ .value
+ .get("removed_paths")
+ .and_then(|value| value.as_array())
+ .is_some_and(|paths| !paths.is_empty()));
+ }
+}
diff --git a/src/openhuman/config/schemas.rs b/src/openhuman/config/schemas.rs
index b466358b2..d98eb6d36 100644
--- a/src/openhuman/config/schemas.rs
+++ b/src/openhuman/config/schemas.rs
@@ -91,6 +91,7 @@ pub fn all_controller_schemas() -> Vec {
schemas("update_analytics_settings"),
schemas("get_analytics_settings"),
schemas("agent_server_status"),
+ schemas("reset_local_data"),
schemas("get_onboarding_completed"),
schemas("set_onboarding_completed"),
]
@@ -154,6 +155,10 @@ pub fn all_registered_controllers() -> Vec {
schema: schemas("agent_server_status"),
handler: handle_agent_server_status,
},
+ RegisteredController {
+ schema: schemas("reset_local_data"),
+ handler: handle_reset_local_data,
+ },
RegisteredController {
schema: schemas("get_onboarding_completed"),
handler: handle_get_onboarding_completed,
@@ -383,6 +388,14 @@ pub fn schemas(function: &str) -> ControllerSchema {
inputs: vec![],
outputs: vec![json_output("status", "Agent server status payload.")],
},
+ "reset_local_data" => ControllerSchema {
+ namespace: "config",
+ function: "reset_local_data",
+ description:
+ "Delete local OpenHuman data for the active config/workspace so the next restart boots clean.",
+ inputs: vec![],
+ outputs: vec![json_output("result", "Reset result with removed paths.")],
+ },
"get_onboarding_completed" => ControllerSchema {
namespace: "config",
function: "get_onboarding_completed",
@@ -565,6 +578,10 @@ fn handle_agent_server_status(_params: Map) -> ControllerFuture {
Box::pin(async { to_json(config_rpc::agent_server_status()) })
}
+fn handle_reset_local_data(_params: Map) -> ControllerFuture {
+ Box::pin(async { to_json(config_rpc::reset_local_data().await?) })
+}
+
fn handle_get_onboarding_completed(_params: Map) -> ControllerFuture {
Box::pin(async { to_json(config_rpc::get_onboarding_completed().await?) })
}
diff --git a/src/openhuman/screen_intelligence/engine.rs b/src/openhuman/screen_intelligence/engine.rs
index d26506cdd..d35163cf3 100644
--- a/src/openhuman/screen_intelligence/engine.rs
+++ b/src/openhuman/screen_intelligence/engine.rs
@@ -277,8 +277,6 @@ impl AccessibilityEngine {
});
}
- self.request_permission(PermissionKind::ScreenRecording)
- .await?;
self.request_permission(PermissionKind::Accessibility)
.await?;
self.request_permission(PermissionKind::InputMonitoring)
@@ -286,7 +284,7 @@ impl AccessibilityEngine {
let mut state = self.inner.lock().await;
state.permissions = detect_permissions();
- state.last_event = Some("permissions_requested".to_string());
+ state.last_event = Some("permissions_requested:accessibility,input_monitoring".to_string());
Ok(state.permissions.clone())
}
diff --git a/src/openhuman/screen_intelligence/ops.rs b/src/openhuman/screen_intelligence/ops.rs
index 58b560917..2b5a3b940 100644
--- a/src/openhuman/screen_intelligence/ops.rs
+++ b/src/openhuman/screen_intelligence/ops.rs
@@ -90,7 +90,7 @@ pub async fn accessibility_request_permissions() -> Result ControllerSchema {
"request_permissions" => ControllerSchema {
namespace: "screen_intelligence",
function: "request_permissions",
- description: "Request required accessibility permissions.",
+ description:
+ "Request accessibility automation permissions without prompting for screen recording.",
inputs: vec![],
outputs: vec![json_output("permissions", "Permission status payload.")],
},
"request_permission" => ControllerSchema {
namespace: "screen_intelligence",
function: "request_permission",
- description: "Request one accessibility permission.",
+ description: "Request one permission such as accessibility, input monitoring, or screen recording.",
inputs: vec![FieldSchema {
name: "permission",
ty: TypeSchema::String,
diff --git a/src/openhuman/skills/registry_ops.rs b/src/openhuman/skills/registry_ops.rs
index c5eb31859..226c4418c 100644
--- a/src/openhuman/skills/registry_ops.rs
+++ b/src/openhuman/skills/registry_ops.rs
@@ -1,10 +1,10 @@
-use std::path::{Path, PathBuf};
+use std::path::Path;
use sha2::{Digest, Sha256};
use super::registry_cache::{
- cache_path, is_cache_fresh, is_local_path, local_skills_dir, read_cache, read_local_file,
- registry_url, tag_categories, write_cache,
+ is_cache_fresh, is_local_path, local_skills_dir, read_cache, read_local_file, registry_url,
+ tag_categories, write_cache,
};
use super::registry_types::{
AvailableSkillEntry, InstalledSkillInfo, RegistrySkillEntry, RemoteSkillRegistry,
diff --git a/src/openhuman/subconscious/engine.rs b/src/openhuman/subconscious/engine.rs
index 81ff7ec0f..a2acbacfc 100644
--- a/src/openhuman/subconscious/engine.rs
+++ b/src/openhuman/subconscious/engine.rs
@@ -9,9 +9,9 @@ use super::prompt::build_subconscious_prompt;
use super::situation_report::build_situation_report;
use super::types::{Decision, SubconsciousStatus, TickOutput, TickResult};
use crate::openhuman::config::Config;
-use crate::openhuman::memory::{MemoryClient, MemoryClientRef};
+use crate::openhuman::memory::MemoryClientRef;
use anyhow::Result;
-use std::path::{Path, PathBuf};
+use std::path::PathBuf;
use std::sync::Arc;
use tokio::sync::Mutex;
use tokio::time::{self, Duration};
diff --git a/tests/skills_debug_e2e.rs b/tests/skills_debug_e2e.rs
index 31cd8f5e0..642bc9d6c 100644
--- a/tests/skills_debug_e2e.rs
+++ b/tests/skills_debug_e2e.rs
@@ -3,9 +3,9 @@
//! Exercises the full skill lifecycle through the RuntimeEngine:
//! discover → start → list tools → call tool → setup flow → tick/sync → stop
//!
-//! By default uses the bundled `example-skill` from the openhuman-skills repo.
+//! By default uses a preferred skill from the checked-out openhuman-skills repo.
//! Override with env vars:
-//! SKILL_DEBUG_ID — skill ID to test (default: "example-skill")
+//! SKILL_DEBUG_ID — skill ID to test
//! SKILL_DEBUG_DIR — path to skills directory containing skill folders
//! SKILL_DEBUG_TOOL — specific tool name to call (default: first tool found)
//! SKILL_DEBUG_TOOL_ARGS — JSON args for the tool call (default: "{}")
@@ -37,6 +37,46 @@ fn is_verbose() -> bool {
.unwrap_or(false)
}
+fn manifests_in_dir(skills_dir: &Path) -> Vec {
+ let mut ids = Vec::new();
+ let Ok(entries) = std::fs::read_dir(skills_dir) else {
+ return ids;
+ };
+
+ for entry in entries.flatten() {
+ let path = entry.path();
+ if !path.is_dir() || !path.join("manifest.json").exists() {
+ continue;
+ }
+ if let Some(name) = path.file_name().and_then(|name| name.to_str()) {
+ ids.push(name.to_string());
+ }
+ }
+
+ ids.sort();
+ ids
+}
+
+fn select_skill_id(skills_dir: &Path, env_key: &str, preferred: &[&str]) -> String {
+ if let Ok(skill_id) = std::env::var(env_key) {
+ if !skill_id.trim().is_empty() {
+ return skill_id;
+ }
+ }
+
+ let available = manifests_in_dir(skills_dir);
+ for candidate in preferred {
+ if available.iter().any(|id| id == candidate) {
+ return (*candidate).to_string();
+ }
+ }
+
+ available
+ .into_iter()
+ .next()
+ .unwrap_or_else(|| "example-skill".to_string())
+}
+
fn banner(label: &str) {
let sep = "=".repeat(60);
eprintln!("\n{sep}");
@@ -104,7 +144,7 @@ fn try_find_skills_dir() -> Option {
if let Some(parent) = cwd.parent() {
for entry in std::fs::read_dir(parent).into_iter().flatten().flatten() {
let candidate = entry.path().join("skills/skills");
- if candidate.exists() && candidate.join("example-skill/manifest.json").exists() {
+ if candidate.exists() && candidate.read_dir().is_ok() {
return Some(candidate.canonicalize().unwrap());
}
}
@@ -155,8 +195,12 @@ async fn skill_full_lifecycle() {
.is_test(true)
.try_init();
- let skill_id = env_or("SKILL_DEBUG_ID", "example-skill");
let skills_dir = require_skills_dir!();
+ let skill_id = select_skill_id(
+ &skills_dir,
+ "SKILL_DEBUG_ID",
+ &["server-ping", "gmail", "notion"],
+ );
let tmp = tempdir().expect("tempdir");
let data_dir = tmp.path().join("skills_data");
std::fs::create_dir_all(&data_dir).expect("create data_dir");
@@ -570,8 +614,12 @@ async fn skill_rapid_start_stop() {
.is_test(true)
.try_init();
- let skill_id = env_or("SKILL_DEBUG_ID", "example-skill");
let skills_dir = require_skills_dir!();
+ let skill_id = select_skill_id(
+ &skills_dir,
+ "SKILL_DEBUG_ID",
+ &["server-ping", "gmail", "notion"],
+ );
let tmp = tempdir().expect("tempdir");
let data_dir = tmp.path().join("skills_data");
std::fs::create_dir_all(&data_dir).unwrap();
@@ -611,8 +659,8 @@ async fn skill_disconnect_flow() {
.is_test(true)
.try_init();
- let skill_id = env_or("SKILL_DEBUG_ID", "example-skill");
let skills_dir = require_skills_dir!();
+ let skill_id = select_skill_id(&skills_dir, "SKILL_DEBUG_ID", &["gmail", "notion"]);
let tmp = tempdir().expect("tempdir");
let data_dir = tmp.path().join("skills_data");
std::fs::create_dir_all(&data_dir).unwrap();