mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
Split skills registry and runtime modules (#3481)
This commit is contained in:
@@ -64,22 +64,17 @@ jobs:
|
||||
rust-core-coverage:
|
||||
name: Rust Core Coverage (cargo-llvm-cov)
|
||||
runs-on: ubuntu-22.04
|
||||
# See test.yml `rust-core-tests` — same shared-singleton flake risk in
|
||||
# the lib suite. Coverage instrumentation roughly doubles wall time vs.
|
||||
# the plain test runs; give it enough headroom for cold/warm cache
|
||||
# variance while still surfacing deadlocks with logs.
|
||||
timeout-minutes: 30
|
||||
container:
|
||||
image: ghcr.io/tinyhumansai/openhuman_ci:rust-1.93.0
|
||||
env:
|
||||
CARGO_INCREMENTAL: "0"
|
||||
# Coverage instrumentation makes each test binary link substantially
|
||||
# heavier. Keep the core coverage job's linker work serialized to avoid
|
||||
# intermittent rust-lld bus errors on hosted Linux runners.
|
||||
CARGO_BUILD_JOBS: "1"
|
||||
# sccache is incompatible with `-C instrument-coverage` profiles, so we
|
||||
# skip it for coverage runs and rely on Swatinem/rust-cache for warmup.
|
||||
steps:
|
||||
- name: Free disk space
|
||||
run: |
|
||||
rm -rf /__t/* || true
|
||||
echo "Disk after cleanup:"
|
||||
df -h /__w || true
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
@@ -97,7 +92,7 @@ jobs:
|
||||
- name: Run cargo llvm-cov for openhuman core
|
||||
run: cargo llvm-cov --no-fail-fast -p openhuman --lcov --output-path lcov-core.info
|
||||
env:
|
||||
CARGO_BUILD_JOBS: "2"
|
||||
CARGO_BUILD_JOBS: "1"
|
||||
- name: Upload core lcov
|
||||
uses: actions/upload-artifact@v5
|
||||
with:
|
||||
|
||||
@@ -297,6 +297,18 @@ jobs:
|
||||
env:
|
||||
CARGO_INCREMENTAL: "0"
|
||||
steps:
|
||||
- name: Free disk space
|
||||
run: |
|
||||
# Coverage-instrumented builds produce huge binaries that exhaust the
|
||||
# runner's ~14GB free disk during linking (SIGBUS). Inside a container,
|
||||
# host paths are bind-mounted:
|
||||
# /opt/hostedtoolcache → /__t
|
||||
# /home/runner/work → /__w
|
||||
# Deleting /__t frees ~8GB on the same partition the build uses.
|
||||
rm -rf /__t/* || true
|
||||
echo "Disk after cleanup:"
|
||||
df -h /__w || true
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
@@ -309,10 +321,6 @@ jobs:
|
||||
with:
|
||||
workspaces: . -> target
|
||||
cache-on-failure: true
|
||||
# Coverage uses instrumented builds (-C instrument-coverage), whose
|
||||
# rustflags hash to a different Swatinem key than the plain builds, so
|
||||
# it gets its own cache and cannot reuse pr-rust-core. It still reuses
|
||||
# the (non-instrumented) dependency tree run-over-run.
|
||||
shared-key: pr-rust-core-cov
|
||||
|
||||
- name: Install cargo-llvm-cov
|
||||
@@ -321,7 +329,7 @@ jobs:
|
||||
- name: Run cargo llvm-cov for openhuman core
|
||||
run: bash scripts/ci-cancel-aware.sh cargo llvm-cov --no-fail-fast -p openhuman --lcov --output-path lcov-core.info
|
||||
env:
|
||||
CARGO_BUILD_JOBS: "2"
|
||||
CARGO_BUILD_JOBS: "1"
|
||||
|
||||
- name: Upload core lcov
|
||||
uses: actions/upload-artifact@v5
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import debug from 'debug';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { useT } from '../../lib/i18n/I18nContext';
|
||||
import { type CatalogEntry, skillRegistryApi } from '../../services/api/skillRegistryApi';
|
||||
import {
|
||||
skillRegistryApi,
|
||||
type CatalogEntry,
|
||||
} from '../../services/api/skillRegistryApi';
|
||||
import {
|
||||
workflowsApi,
|
||||
type InstallWorkflowFromUrlResult,
|
||||
workflowsApi,
|
||||
type WorkflowSummary,
|
||||
} from '../../services/api/workflowsApi';
|
||||
import EmptyStateCard from '../EmptyStateCard';
|
||||
@@ -16,29 +13,73 @@ import InstallSkillDialog from './InstallSkillDialog';
|
||||
import UninstallSkillConfirmDialog from './UninstallSkillConfirmDialog';
|
||||
|
||||
const log = debug('skills:explorer-tab');
|
||||
const CATALOG_PAGE_SIZE = 60;
|
||||
const SEARCH_DEBOUNCE_MS = 300;
|
||||
|
||||
function SkillFormatBadge({ format }: { format: string }) {
|
||||
const lower = format.toLowerCase();
|
||||
const label =
|
||||
lower === 'hermes'
|
||||
? 'Hermes'
|
||||
: lower === 'legacy'
|
||||
? 'Legacy'
|
||||
: lower === 'openclaw'
|
||||
? 'OpenClaw'
|
||||
: 'OpenHuman';
|
||||
function SourceBadge({ source }: { source: string }) {
|
||||
const SOURCE_COLORS: Record<string, string> = {
|
||||
'built-in':
|
||||
'bg-emerald-50 text-emerald-700 border-emerald-200 dark:bg-emerald-500/10 dark:text-emerald-300 dark:border-emerald-500/30',
|
||||
optional:
|
||||
'bg-blue-50 text-blue-700 border-blue-200 dark:bg-blue-500/10 dark:text-blue-300 dark:border-blue-500/30',
|
||||
ClawHub:
|
||||
'bg-teal-50 text-teal-700 border-teal-200 dark:bg-teal-500/10 dark:text-teal-300 dark:border-teal-500/30',
|
||||
'skills.sh':
|
||||
'bg-violet-50 text-violet-700 border-violet-200 dark:bg-violet-500/10 dark:text-violet-300 dark:border-violet-500/30',
|
||||
LobeHub:
|
||||
'bg-pink-50 text-pink-700 border-pink-200 dark:bg-pink-500/10 dark:text-pink-300 dark:border-pink-500/30',
|
||||
'browse.sh':
|
||||
'bg-amber-50 text-amber-700 border-amber-200 dark:bg-amber-500/10 dark:text-amber-300 dark:border-amber-500/30',
|
||||
};
|
||||
const colors =
|
||||
lower === 'hermes'
|
||||
? 'bg-violet-50 text-violet-700 border-violet-200 dark:bg-violet-500/10 dark:text-violet-300 dark:border-violet-500/30'
|
||||
: lower === 'openclaw'
|
||||
? 'bg-teal-50 text-teal-700 border-teal-200 dark:bg-teal-500/10 dark:text-teal-300 dark:border-teal-500/30'
|
||||
: lower === 'legacy'
|
||||
? 'bg-amber-50 text-amber-700 border-amber-200 dark:bg-amber-500/10 dark:text-amber-300 dark:border-amber-500/30'
|
||||
: 'bg-primary-50 text-primary-700 border-primary-200 dark:bg-primary-500/10 dark:text-primary-300 dark:border-primary-500/30';
|
||||
SOURCE_COLORS[source] ??
|
||||
'bg-stone-50 text-stone-600 border-stone-200 dark:bg-neutral-800 dark:text-neutral-300 dark:border-neutral-700';
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center rounded-full border px-1.5 py-0.5 text-[9px] font-semibold uppercase tracking-wider ${colors}`}>
|
||||
{label}
|
||||
{source}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function SkillFormatBadge({ format }: { format: string }) {
|
||||
const lower = format.toLowerCase();
|
||||
const FORMAT_MAP: Record<string, { label: string; colors: string }> = {
|
||||
hermes: {
|
||||
label: 'Hermes',
|
||||
colors:
|
||||
'bg-violet-50 text-violet-700 border-violet-200 dark:bg-violet-500/10 dark:text-violet-300 dark:border-violet-500/30',
|
||||
},
|
||||
agentskills: {
|
||||
label: 'AgentSkills',
|
||||
colors:
|
||||
'bg-violet-50 text-violet-700 border-violet-200 dark:bg-violet-500/10 dark:text-violet-300 dark:border-violet-500/30',
|
||||
},
|
||||
openclaw: {
|
||||
label: 'OpenClaw',
|
||||
colors:
|
||||
'bg-teal-50 text-teal-700 border-teal-200 dark:bg-teal-500/10 dark:text-teal-300 dark:border-teal-500/30',
|
||||
},
|
||||
clawhub: {
|
||||
label: 'ClawHub',
|
||||
colors:
|
||||
'bg-teal-50 text-teal-700 border-teal-200 dark:bg-teal-500/10 dark:text-teal-300 dark:border-teal-500/30',
|
||||
},
|
||||
legacy: {
|
||||
label: 'Legacy',
|
||||
colors:
|
||||
'bg-amber-50 text-amber-700 border-amber-200 dark:bg-amber-500/10 dark:text-amber-300 dark:border-amber-500/30',
|
||||
},
|
||||
};
|
||||
const entry = FORMAT_MAP[lower] ?? {
|
||||
label: format || 'Skill',
|
||||
colors:
|
||||
'bg-stone-50 text-stone-600 border-stone-200 dark:bg-neutral-800 dark:text-neutral-300 dark:border-neutral-700',
|
||||
};
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center rounded-full border px-1.5 py-0.5 text-[9px] font-semibold uppercase tracking-wider ${entry.colors}`}>
|
||||
{entry.label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -58,27 +99,30 @@ function SkillScopeBadge({ scope }: { scope: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
function SourceBadge({ sourceId }: { sourceId: string }) {
|
||||
return (
|
||||
<span className="inline-flex items-center rounded-full border border-stone-200 dark:border-neutral-700 bg-stone-50 dark:bg-neutral-800 px-1.5 py-0.5 text-[9px] font-medium text-stone-500 dark:text-neutral-400">
|
||||
{sourceId}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
interface SkillTileProps {
|
||||
skill: WorkflowSummary;
|
||||
onUninstall: () => void;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
function SkillTile({ skill, onUninstall }: SkillTileProps) {
|
||||
function SkillTile({ skill, onUninstall, onClick }: SkillTileProps) {
|
||||
const { t } = useT();
|
||||
const canUninstall = skill.scope === 'user';
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid={`skill-explorer-tile-${skill.id}`}
|
||||
className="group flex flex-col justify-between rounded-2xl border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 p-3 transition-colors hover:bg-stone-50 dark:hover:bg-neutral-800/60">
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={onClick}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter') onClick();
|
||||
if (e.key === ' ' || e.key === 'Space') {
|
||||
e.preventDefault();
|
||||
onClick();
|
||||
}
|
||||
}}
|
||||
className="group flex flex-col justify-between rounded-2xl border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 p-3 transition-colors cursor-pointer hover:bg-stone-50 dark:hover:bg-neutral-800/60">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-xl bg-stone-100 dark:bg-neutral-800">
|
||||
@@ -163,14 +207,25 @@ interface CatalogTileProps {
|
||||
installed: boolean;
|
||||
installing: boolean;
|
||||
onInstall: () => void;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
function CatalogTile({ entry, installed, installing, onInstall }: CatalogTileProps) {
|
||||
function CatalogTile({ entry, installed, installing, onInstall, onClick }: CatalogTileProps) {
|
||||
const { t } = useT();
|
||||
return (
|
||||
<div
|
||||
data-testid={`registry-tile-${entry.id}`}
|
||||
className={`group flex flex-col justify-between rounded-2xl border p-3 transition-colors ${
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={onClick}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter') onClick();
|
||||
if (e.key === ' ' || e.key === 'Space') {
|
||||
e.preventDefault();
|
||||
onClick();
|
||||
}
|
||||
}}
|
||||
className={`group flex flex-col justify-between rounded-2xl border p-3 transition-colors cursor-pointer ${
|
||||
installed
|
||||
? 'border-sage-300 bg-sage-50/60 dark:border-sage-500/30 dark:bg-sage-500/10'
|
||||
: 'border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 hover:bg-stone-50 dark:hover:bg-neutral-800/60'
|
||||
@@ -192,8 +247,7 @@ function CatalogTile({ entry, installed, installing, onInstall }: CatalogTilePro
|
||||
</svg>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<SkillFormatBadge format={entry.format} />
|
||||
<SourceBadge sourceId={entry.source_id} />
|
||||
<SourceBadge source={entry.source} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -225,14 +279,7 @@ function CatalogTile({ entry, installed, installing, onInstall }: CatalogTilePro
|
||||
</span>
|
||||
)}
|
||||
{entry.author && (
|
||||
<span className="text-[10px] text-stone-400 dark:text-neutral-500">
|
||||
{entry.author}
|
||||
</span>
|
||||
)}
|
||||
{entry.stars != null && entry.stars > 0 && (
|
||||
<span className="text-[10px] text-stone-400 dark:text-neutral-500">
|
||||
{entry.stars}
|
||||
</span>
|
||||
<span className="text-[10px] text-stone-400 dark:text-neutral-500">{entry.author}</span>
|
||||
)}
|
||||
</div>
|
||||
{installed ? (
|
||||
@@ -257,6 +304,161 @@ function CatalogTile({ entry, installed, installing, onInstall }: CatalogTilePro
|
||||
);
|
||||
}
|
||||
|
||||
interface SkillDetailDialogProps {
|
||||
entry: CatalogEntry | null;
|
||||
skill: WorkflowSummary | null;
|
||||
installed: boolean;
|
||||
onClose: () => void;
|
||||
onInstall?: () => void;
|
||||
installing?: boolean;
|
||||
}
|
||||
|
||||
function SkillDetailDialog({
|
||||
entry,
|
||||
skill,
|
||||
installed,
|
||||
onClose,
|
||||
onInstall,
|
||||
installing,
|
||||
}: SkillDetailDialogProps) {
|
||||
const { t } = useT();
|
||||
const name = entry?.name ?? skill?.name ?? '';
|
||||
const description = entry?.description ?? skill?.description ?? '';
|
||||
const tags = entry?.tags ?? skill?.tags ?? [];
|
||||
const version = entry?.version ?? skill?.version ?? '';
|
||||
const author = entry?.author ?? '';
|
||||
const source = entry?.source ?? '';
|
||||
const category = entry?.category ?? '';
|
||||
const downloadUrl = entry?.download_url ?? '';
|
||||
const license = entry?.license ?? '';
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm"
|
||||
onClick={onClose}>
|
||||
<div
|
||||
className="mx-4 w-full max-w-lg rounded-2xl border border-stone-200 dark:border-neutral-700 bg-white dark:bg-neutral-900 shadow-xl"
|
||||
onClick={e => e.stopPropagation()}>
|
||||
<div className="flex items-start justify-between gap-3 border-b border-stone-100 dark:border-neutral-800 p-5">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-base font-semibold text-stone-900 dark:text-neutral-100 truncate">
|
||||
{name}
|
||||
</h2>
|
||||
{installed && (
|
||||
<span className="flex-shrink-0 rounded-full border border-sage-200 dark:border-sage-500/30 bg-sage-50 dark:bg-sage-500/10 px-2 py-0.5 text-[10px] font-medium text-sage-700 dark:text-sage-300">
|
||||
{t('skills.explorer.installed')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-1.5 flex items-center gap-1.5">
|
||||
{source && <SourceBadge source={source} />}
|
||||
{category && (
|
||||
<span className="inline-flex items-center rounded-full border border-stone-200 dark:border-neutral-700 bg-stone-50 dark:bg-neutral-800 px-1.5 py-0.5 text-[9px] font-medium text-stone-500 dark:text-neutral-400">
|
||||
{category}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="flex-shrink-0 rounded-lg p-1 text-stone-400 dark:text-neutral-500 hover:text-stone-600 dark:hover:text-neutral-300 hover:bg-stone-100 dark:hover:bg-neutral-800 transition-colors">
|
||||
<svg
|
||||
className="h-5 w-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18 18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-5 space-y-4">
|
||||
{description && (
|
||||
<div>
|
||||
<h3 className="text-[11px] font-semibold uppercase tracking-wider text-stone-400 dark:text-neutral-500 mb-1">
|
||||
{t('skills.detail.description')}
|
||||
</h3>
|
||||
<p className="text-sm text-stone-700 dark:text-neutral-300 leading-relaxed whitespace-pre-wrap">
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap gap-x-6 gap-y-2">
|
||||
{version && (
|
||||
<div>
|
||||
<span className="text-[10px] font-semibold uppercase tracking-wider text-stone-400 dark:text-neutral-500">
|
||||
{t('skills.detail.version')}
|
||||
</span>
|
||||
<p className="text-xs font-mono text-stone-700 dark:text-neutral-300">{version}</p>
|
||||
</div>
|
||||
)}
|
||||
{author && (
|
||||
<div>
|
||||
<span className="text-[10px] font-semibold uppercase tracking-wider text-stone-400 dark:text-neutral-500">
|
||||
{t('skills.detail.author')}
|
||||
</span>
|
||||
<p className="text-xs text-stone-700 dark:text-neutral-300">{author}</p>
|
||||
</div>
|
||||
)}
|
||||
{license && (
|
||||
<div>
|
||||
<span className="text-[10px] font-semibold uppercase tracking-wider text-stone-400 dark:text-neutral-500">
|
||||
{t('skills.detail.license')}
|
||||
</span>
|
||||
<p className="text-xs text-stone-700 dark:text-neutral-300">{license}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{tags.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-[11px] font-semibold uppercase tracking-wider text-stone-400 dark:text-neutral-500 mb-1.5">
|
||||
{t('skills.detail.tags')}
|
||||
</h3>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{tags.map(tag => (
|
||||
<span
|
||||
key={tag}
|
||||
className="rounded-full bg-stone-100 dark:bg-neutral-800 px-2 py-0.5 text-[10px] font-medium text-stone-600 dark:text-neutral-400">
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{downloadUrl && (
|
||||
<div>
|
||||
<h3 className="text-[11px] font-semibold uppercase tracking-wider text-stone-400 dark:text-neutral-500 mb-1">
|
||||
{t('skills.detail.source')}
|
||||
</h3>
|
||||
<p className="text-[11px] font-mono text-stone-400 dark:text-neutral-500 break-all">
|
||||
{downloadUrl}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!installed && onInstall && (
|
||||
<div className="border-t border-stone-100 dark:border-neutral-800 p-4 flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
disabled={installing}
|
||||
onClick={onInstall}
|
||||
className="rounded-lg border border-primary-200 dark:border-primary-500/30 bg-primary-50 dark:bg-primary-500/10 px-4 py-2 text-xs font-medium text-primary-700 dark:text-primary-300 transition-colors hover:bg-primary-100 dark:hover:bg-primary-500/20 disabled:opacity-50">
|
||||
{installing ? t('skills.explorer.installing') : t('skills.explorer.install')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type ExplorerView = 'installed' | 'registry';
|
||||
|
||||
interface SkillsExplorerTabProps {
|
||||
@@ -271,15 +473,34 @@ export default function SkillsExplorerTab({ onToast }: SkillsExplorerTabProps) {
|
||||
const [skillsLoading, setSkillsLoading] = useState(true);
|
||||
const [skillsError, setSkillsError] = useState<string | null>(null);
|
||||
|
||||
const [catalog, setCatalog] = useState<CatalogEntry[]>([]);
|
||||
const [catalogEntries, setCatalogEntries] = useState<CatalogEntry[]>([]);
|
||||
const [catalogTotal, setCatalogTotal] = useState(0);
|
||||
const [catalogLoading, setCatalogLoading] = useState(false);
|
||||
const [catalogError, setCatalogError] = useState<string | null>(null);
|
||||
const [catalogInitialized, setCatalogInitialized] = useState(false);
|
||||
const [installingId, setInstallingId] = useState<string | null>(null);
|
||||
|
||||
const [sources, setSources] = useState<string[]>([]);
|
||||
const [activeSources, setActiveSources] = useState<Set<string>>(new Set());
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [formatFilter, setFormatFilter] = useState<string>('all');
|
||||
const [debouncedQuery, setDebouncedQuery] = useState('');
|
||||
const [installDialogOpen, setInstallDialogOpen] = useState(false);
|
||||
const [uninstallTarget, setUninstallTarget] = useState<WorkflowSummary | null>(null);
|
||||
const [detailEntry, setDetailEntry] = useState<CatalogEntry | null>(null);
|
||||
const [detailSkill, setDetailSkill] = useState<WorkflowSummary | null>(null);
|
||||
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
// Debounce search input
|
||||
useEffect(() => {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
debounceRef.current = setTimeout(() => {
|
||||
setDebouncedQuery(searchQuery);
|
||||
}, SEARCH_DEBOUNCE_MS);
|
||||
return () => {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
};
|
||||
}, [searchQuery]);
|
||||
|
||||
const fetchSkills = useCallback(async () => {
|
||||
log('fetchSkills: start');
|
||||
@@ -298,32 +519,63 @@ export default function SkillsExplorerTab({ onToast }: SkillsExplorerTabProps) {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fetchCatalog = useCallback(async (forceRefresh = false) => {
|
||||
log('fetchCatalog: forceRefresh=%s', forceRefresh);
|
||||
setCatalogLoading(true);
|
||||
setCatalogError(null);
|
||||
try {
|
||||
const entries = await skillRegistryApi.browse(forceRefresh);
|
||||
log('fetchCatalog: count=%d', entries.length);
|
||||
setCatalog(entries);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
log('fetchCatalog: error=%s', msg);
|
||||
setCatalogError(msg);
|
||||
} finally {
|
||||
setCatalogLoading(false);
|
||||
}
|
||||
}, []);
|
||||
// Compute the active source filter for RPC calls.
|
||||
// Only apply when the user has deselected at least one source.
|
||||
const activeSourceFilter = useMemo(() => {
|
||||
if (activeSources.size === 0 || activeSources.size >= sources.length) return undefined;
|
||||
// If exactly one source is active, pass it as the filter
|
||||
if (activeSources.size === 1) return [...activeSources][0];
|
||||
return undefined;
|
||||
}, [activeSources, sources.length]);
|
||||
|
||||
// Fetch catalog via RPC search (handles both browse and search).
|
||||
// When query is empty and no source filter, uses browse; otherwise uses search.
|
||||
const fetchCatalog = useCallback(
|
||||
async (query: string, sourceFilter: string | undefined, forceRefresh: boolean) => {
|
||||
log('fetchCatalog: query=%s source=%s forceRefresh=%s', query, sourceFilter, forceRefresh);
|
||||
setCatalogLoading(true);
|
||||
setCatalogError(null);
|
||||
try {
|
||||
let entries: CatalogEntry[];
|
||||
if (!query && !sourceFilter && !forceRefresh) {
|
||||
entries = await skillRegistryApi.browse(false);
|
||||
} else if (!query && !sourceFilter && forceRefresh) {
|
||||
entries = await skillRegistryApi.browse(true);
|
||||
} else {
|
||||
entries = await skillRegistryApi.search(query || '', sourceFilter);
|
||||
}
|
||||
log('fetchCatalog: total=%d', entries.length);
|
||||
setCatalogTotal(entries.length);
|
||||
setCatalogEntries(entries.slice(0, CATALOG_PAGE_SIZE));
|
||||
setCatalogInitialized(true);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
log('fetchCatalog: error=%s', msg);
|
||||
setCatalogError(msg);
|
||||
} finally {
|
||||
setCatalogLoading(false);
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
void fetchSkills();
|
||||
skillRegistryApi
|
||||
.sources()
|
||||
.then(s => {
|
||||
setSources(s);
|
||||
setActiveSources(new Set(s));
|
||||
})
|
||||
.catch(() => {});
|
||||
}, [fetchSkills]);
|
||||
|
||||
// Trigger catalog search when debounced query or source filter changes
|
||||
useEffect(() => {
|
||||
if (view === 'registry' && catalog.length === 0 && !catalogLoading) {
|
||||
void fetchCatalog();
|
||||
if (view === 'registry') {
|
||||
void fetchCatalog(debouncedQuery, activeSourceFilter, false);
|
||||
}
|
||||
}, [view, catalog.length, catalogLoading, fetchCatalog]);
|
||||
}, [view, debouncedQuery, activeSourceFilter, fetchCatalog]);
|
||||
|
||||
const installedIds = useMemo(() => new Set(skills.map(s => s.id)), [skills]);
|
||||
|
||||
@@ -347,25 +599,18 @@ export default function SkillsExplorerTab({ onToast }: SkillsExplorerTabProps) {
|
||||
});
|
||||
}, [filteredSkills]);
|
||||
|
||||
const filteredCatalog = useMemo(() => {
|
||||
const q = searchQuery.toLowerCase().trim();
|
||||
return catalog.filter(entry => {
|
||||
if (formatFilter !== 'all' && entry.format !== formatFilter) return false;
|
||||
if (!q) return true;
|
||||
return (
|
||||
entry.name.toLowerCase().includes(q) ||
|
||||
entry.description.toLowerCase().includes(q) ||
|
||||
entry.tags.some(tag => tag.toLowerCase().includes(q)) ||
|
||||
entry.format.toLowerCase().includes(q) ||
|
||||
(entry.author ?? '').toLowerCase().includes(q)
|
||||
);
|
||||
});
|
||||
}, [catalog, searchQuery, formatFilter]);
|
||||
|
||||
const catalogFormats = useMemo(() => {
|
||||
const formats = new Set(catalog.map(e => e.format));
|
||||
return ['all', ...Array.from(formats).sort()];
|
||||
}, [catalog]);
|
||||
// When multiple sources are active (but not all), do client-side filtering
|
||||
// on the already-fetched results since the RPC only supports single source filter.
|
||||
const displayedCatalog = useMemo(() => {
|
||||
if (
|
||||
activeSources.size === 0 ||
|
||||
activeSources.size >= sources.length ||
|
||||
activeSources.size === 1
|
||||
) {
|
||||
return catalogEntries;
|
||||
}
|
||||
return catalogEntries.filter(e => activeSources.has(e.source));
|
||||
}, [catalogEntries, activeSources, sources.length]);
|
||||
|
||||
const handleInstalled = useCallback(
|
||||
(result: InstallWorkflowFromUrlResult) => {
|
||||
@@ -388,32 +633,25 @@ export default function SkillsExplorerTab({ onToast }: SkillsExplorerTabProps) {
|
||||
const handleUninstalled = useCallback(() => {
|
||||
log('handleUninstalled');
|
||||
void fetchSkills();
|
||||
onToast?.({
|
||||
type: 'success',
|
||||
title: t('skills.explorer.uninstallSuccess'),
|
||||
});
|
||||
onToast?.({ type: 'success', title: t('skills.explorer.uninstallSuccess') });
|
||||
}, [fetchSkills, onToast, t]);
|
||||
|
||||
const handleRegistryInstall = useCallback(
|
||||
async (entry: CatalogEntry) => {
|
||||
log('handleRegistryInstall: id=%s source=%s', entry.id, entry.source_id);
|
||||
log('handleRegistryInstall: id=%s source=%s', entry.id, entry.source);
|
||||
setInstallingId(entry.id);
|
||||
try {
|
||||
const result = await skillRegistryApi.install(entry.id, entry.source_id);
|
||||
const result = await skillRegistryApi.install(entry.id);
|
||||
void fetchSkills();
|
||||
onToast?.({
|
||||
type: 'success',
|
||||
title: t('skills.install.installComplete'),
|
||||
message: `Installed ${entry.name}${result.new_skills.length > 0 ? ` (${result.new_skills.join(', ')})` : ''}`,
|
||||
message: `Installed ${entry.name}${result.newSkills.length > 0 ? ` (${result.newSkills.join(', ')})` : ''}`,
|
||||
});
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
log('handleRegistryInstall: error=%s', msg);
|
||||
onToast?.({
|
||||
type: 'error',
|
||||
title: t('skills.install.errors.genericTitle'),
|
||||
message: msg,
|
||||
});
|
||||
onToast?.({ type: 'error', title: t('skills.install.errors.genericTitle'), message: msg });
|
||||
} finally {
|
||||
setInstallingId(null);
|
||||
}
|
||||
@@ -457,8 +695,8 @@ export default function SkillsExplorerTab({ onToast }: SkillsExplorerTabProps) {
|
||||
: 'border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 text-stone-600 dark:text-neutral-300 hover:bg-stone-50 dark:hover:bg-neutral-800/60'
|
||||
}`}>
|
||||
{t('skills.explorer.registryTab')}
|
||||
{catalog.length > 0 && (
|
||||
<span className="ml-1.5 text-[10px] opacity-70">{catalog.length}</span>
|
||||
{catalogTotal > 0 && (
|
||||
<span className="ml-1.5 text-[10px] opacity-70">{catalogTotal.toLocaleString()}</span>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
@@ -476,7 +714,36 @@ export default function SkillsExplorerTab({ onToast }: SkillsExplorerTabProps) {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Search + format filter */}
|
||||
{/* Source toggles */}
|
||||
{view === 'registry' && sources.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5 px-1 pb-3">
|
||||
{sources.map(src => {
|
||||
const active = activeSources.has(src);
|
||||
return (
|
||||
<button
|
||||
key={src}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setActiveSources(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(src)) next.delete(src);
|
||||
else next.add(src);
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
className={`rounded-full border px-2.5 py-1 text-[10px] font-medium transition-colors ${
|
||||
active
|
||||
? 'border-primary-300 dark:border-primary-500/50 bg-primary-100 dark:bg-primary-500/20 text-primary-700 dark:text-primary-300'
|
||||
: 'border-stone-200 dark:border-neutral-700 bg-stone-50 dark:bg-neutral-800 text-stone-400 dark:text-neutral-500 hover:text-stone-600 dark:hover:text-neutral-300'
|
||||
}`}>
|
||||
{src}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Search */}
|
||||
<div className="flex gap-2 px-1 pb-3">
|
||||
<div className="relative flex-1">
|
||||
<svg
|
||||
@@ -493,28 +760,17 @@ export default function SkillsExplorerTab({ onToast }: SkillsExplorerTabProps) {
|
||||
</svg>
|
||||
<input
|
||||
type="text"
|
||||
data-testid="skill-search-input"
|
||||
value={searchQuery}
|
||||
onChange={e => setSearchQuery(e.target.value)}
|
||||
placeholder={t('skills.explorer.searchPlaceholder')}
|
||||
className="w-full rounded-lg border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 py-2 pl-9 pr-3 text-xs text-stone-900 dark:text-neutral-100 placeholder:text-stone-400 dark:placeholder:text-neutral-500 shadow-sm transition-colors focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/30"
|
||||
/>
|
||||
</div>
|
||||
{view === 'registry' && catalogFormats.length > 2 && (
|
||||
<select
|
||||
value={formatFilter}
|
||||
onChange={e => setFormatFilter(e.target.value)}
|
||||
className="rounded-lg border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 px-2 py-2 text-xs text-stone-700 dark:text-neutral-200 shadow-sm focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/30">
|
||||
{catalogFormats.map(f => (
|
||||
<option key={f} value={f}>
|
||||
{f === 'all' ? t('skills.explorer.allFormats') : f}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
{view === 'registry' && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void fetchCatalog(true)}
|
||||
onClick={() => void fetchCatalog(debouncedQuery, activeSourceFilter, true)}
|
||||
disabled={catalogLoading}
|
||||
title={t('skills.explorer.refreshRegistry')}
|
||||
className="flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-lg border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 text-stone-500 dark:text-neutral-400 shadow-sm transition-colors hover:bg-stone-50 dark:hover:bg-neutral-800 disabled:opacity-50">
|
||||
@@ -548,7 +804,9 @@ export default function SkillsExplorerTab({ onToast }: SkillsExplorerTabProps) {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
void (view === 'installed' ? fetchSkills() : fetchCatalog(true))
|
||||
void (view === 'installed'
|
||||
? fetchSkills()
|
||||
: fetchCatalog(debouncedQuery, activeSourceFilter, true))
|
||||
}
|
||||
className="mt-2 rounded-lg border border-coral-200 dark:border-coral-500/30 px-3 py-1 text-[11px] font-medium text-coral-700 dark:text-coral-300 hover:bg-coral-100 dark:hover:bg-coral-500/20">
|
||||
{t('common.retry')}
|
||||
@@ -597,6 +855,7 @@ export default function SkillsExplorerTab({ onToast }: SkillsExplorerTabProps) {
|
||||
<SkillTile
|
||||
key={skill.id}
|
||||
skill={skill}
|
||||
onClick={() => setDetailSkill(skill)}
|
||||
onUninstall={() => setUninstallTarget(skill)}
|
||||
/>
|
||||
))}
|
||||
@@ -608,7 +867,7 @@ export default function SkillsExplorerTab({ onToast }: SkillsExplorerTabProps) {
|
||||
{/* ── Registry view ── */}
|
||||
{view === 'registry' && !loading && !error && (
|
||||
<>
|
||||
{catalog.length === 0 && (
|
||||
{catalogInitialized && catalogEntries.length === 0 && (
|
||||
<EmptyStateCard
|
||||
className="mx-1 mb-3 py-10"
|
||||
icon={
|
||||
@@ -625,33 +884,40 @@ export default function SkillsExplorerTab({ onToast }: SkillsExplorerTabProps) {
|
||||
/>
|
||||
</svg>
|
||||
}
|
||||
title={t('skills.explorer.registryEmptyTitle')}
|
||||
description={t('skills.explorer.registryEmptyDescription')}
|
||||
actionLabel={t('skills.explorer.refreshRegistry')}
|
||||
onAction={() => void fetchCatalog(true)}
|
||||
title={
|
||||
debouncedQuery ? t('skills.noResults') : t('skills.explorer.registryEmptyTitle')
|
||||
}
|
||||
description={debouncedQuery ? '' : t('skills.explorer.registryEmptyDescription')}
|
||||
actionLabel={debouncedQuery ? undefined : t('skills.explorer.refreshRegistry')}
|
||||
onAction={debouncedQuery ? undefined : () => void fetchCatalog('', undefined, true)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{catalog.length > 0 && filteredCatalog.length === 0 && (
|
||||
<p className="px-1 py-8 text-center text-xs text-stone-400 dark:text-neutral-500">
|
||||
{t('skills.noResults')}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{filteredCatalog.length > 0 && (
|
||||
<div
|
||||
className="grid gap-2 sm:gap-3"
|
||||
style={{ gridTemplateColumns: 'repeat(auto-fill, minmax(14rem, 1fr))' }}>
|
||||
{filteredCatalog.map(entry => (
|
||||
<CatalogTile
|
||||
key={`${entry.source_id}-${entry.id}`}
|
||||
entry={entry}
|
||||
installed={installedIds.has(entry.id)}
|
||||
installing={installingId === entry.id}
|
||||
onInstall={() => void handleRegistryInstall(entry)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{displayedCatalog.length > 0 && (
|
||||
<>
|
||||
<div
|
||||
className="grid gap-2 sm:gap-3"
|
||||
style={{ gridTemplateColumns: 'repeat(auto-fill, minmax(14rem, 1fr))' }}>
|
||||
{displayedCatalog.map(entry => (
|
||||
<CatalogTile
|
||||
key={`${entry.source}-${entry.id}`}
|
||||
entry={entry}
|
||||
installed={installedIds.has(entry.id)}
|
||||
installing={installingId === entry.id}
|
||||
onClick={() => setDetailEntry(entry)}
|
||||
onInstall={() => void handleRegistryInstall(entry)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{catalogTotal > displayedCatalog.length && (
|
||||
<p className="mt-3 px-1 text-center text-[11px] text-stone-400 dark:text-neutral-500">
|
||||
{t('skills.explorer.showingOf')
|
||||
.replace('{shown}', String(displayedCatalog.length))
|
||||
.replace('{total}', catalogTotal.toLocaleString()) ||
|
||||
`Showing ${displayedCatalog.length} of ${catalogTotal.toLocaleString()} results. Refine your search to see more.`}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
@@ -670,6 +936,27 @@ export default function SkillsExplorerTab({ onToast }: SkillsExplorerTabProps) {
|
||||
onUninstalled={handleUninstalled}
|
||||
/>
|
||||
)}
|
||||
|
||||
{(detailEntry || detailSkill) && (
|
||||
<SkillDetailDialog
|
||||
entry={detailEntry}
|
||||
skill={detailSkill}
|
||||
installed={detailEntry ? installedIds.has(detailEntry.id) : true}
|
||||
onClose={() => {
|
||||
setDetailEntry(null);
|
||||
setDetailSkill(null);
|
||||
}}
|
||||
onInstall={
|
||||
detailEntry && !installedIds.has(detailEntry.id)
|
||||
? () => {
|
||||
void handleRegistryInstall(detailEntry);
|
||||
setDetailEntry(null);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
installing={detailEntry ? installingId === detailEntry.id : false}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
*
|
||||
* Small centered confirm modal for destructive uninstall of a user-scope
|
||||
* SKILL.md skill. Wraps `workflowsApi.uninstallWorkflow` which calls
|
||||
* `openhuman.workflows_uninstall` on the Rust side — that RPC only accepts
|
||||
* `openhuman.skill_registry_uninstall` on the Rust side — that RPC only accepts
|
||||
* user-scope installs (`~/.openhuman/skills/<name>/`) and refuses project
|
||||
* and legacy scopes. The card that opens this dialog is responsible for
|
||||
* not surfacing the Uninstall action for non-user-scope entries.
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -18,6 +18,7 @@ vi.mock('../../../services/api/skillRegistryApi', () => ({
|
||||
browse: vi.fn(),
|
||||
search: vi.fn(),
|
||||
sources: vi.fn(),
|
||||
categories: vi.fn(),
|
||||
install: vi.fn(),
|
||||
},
|
||||
}));
|
||||
@@ -53,14 +54,27 @@ const MOCK_CATALOG_ENTRY: CatalogEntry = {
|
||||
id: 'registry-skill-1',
|
||||
name: 'Registry Skill',
|
||||
description: 'A skill from the registry',
|
||||
format: 'hermes',
|
||||
source: 'built-in',
|
||||
category: 'productivity',
|
||||
author: 'Registry Author',
|
||||
version: '2.0.0',
|
||||
tags: ['registry', 'remote'],
|
||||
platforms: [],
|
||||
download_url: 'https://example.com/SKILL.md',
|
||||
source_id: 'openhuman-community',
|
||||
stars: 42,
|
||||
updated_at: '2026-01-01',
|
||||
docs_path: null,
|
||||
commands: [],
|
||||
env_vars: [],
|
||||
license: 'MIT',
|
||||
};
|
||||
|
||||
const MOCK_DOCKER_ENTRY: CatalogEntry = {
|
||||
...MOCK_CATALOG_ENTRY,
|
||||
id: 'docker-manager',
|
||||
name: 'Docker Manager',
|
||||
description: 'Manage Docker containers and images',
|
||||
source: 'skills.sh',
|
||||
category: 'devops',
|
||||
tags: ['docker', 'containers'],
|
||||
};
|
||||
|
||||
async function switchToInstalled() {
|
||||
@@ -70,6 +84,27 @@ async function switchToInstalled() {
|
||||
});
|
||||
}
|
||||
|
||||
const MOCK_LEGACY_SKILL: WorkflowSummary = {
|
||||
...MOCK_SKILL,
|
||||
id: 'legacy-skill',
|
||||
name: 'Legacy Skill',
|
||||
scope: 'user',
|
||||
legacy: true,
|
||||
sourceFormat: 'legacy',
|
||||
};
|
||||
|
||||
const MOCK_CATALOG_ENTRY_WITH_META: CatalogEntry = {
|
||||
...MOCK_CATALOG_ENTRY,
|
||||
id: 'full-meta-skill',
|
||||
name: 'Full Meta Skill',
|
||||
source: 'ClawHub',
|
||||
version: '3.1.0',
|
||||
author: 'Meta Author',
|
||||
license: 'Apache-2.0',
|
||||
tags: ['tag1', 'tag2', 'tag3'],
|
||||
download_url: 'https://clawhub.io/skills/full-meta',
|
||||
};
|
||||
|
||||
describe('SkillsExplorerTab', () => {
|
||||
beforeEach(async () => {
|
||||
const { workflowsApi } = await import('../../../services/api/workflowsApi');
|
||||
@@ -79,8 +114,12 @@ describe('SkillsExplorerTab', () => {
|
||||
vi.mocked(workflowsApi.listWorkflows).mockReset();
|
||||
vi.mocked(workflowsApi.uninstallWorkflow).mockReset();
|
||||
vi.mocked(skillRegistryApi.browse).mockReset();
|
||||
vi.mocked(skillRegistryApi.search).mockReset();
|
||||
vi.mocked(skillRegistryApi.install).mockReset();
|
||||
vi.mocked(skillRegistryApi.sources).mockReset();
|
||||
vi.mocked(skillRegistryApi.browse).mockResolvedValue([]);
|
||||
vi.mocked(skillRegistryApi.search).mockResolvedValue([]);
|
||||
vi.mocked(skillRegistryApi.sources).mockResolvedValue([]);
|
||||
});
|
||||
|
||||
it('defaults to registry view and shows catalog entries', async () => {
|
||||
@@ -96,8 +135,37 @@ describe('SkillsExplorerTab', () => {
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Registry Skill')).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByText('Hermes')).toBeInTheDocument();
|
||||
expect(screen.getByText('openhuman-community')).toBeInTheDocument();
|
||||
expect(screen.getByText('built-in')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('searches catalog via RPC when typing in search box', async () => {
|
||||
const { workflowsApi } = await import('../../../services/api/workflowsApi');
|
||||
const { skillRegistryApi } = await import(
|
||||
'../../../services/api/skillRegistryApi'
|
||||
);
|
||||
vi.mocked(workflowsApi.listWorkflows).mockResolvedValue([]);
|
||||
vi.mocked(skillRegistryApi.browse).mockResolvedValue([MOCK_CATALOG_ENTRY]);
|
||||
vi.mocked(skillRegistryApi.search).mockResolvedValue([MOCK_DOCKER_ENTRY]);
|
||||
|
||||
render(<SkillsExplorerTab />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Registry Skill')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const searchInput = screen.getByTestId('skill-search-input');
|
||||
await act(async () => {
|
||||
fireEvent.change(searchInput, { target: { value: 'docker' } });
|
||||
});
|
||||
|
||||
// Wait for the debounce to fire and the RPC search to be called
|
||||
await waitFor(() => {
|
||||
expect(skillRegistryApi.search).toHaveBeenCalledWith('docker', undefined);
|
||||
}, { timeout: 2000 });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Docker Manager')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows installed skills when switching to installed tab', async () => {
|
||||
@@ -264,4 +332,479 @@ describe('SkillsExplorerTab', () => {
|
||||
'Install from URL'
|
||||
);
|
||||
});
|
||||
|
||||
it('shows "no results" when installed skills exist but search has no matches', async () => {
|
||||
const { workflowsApi } = await import('../../../services/api/workflowsApi');
|
||||
vi.mocked(workflowsApi.listWorkflows).mockResolvedValue([MOCK_SKILL]);
|
||||
|
||||
render(<SkillsExplorerTab />);
|
||||
await switchToInstalled();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Test Skill')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const searchInput = screen.getByPlaceholderText('Search skills...');
|
||||
fireEvent.change(searchInput, { target: { value: 'xyznotfound999' } });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Test Skill')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('opens skill detail dialog when a skill tile is clicked', async () => {
|
||||
const { workflowsApi } = await import('../../../services/api/workflowsApi');
|
||||
vi.mocked(workflowsApi.listWorkflows).mockResolvedValue([MOCK_SKILL]);
|
||||
|
||||
render(<SkillsExplorerTab />);
|
||||
await switchToInstalled();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('skill-explorer-tile-test-skill')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByTestId('skill-explorer-tile-test-skill'));
|
||||
});
|
||||
|
||||
// The detail dialog should appear with the skill name
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByText('Test Skill').length).toBeGreaterThan(1);
|
||||
});
|
||||
});
|
||||
|
||||
it('activates skill tile on Enter key and Space key', async () => {
|
||||
const { workflowsApi } = await import('../../../services/api/workflowsApi');
|
||||
vi.mocked(workflowsApi.listWorkflows).mockResolvedValue([MOCK_SKILL]);
|
||||
|
||||
render(<SkillsExplorerTab />);
|
||||
await switchToInstalled();
|
||||
|
||||
const tile = await screen.findByTestId('skill-explorer-tile-test-skill');
|
||||
|
||||
// Enter opens detail
|
||||
await act(async () => {
|
||||
fireEvent.keyDown(tile, { key: 'Enter' });
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByText('Test Skill').length).toBeGreaterThan(1);
|
||||
});
|
||||
});
|
||||
|
||||
it('opens catalog entry detail dialog when a registry tile is clicked', async () => {
|
||||
const { workflowsApi } = await import('../../../services/api/workflowsApi');
|
||||
const { skillRegistryApi } = await import('../../../services/api/skillRegistryApi');
|
||||
vi.mocked(workflowsApi.listWorkflows).mockResolvedValue([]);
|
||||
vi.mocked(skillRegistryApi.browse).mockResolvedValue([MOCK_CATALOG_ENTRY_WITH_META]);
|
||||
|
||||
render(<SkillsExplorerTab />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('registry-tile-full-meta-skill')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByTestId('registry-tile-full-meta-skill'));
|
||||
});
|
||||
|
||||
// Detail dialog shows the entry's name and metadata
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByText('Full Meta Skill').length).toBeGreaterThan(1);
|
||||
});
|
||||
// Should show version, author, license
|
||||
expect(screen.getByText('v3.1.0')).toBeInTheDocument();
|
||||
expect(screen.getAllByText('Meta Author').length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getByText('Apache-2.0')).toBeInTheDocument();
|
||||
// Download URL
|
||||
expect(screen.getByText('https://clawhub.io/skills/full-meta')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('closes skill detail dialog when overlay is clicked', async () => {
|
||||
const { workflowsApi } = await import('../../../services/api/workflowsApi');
|
||||
vi.mocked(workflowsApi.listWorkflows).mockResolvedValue([MOCK_SKILL]);
|
||||
|
||||
render(<SkillsExplorerTab />);
|
||||
await switchToInstalled();
|
||||
|
||||
const tile = await screen.findByTestId('skill-explorer-tile-test-skill');
|
||||
await act(async () => {
|
||||
fireEvent.click(tile);
|
||||
});
|
||||
|
||||
// Dialog open — find the backdrop and click it
|
||||
await waitFor(() => {
|
||||
// The close button (×) should be visible
|
||||
expect(screen.getByRole('button', { name: '' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Click the ×-close button inside the dialog header
|
||||
const closeBtns = screen.getAllByRole('button');
|
||||
const closeBtn = closeBtns.find(b => {
|
||||
const svg = b.querySelector('svg');
|
||||
return svg !== null && b.closest('[class*="fixed inset-0"]') !== null;
|
||||
});
|
||||
if (closeBtn) {
|
||||
await act(async () => {
|
||||
fireEvent.click(closeBtn);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('shows install button in detail dialog footer for non-installed registry entry', async () => {
|
||||
const { workflowsApi } = await import('../../../services/api/workflowsApi');
|
||||
const { skillRegistryApi } = await import('../../../services/api/skillRegistryApi');
|
||||
vi.mocked(workflowsApi.listWorkflows).mockResolvedValue([]);
|
||||
vi.mocked(skillRegistryApi.browse).mockResolvedValue([MOCK_CATALOG_ENTRY]);
|
||||
|
||||
render(<SkillsExplorerTab />);
|
||||
|
||||
// Click tile to open detail dialog
|
||||
const tile = await screen.findByTestId('registry-tile-registry-skill-1');
|
||||
await act(async () => {
|
||||
fireEvent.click(tile);
|
||||
});
|
||||
|
||||
// The dialog should show the skill name (header) and description
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByText('Registry Skill').length).toBeGreaterThan(1);
|
||||
});
|
||||
// The detail dialog shows a second install button in the footer (in addition to the tile's button)
|
||||
const installBtns = screen.getAllByRole('button', { name: 'Install' });
|
||||
expect(installBtns.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
it('calls skillRegistryApi.install when the install button in registry tile is clicked', async () => {
|
||||
const { workflowsApi } = await import('../../../services/api/workflowsApi');
|
||||
const { skillRegistryApi } = await import('../../../services/api/skillRegistryApi');
|
||||
const onToast = vi.fn();
|
||||
vi.mocked(workflowsApi.listWorkflows).mockResolvedValue([]);
|
||||
vi.mocked(skillRegistryApi.browse).mockResolvedValue([MOCK_CATALOG_ENTRY]);
|
||||
vi.mocked(skillRegistryApi.install).mockResolvedValue({
|
||||
url: 'https://example.com/SKILL.md',
|
||||
stdout: 'ok',
|
||||
stderr: '',
|
||||
newSkills: ['registry-skill-1'],
|
||||
});
|
||||
|
||||
render(<SkillsExplorerTab onToast={onToast} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('registry-install-registry-skill-1')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByTestId('registry-install-registry-skill-1'));
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(skillRegistryApi.install).toHaveBeenCalledWith('registry-skill-1');
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(onToast).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: 'success' })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('shows error toast when registry install fails', async () => {
|
||||
const { workflowsApi } = await import('../../../services/api/workflowsApi');
|
||||
const { skillRegistryApi } = await import('../../../services/api/skillRegistryApi');
|
||||
const onToast = vi.fn();
|
||||
vi.mocked(workflowsApi.listWorkflows).mockResolvedValue([]);
|
||||
vi.mocked(skillRegistryApi.browse).mockResolvedValue([MOCK_CATALOG_ENTRY]);
|
||||
vi.mocked(skillRegistryApi.install).mockRejectedValue(new Error('Install failed'));
|
||||
|
||||
render(<SkillsExplorerTab onToast={onToast} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('registry-install-registry-skill-1')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByTestId('registry-install-registry-skill-1'));
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onToast).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: 'error' })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('shows source toggle buttons when sources are available', async () => {
|
||||
const { workflowsApi } = await import('../../../services/api/workflowsApi');
|
||||
const { skillRegistryApi } = await import('../../../services/api/skillRegistryApi');
|
||||
vi.mocked(workflowsApi.listWorkflows).mockResolvedValue([]);
|
||||
vi.mocked(skillRegistryApi.sources).mockResolvedValue(['built-in', 'ClawHub']);
|
||||
// No catalog entries so "built-in" only appears in the toggle buttons
|
||||
vi.mocked(skillRegistryApi.browse).mockResolvedValue([]);
|
||||
|
||||
render(<SkillsExplorerTab />);
|
||||
|
||||
// Source toggles are rendered as buttons with their source name text
|
||||
await waitFor(() => {
|
||||
const buttons = screen.getAllByRole('button');
|
||||
const sourceButtons = buttons.filter(b => b.textContent === 'built-in' || b.textContent === 'ClawHub');
|
||||
expect(sourceButtons.length).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
it('deselecting a source filter triggers search with single active source', async () => {
|
||||
const { workflowsApi } = await import('../../../services/api/workflowsApi');
|
||||
const { skillRegistryApi } = await import('../../../services/api/skillRegistryApi');
|
||||
vi.mocked(workflowsApi.listWorkflows).mockResolvedValue([]);
|
||||
vi.mocked(skillRegistryApi.sources).mockResolvedValue(['built-in', 'ClawHub']);
|
||||
vi.mocked(skillRegistryApi.browse).mockResolvedValue([]);
|
||||
vi.mocked(skillRegistryApi.search).mockResolvedValue([]);
|
||||
|
||||
render(<SkillsExplorerTab />);
|
||||
|
||||
// Wait for sources to load and buttons to appear
|
||||
await waitFor(() => {
|
||||
const buttons = screen.getAllByRole('button');
|
||||
expect(buttons.some(b => b.textContent === 'ClawHub')).toBe(true);
|
||||
});
|
||||
|
||||
// Deselect 'ClawHub' — only 'built-in' remains active → triggers search with source filter
|
||||
const clawhubBtn = screen.getAllByRole('button').find(b => b.textContent === 'ClawHub')!;
|
||||
await act(async () => {
|
||||
fireEvent.click(clawhubBtn);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(skillRegistryApi.search).toHaveBeenCalledWith('', 'built-in');
|
||||
});
|
||||
});
|
||||
|
||||
it('shows catalog count in Registry tab badge when entries exist', async () => {
|
||||
const { workflowsApi } = await import('../../../services/api/workflowsApi');
|
||||
const { skillRegistryApi } = await import('../../../services/api/skillRegistryApi');
|
||||
vi.mocked(workflowsApi.listWorkflows).mockResolvedValue([]);
|
||||
vi.mocked(skillRegistryApi.browse).mockResolvedValue([MOCK_CATALOG_ENTRY, MOCK_DOCKER_ENTRY]);
|
||||
|
||||
render(<SkillsExplorerTab />);
|
||||
|
||||
await waitFor(() => {
|
||||
// The catalog count badge (2) should appear in the Registry tab
|
||||
expect(screen.getByText('2')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows installed skill count in Installed tab badge', async () => {
|
||||
const { workflowsApi } = await import('../../../services/api/workflowsApi');
|
||||
vi.mocked(workflowsApi.listWorkflows).mockResolvedValue([MOCK_SKILL, MOCK_PROJECT_SKILL]);
|
||||
|
||||
render(<SkillsExplorerTab />);
|
||||
|
||||
await waitFor(() => {
|
||||
// The skills count badge (2) should appear in the Installed tab
|
||||
expect(screen.getByText('2')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows legacy scope badge for legacy skills', async () => {
|
||||
const { workflowsApi } = await import('../../../services/api/workflowsApi');
|
||||
vi.mocked(workflowsApi.listWorkflows).mockResolvedValue([MOCK_LEGACY_SKILL]);
|
||||
|
||||
render(<SkillsExplorerTab />);
|
||||
await switchToInstalled();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Legacy Skill')).toBeInTheDocument();
|
||||
});
|
||||
// legacy scope badge should show
|
||||
expect(screen.getAllByText('Legacy').length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it('displays SkillFormatBadge with fallback label for unknown format', async () => {
|
||||
const { workflowsApi } = await import('../../../services/api/workflowsApi');
|
||||
const unknownFormatSkill = { ...MOCK_SKILL, id: 'unk-skill', name: 'Unknown Format Skill', sourceFormat: 'unknown-format' };
|
||||
vi.mocked(workflowsApi.listWorkflows).mockResolvedValue([unknownFormatSkill]);
|
||||
|
||||
render(<SkillsExplorerTab />);
|
||||
await switchToInstalled();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Unknown Format Skill')).toBeInTheDocument();
|
||||
});
|
||||
// The badge renders the raw format string for unknown formats
|
||||
expect(screen.getByText('unknown-format')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows empty registry state when catalog returns no results', async () => {
|
||||
const { workflowsApi } = await import('../../../services/api/workflowsApi');
|
||||
const { skillRegistryApi } = await import('../../../services/api/skillRegistryApi');
|
||||
vi.mocked(workflowsApi.listWorkflows).mockResolvedValue([]);
|
||||
vi.mocked(skillRegistryApi.browse).mockResolvedValue([]);
|
||||
|
||||
render(<SkillsExplorerTab />);
|
||||
|
||||
await waitFor(() => {
|
||||
// Empty registry state shows its title (i18n key: skills.explorer.registryEmptyTitle)
|
||||
expect(screen.getByText('No registry entries')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('retry button on error retriggers catalog fetch', async () => {
|
||||
const { workflowsApi } = await import('../../../services/api/workflowsApi');
|
||||
const { skillRegistryApi } = await import('../../../services/api/skillRegistryApi');
|
||||
vi.mocked(workflowsApi.listWorkflows).mockResolvedValue([]);
|
||||
vi.mocked(skillRegistryApi.browse)
|
||||
.mockRejectedValueOnce(new Error('timeout'))
|
||||
.mockResolvedValue([MOCK_CATALOG_ENTRY]);
|
||||
|
||||
render(<SkillsExplorerTab />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('timeout')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: /Try again/ }));
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Registry Skill')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('retry button on installed view error retriggers skills fetch', async () => {
|
||||
const { workflowsApi } = await import('../../../services/api/workflowsApi');
|
||||
vi.mocked(workflowsApi.listWorkflows)
|
||||
.mockRejectedValueOnce(new Error('skills fetch failed'))
|
||||
.mockResolvedValue([MOCK_SKILL]);
|
||||
|
||||
render(<SkillsExplorerTab />);
|
||||
await switchToInstalled();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('skills fetch failed')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: /Try again/ }));
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Test Skill')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('refresh button triggers force-refresh catalog fetch', async () => {
|
||||
const { workflowsApi } = await import('../../../services/api/workflowsApi');
|
||||
const { skillRegistryApi } = await import('../../../services/api/skillRegistryApi');
|
||||
vi.mocked(workflowsApi.listWorkflows).mockResolvedValue([]);
|
||||
vi.mocked(skillRegistryApi.browse).mockResolvedValue([MOCK_CATALOG_ENTRY]);
|
||||
|
||||
render(<SkillsExplorerTab />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Registry Skill')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const callsBefore = vi.mocked(skillRegistryApi.browse).mock.calls.length;
|
||||
|
||||
const refreshBtn = screen.getByTitle('Refresh registry');
|
||||
await act(async () => {
|
||||
fireEvent.click(refreshBtn);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(vi.mocked(skillRegistryApi.browse).mock.calls.length).toBeGreaterThan(callsBefore);
|
||||
});
|
||||
// The last browse call should use forceRefresh=true
|
||||
const calls = vi.mocked(skillRegistryApi.browse).mock.calls;
|
||||
const lastCall = calls[calls.length - 1];
|
||||
expect(lastCall[0]).toBe(true);
|
||||
});
|
||||
|
||||
it('sorts hermes skills before non-hermes in installed view', async () => {
|
||||
const { workflowsApi } = await import('../../../services/api/workflowsApi');
|
||||
const alphaSkill = {
|
||||
...MOCK_SKILL,
|
||||
id: 'alpha',
|
||||
name: 'Alpha Skill',
|
||||
sourceFormat: 'openhuman',
|
||||
};
|
||||
const hermesSkill = {
|
||||
...MOCK_SKILL,
|
||||
id: 'hermes',
|
||||
name: 'Hermes Skill',
|
||||
sourceFormat: 'hermes',
|
||||
};
|
||||
vi.mocked(workflowsApi.listWorkflows).mockResolvedValue([alphaSkill, hermesSkill]);
|
||||
|
||||
render(<SkillsExplorerTab />);
|
||||
await switchToInstalled();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Hermes Skill')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const allTiles = screen.getAllByRole('button').filter(
|
||||
el => el.getAttribute('data-testid')?.startsWith('skill-explorer-tile')
|
||||
);
|
||||
// Hermes should come first
|
||||
expect(allTiles[0]).toHaveAttribute('data-testid', 'skill-explorer-tile-hermes');
|
||||
expect(allTiles[1]).toHaveAttribute('data-testid', 'skill-explorer-tile-alpha');
|
||||
});
|
||||
|
||||
it('activates catalog tile on Enter key', async () => {
|
||||
const { workflowsApi } = await import('../../../services/api/workflowsApi');
|
||||
const { skillRegistryApi } = await import('../../../services/api/skillRegistryApi');
|
||||
vi.mocked(workflowsApi.listWorkflows).mockResolvedValue([]);
|
||||
vi.mocked(skillRegistryApi.browse).mockResolvedValue([MOCK_CATALOG_ENTRY]);
|
||||
|
||||
render(<SkillsExplorerTab />);
|
||||
|
||||
const tile = await screen.findByTestId('registry-tile-registry-skill-1');
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.keyDown(tile, { key: 'Enter' });
|
||||
});
|
||||
|
||||
// Detail dialog opens
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByText('Registry Skill').length).toBeGreaterThan(1);
|
||||
});
|
||||
});
|
||||
|
||||
it('detail dialog install button (footer) triggers install', async () => {
|
||||
const { workflowsApi } = await import('../../../services/api/workflowsApi');
|
||||
const { skillRegistryApi } = await import('../../../services/api/skillRegistryApi');
|
||||
const onToast = vi.fn();
|
||||
vi.mocked(workflowsApi.listWorkflows).mockResolvedValue([]);
|
||||
// Override the beforeEach mock so browse returns an entry
|
||||
vi.mocked(skillRegistryApi.browse).mockResolvedValue([MOCK_CATALOG_ENTRY]);
|
||||
vi.mocked(skillRegistryApi.install).mockResolvedValue({
|
||||
url: 'https://example.com/SKILL.md',
|
||||
stdout: 'ok',
|
||||
stderr: '',
|
||||
newSkills: [],
|
||||
});
|
||||
|
||||
render(<SkillsExplorerTab onToast={onToast} />);
|
||||
|
||||
// Wait for tile to appear, then open detail dialog
|
||||
const tile = await screen.findByTestId('registry-tile-registry-skill-1');
|
||||
await act(async () => {
|
||||
fireEvent.click(tile);
|
||||
});
|
||||
|
||||
// Wait for dialog to open (name appears twice — tile + dialog header)
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByText('Registry Skill').length).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
// The dialog footer has an extra Install button — click the last one (the footer button)
|
||||
const installBtns = screen.getAllByRole('button', { name: 'Install' });
|
||||
const dialogInstallBtn = installBtns[installBtns.length - 1];
|
||||
await act(async () => {
|
||||
fireEvent.click(dialogInstallBtn);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(skillRegistryApi.install).toHaveBeenCalledWith('registry-skill-1');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -38,6 +38,7 @@ const hoisted = vi.hoisted(() => ({
|
||||
recentRuns: vi.fn(),
|
||||
readRunLog: vi.fn(),
|
||||
cancelRun: vi.fn(),
|
||||
resolveRuntimes: vi.fn(),
|
||||
describeSkillList: vi.fn(),
|
||||
}));
|
||||
|
||||
@@ -58,6 +59,7 @@ vi.mock('../../../services/api/workflowsApi', () => ({
|
||||
recentRuns: hoisted.recentRuns,
|
||||
readRunLog: hoisted.readRunLog,
|
||||
cancelRun: hoisted.cancelRun,
|
||||
resolveRuntimes: hoisted.resolveRuntimes,
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -79,7 +81,7 @@ vi.mock('../inputs/RepoPicker', () => ({
|
||||
data-testid="repo-picker-stub"
|
||||
id={props.id}
|
||||
value={props.value}
|
||||
onChange={(e) => props.onChange(e.target.value)}
|
||||
onChange={e => props.onChange(e.target.value)}
|
||||
/>
|
||||
),
|
||||
}));
|
||||
@@ -89,7 +91,7 @@ vi.mock('../inputs/BranchPicker', () => ({
|
||||
data-testid="branch-picker-stub"
|
||||
id={props.id}
|
||||
value={props.value}
|
||||
onChange={(e) => props.onChange(e.target.value)}
|
||||
onChange={e => props.onChange(e.target.value)}
|
||||
/>
|
||||
),
|
||||
}));
|
||||
@@ -156,11 +158,12 @@ function renderBody(Body: React.ComponentType, initialPath = '/workflows/run') {
|
||||
|
||||
describe('WorkflowRunnerBody — saved-schedule toggle', () => {
|
||||
beforeEach(() => {
|
||||
Object.values(hoisted).forEach((fn) => fn.mockReset());
|
||||
Object.values(hoisted).forEach(fn => fn.mockReset());
|
||||
|
||||
hoisted.listWorkflows.mockResolvedValue(skillsList);
|
||||
hoisted.describeWorkflow.mockResolvedValue(skillDescription);
|
||||
hoisted.recentRuns.mockResolvedValue([]);
|
||||
hoisted.resolveRuntimes.mockResolvedValue({ runtimes: [] });
|
||||
hoisted.cronList.mockResolvedValue({ result: [makeJob({ enabled: true })] });
|
||||
hoisted.cronUpdate.mockResolvedValue({ result: makeJob({ enabled: false }) });
|
||||
hoisted.cronRuns.mockResolvedValue({ result: { runs: [] } });
|
||||
@@ -261,10 +264,11 @@ function makeRun(
|
||||
|
||||
describe('WorkflowRunnerBody — per-job history viewer', () => {
|
||||
beforeEach(() => {
|
||||
Object.values(hoisted).forEach((fn) => fn.mockReset());
|
||||
Object.values(hoisted).forEach(fn => fn.mockReset());
|
||||
hoisted.listWorkflows.mockResolvedValue(skillsList);
|
||||
hoisted.describeWorkflow.mockResolvedValue(skillDescription);
|
||||
hoisted.recentRuns.mockResolvedValue([]);
|
||||
hoisted.resolveRuntimes.mockResolvedValue({ runtimes: [] });
|
||||
hoisted.cronList.mockResolvedValue({ result: [makeJob({ enabled: true })] });
|
||||
hoisted.cronRuns.mockResolvedValue({ result: { runs: [makeRun(1), makeRun(2)] } });
|
||||
});
|
||||
@@ -285,7 +289,7 @@ describe('WorkflowRunnerBody — per-job history viewer', () => {
|
||||
expect(screen.getByTestId('history-run-job-1-2')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("expands a run row to show its captured output, hides on collapse", async () => {
|
||||
it('expands a run row to show its captured output, hides on collapse', async () => {
|
||||
const Body = await importBody();
|
||||
renderBody(Body);
|
||||
await waitFor(() => expect(hoisted.listWorkflows).toHaveBeenCalled());
|
||||
@@ -319,11 +323,7 @@ describe('WorkflowRunnerBody — per-job history viewer', () => {
|
||||
enabled: true,
|
||||
last_run: '2026-05-29T10:00:00Z',
|
||||
}),
|
||||
makeJob({
|
||||
id: 'job-paused',
|
||||
name: `skill-run-${SKILL_ID}-paused`,
|
||||
enabled: false,
|
||||
}),
|
||||
makeJob({ id: 'job-paused', name: `skill-run-${SKILL_ID}-paused`, enabled: false }),
|
||||
];
|
||||
hoisted.cronList.mockResolvedValue({ result: jobs });
|
||||
|
||||
@@ -350,7 +350,7 @@ describe('WorkflowRunnerBody — per-job history viewer', () => {
|
||||
// card emits a number of helper testids (`*-toggle`, `*-open`,
|
||||
// etc.) prefixed with the same root — narrow the regex to just
|
||||
// the card root by anchoring on a job-id pattern.
|
||||
const rows = ['job-recent-enabled', 'job-old-enabled', 'job-paused'].map((id) =>
|
||||
const rows = ['job-recent-enabled', 'job-old-enabled', 'job-paused'].map(id =>
|
||||
screen.getByTestId(`scheduled-job-${id}`)
|
||||
);
|
||||
expect(rows[0]).toHaveAttribute('data-active', 'true');
|
||||
@@ -358,10 +358,10 @@ describe('WorkflowRunnerBody — per-job history viewer', () => {
|
||||
expect(rows[2]).toHaveAttribute('data-active', 'false');
|
||||
// Confirm DOM order by walking the parent's children.
|
||||
const parent = rows[0].parentElement!;
|
||||
const cardChildren = Array.from(parent.children).filter((el) =>
|
||||
const cardChildren = Array.from(parent.children).filter(el =>
|
||||
el.getAttribute('data-testid')?.startsWith('scheduled-job-job-')
|
||||
);
|
||||
expect(cardChildren.map((el) => el.getAttribute('data-testid'))).toEqual([
|
||||
expect(cardChildren.map(el => el.getAttribute('data-testid'))).toEqual([
|
||||
'scheduled-job-job-recent-enabled',
|
||||
'scheduled-job-job-old-enabled',
|
||||
'scheduled-job-job-paused',
|
||||
@@ -369,9 +369,7 @@ describe('WorkflowRunnerBody — per-job history viewer', () => {
|
||||
});
|
||||
|
||||
it('does not show an Active badge when no schedules are enabled', async () => {
|
||||
hoisted.cronList.mockResolvedValue({
|
||||
result: [makeJob({ enabled: false })],
|
||||
});
|
||||
hoisted.cronList.mockResolvedValue({ result: [makeJob({ enabled: false })] });
|
||||
const Body = await importBody();
|
||||
renderBody(Body);
|
||||
await waitFor(() => expect(hoisted.listWorkflows).toHaveBeenCalled());
|
||||
@@ -402,10 +400,11 @@ describe('WorkflowRunnerBody — per-job history viewer', () => {
|
||||
|
||||
describe('WorkflowRunnerBody — schedule frequency + save', () => {
|
||||
beforeEach(() => {
|
||||
Object.values(hoisted).forEach((fn) => fn.mockReset());
|
||||
Object.values(hoisted).forEach(fn => fn.mockReset());
|
||||
hoisted.listWorkflows.mockResolvedValue(skillsList);
|
||||
hoisted.describeWorkflow.mockResolvedValue(skillDescription);
|
||||
hoisted.recentRuns.mockResolvedValue([]);
|
||||
hoisted.resolveRuntimes.mockResolvedValue({ runtimes: [] });
|
||||
hoisted.cronList.mockResolvedValue({ result: [] });
|
||||
hoisted.cronAdd.mockResolvedValue({ result: makeJob() });
|
||||
hoisted.cronRuns.mockResolvedValue({ result: { runs: [] } });
|
||||
@@ -437,8 +436,9 @@ describe('WorkflowRunnerBody — schedule frequency + save', () => {
|
||||
|
||||
describe('WorkflowRunnerBody — SmartIssuePicker conditional mount', () => {
|
||||
beforeEach(() => {
|
||||
Object.values(hoisted).forEach((fn) => fn.mockReset());
|
||||
Object.values(hoisted).forEach(fn => fn.mockReset());
|
||||
hoisted.recentRuns.mockResolvedValue([]);
|
||||
hoisted.resolveRuntimes.mockResolvedValue({ runtimes: [] });
|
||||
hoisted.cronList.mockResolvedValue({ result: [] });
|
||||
hoisted.cronRuns.mockResolvedValue({ result: { runs: [] } });
|
||||
});
|
||||
@@ -501,7 +501,7 @@ describe('WorkflowRunnerBody — SmartIssuePicker conditional mount', () => {
|
||||
|
||||
describe('WorkflowRunnerBody — URL ?workflow= preselect', () => {
|
||||
beforeEach(() => {
|
||||
Object.values(hoisted).forEach((fn) => fn.mockReset());
|
||||
Object.values(hoisted).forEach(fn => fn.mockReset());
|
||||
hoisted.listWorkflows.mockResolvedValue([
|
||||
{ id: 'dev-workflow', name: 'Dev Workflow' },
|
||||
{ id: 'github-issue-crusher', name: 'GitHub Issue Crusher' },
|
||||
@@ -513,6 +513,7 @@ describe('WorkflowRunnerBody — URL ?workflow= preselect', () => {
|
||||
inputs: [],
|
||||
});
|
||||
hoisted.recentRuns.mockResolvedValue([]);
|
||||
hoisted.resolveRuntimes.mockResolvedValue({ runtimes: [] });
|
||||
hoisted.cronList.mockResolvedValue({ result: [] });
|
||||
hoisted.cronRuns.mockResolvedValue({ result: { runs: [] } });
|
||||
});
|
||||
@@ -530,9 +531,7 @@ describe('WorkflowRunnerBody — URL ?workflow= preselect', () => {
|
||||
'settings.skillsRunner.skill'
|
||||
)) as HTMLSelectElement;
|
||||
expect(select.value).toBe('dev-workflow');
|
||||
await waitFor(() =>
|
||||
expect(hoisted.describeWorkflow).toHaveBeenCalledWith('dev-workflow')
|
||||
);
|
||||
await waitFor(() => expect(hoisted.describeWorkflow).toHaveBeenCalledWith('dev-workflow'));
|
||||
});
|
||||
|
||||
it('does not preselect when no ?workflow= is present', async () => {
|
||||
@@ -558,15 +557,11 @@ describe('WorkflowRunnerBody — URL ?workflow= preselect', () => {
|
||||
renderBody(Body, '/workflows/run?workflow=does-not-exist');
|
||||
|
||||
await waitFor(() => expect(hoisted.listWorkflows).toHaveBeenCalled());
|
||||
await waitFor(() =>
|
||||
expect(hoisted.describeWorkflow).toHaveBeenCalledWith('does-not-exist')
|
||||
);
|
||||
await waitFor(() => expect(hoisted.describeWorkflow).toHaveBeenCalledWith('does-not-exist'));
|
||||
// The dropdown value won't render as an option (not in the list),
|
||||
// so its current value normalises to '' visually — but the state
|
||||
// we care about is that the error surfaces, not crashes.
|
||||
expect(
|
||||
await screen.findByText(/settings.skillsRunner.error.describe/)
|
||||
).toBeInTheDocument();
|
||||
expect(await screen.findByText(/settings.skillsRunner.error.describe/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('locked (lock=1): shows the workflow-name header (no picker) AND the Run button', async () => {
|
||||
@@ -582,13 +577,9 @@ describe('WorkflowRunnerBody — URL ?workflow= preselect', () => {
|
||||
const heading = await screen.findByTestId('skills-runner-skill-locked');
|
||||
expect(heading).toHaveTextContent('Dev Workflow');
|
||||
// ...so there is no <select> picker in locked mode...
|
||||
expect(
|
||||
screen.queryByLabelText('settings.skillsRunner.skill')
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.queryByLabelText('settings.skillsRunner.skill')).not.toBeInTheDocument();
|
||||
// ...and the Run button is present after the inputs.
|
||||
expect(
|
||||
await screen.findByText('settings.skillsRunner.runNow')
|
||||
).toBeInTheDocument();
|
||||
expect(await screen.findByText('settings.skillsRunner.runNow')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -600,8 +591,10 @@ describe('WorkflowRunnerBody — URL ?workflow= preselect', () => {
|
||||
|
||||
describe('WorkflowRunnerBody — Run Now flow', () => {
|
||||
beforeEach(() => {
|
||||
Object.values(hoisted).forEach((fn) => fn.mockReset());
|
||||
hoisted.listWorkflows.mockResolvedValue([{ id: 'pr-review-shepherd', name: 'PR Review Shepherd' }]);
|
||||
Object.values(hoisted).forEach(fn => fn.mockReset());
|
||||
hoisted.listWorkflows.mockResolvedValue([
|
||||
{ id: 'pr-review-shepherd', name: 'PR Review Shepherd' },
|
||||
]);
|
||||
hoisted.describeWorkflow.mockResolvedValue({
|
||||
id: 'pr-review-shepherd',
|
||||
name: 'PR Review Shepherd',
|
||||
@@ -613,6 +606,7 @@ describe('WorkflowRunnerBody — Run Now flow', () => {
|
||||
],
|
||||
});
|
||||
hoisted.recentRuns.mockResolvedValue([]);
|
||||
hoisted.resolveRuntimes.mockResolvedValue({ runtimes: [] });
|
||||
hoisted.cronList.mockResolvedValue({ result: [] });
|
||||
hoisted.cronRuns.mockResolvedValue({ result: { runs: [] } });
|
||||
hoisted.runWorkflow.mockResolvedValue({
|
||||
@@ -629,7 +623,9 @@ describe('WorkflowRunnerBody — Run Now flow', () => {
|
||||
|
||||
const select = screen.getByLabelText('settings.skillsRunner.skill') as HTMLSelectElement;
|
||||
fireEvent.change(select, { target: { value: 'pr-review-shepherd' } });
|
||||
await waitFor(() => expect(hoisted.describeWorkflow).toHaveBeenCalledWith('pr-review-shepherd'));
|
||||
await waitFor(() =>
|
||||
expect(hoisted.describeWorkflow).toHaveBeenCalledWith('pr-review-shepherd')
|
||||
);
|
||||
|
||||
// Run Now button should be disabled when required field is empty
|
||||
const runBtn = await screen.findByText('settings.skillsRunner.runNow');
|
||||
@@ -686,9 +682,7 @@ describe('WorkflowRunnerBody — Run Now flow', () => {
|
||||
|
||||
await waitFor(() => expect(hoisted.runWorkflow).toHaveBeenCalledTimes(1));
|
||||
// The post-run refresh burst re-scans recentRuns on its own.
|
||||
await waitFor(() =>
|
||||
expect(hoisted.recentRuns.mock.calls.length).toBeGreaterThan(callsBefore)
|
||||
);
|
||||
await waitFor(() => expect(hoisted.recentRuns.mock.calls.length).toBeGreaterThan(callsBefore));
|
||||
});
|
||||
|
||||
it('surfaces error when runWorkflow rejects', async () => {
|
||||
@@ -708,9 +702,7 @@ describe('WorkflowRunnerBody — Run Now flow', () => {
|
||||
await waitFor(() => expect(runBtn.closest('button')).not.toBeDisabled());
|
||||
fireEvent.click(runBtn.closest('button')!);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId('skill-run-error')).toBeInTheDocument()
|
||||
);
|
||||
await waitFor(() => expect(screen.getByTestId('skill-run-error')).toBeInTheDocument());
|
||||
});
|
||||
});
|
||||
|
||||
@@ -751,7 +743,6 @@ describe('parseScheduledInputs', () => {
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
// ── Stop / Edit / scheduled run-now ──────────────────────────────────
|
||||
//
|
||||
// Covers the consolidated runner's locked-mode surface: the Stop button on
|
||||
@@ -761,15 +752,20 @@ describe('parseScheduledInputs', () => {
|
||||
|
||||
describe('WorkflowRunnerBody — Stop / Edit / scheduled run-now', () => {
|
||||
beforeEach(() => {
|
||||
Object.values(hoisted).forEach((fn) => fn.mockReset());
|
||||
Object.values(hoisted).forEach(fn => fn.mockReset());
|
||||
hoisted.listWorkflows.mockResolvedValue([
|
||||
{ id: SKILL_ID, name: 'GitHub Issue Crusher', scope: 'user', legacy: false },
|
||||
]);
|
||||
hoisted.describeWorkflow.mockResolvedValue(skillDescription);
|
||||
hoisted.recentRuns.mockResolvedValue([]);
|
||||
hoisted.resolveRuntimes.mockResolvedValue({ runtimes: [] });
|
||||
hoisted.cronList.mockResolvedValue({ result: [] });
|
||||
hoisted.cronRuns.mockResolvedValue({ result: { runs: [] } });
|
||||
hoisted.runWorkflow.mockResolvedValue({ run_id: 'r-new', workflow_id: SKILL_ID, log: '/tmp/l' });
|
||||
hoisted.runWorkflow.mockResolvedValue({
|
||||
run_id: 'r-new',
|
||||
workflow_id: SKILL_ID,
|
||||
log: '/tmp/l',
|
||||
});
|
||||
hoisted.cancelRun.mockResolvedValue(true);
|
||||
});
|
||||
|
||||
@@ -802,6 +798,15 @@ describe('WorkflowRunnerBody — Stop / Edit / scheduled run-now', () => {
|
||||
});
|
||||
|
||||
it('runs a saved schedule directly with its snapshotted inputs', async () => {
|
||||
hoisted.listWorkflows.mockResolvedValue([
|
||||
{
|
||||
id: SKILL_ID,
|
||||
name: 'GitHub Issue Crusher',
|
||||
scope: 'user',
|
||||
legacy: false,
|
||||
resources: ['scripts/run.py'],
|
||||
},
|
||||
]);
|
||||
const prompt = [
|
||||
`Run the ${SKILL_ID} workflow via the run_workflow tool (workflow_id: "${SKILL_ID}") with these inputs:`,
|
||||
'- channel: team-product',
|
||||
@@ -819,8 +824,507 @@ describe('WorkflowRunnerBody — Stop / Edit / scheduled run-now', () => {
|
||||
|
||||
// The card's "Run" runs the workflow directly with those inputs.
|
||||
fireEvent.click(screen.getByText('settings.skillsRunner.schedule.runNow'));
|
||||
await waitFor(() => expect(hoisted.resolveRuntimes).toHaveBeenCalledWith('python'));
|
||||
await waitFor(() =>
|
||||
expect(hoisted.runWorkflow).toHaveBeenCalledWith(SKILL_ID, { channel: 'team-product' })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ── skillsError display ──────────────────────────────────────────────
|
||||
|
||||
describe('WorkflowRunnerBody — skillsError state', () => {
|
||||
beforeEach(() => {
|
||||
Object.values(hoisted).forEach(fn => fn.mockReset());
|
||||
hoisted.recentRuns.mockResolvedValue([]);
|
||||
hoisted.resolveRuntimes.mockResolvedValue({ runtimes: [] });
|
||||
hoisted.cronList.mockResolvedValue({ result: [] });
|
||||
hoisted.cronRuns.mockResolvedValue({ result: { runs: [] } });
|
||||
});
|
||||
|
||||
it('shows skillsError message when listWorkflows rejects', async () => {
|
||||
hoisted.listWorkflows.mockRejectedValue(new Error('network failure'));
|
||||
const Body = await importBody();
|
||||
renderBody(Body);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText(/settings.skillsRunner.error.listWorkflows/)).toBeInTheDocument()
|
||||
);
|
||||
expect(screen.getByText(/network failure/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Recent runs status badge colors ─────────────────────────────────
|
||||
|
||||
describe('WorkflowRunnerBody — recent runs status badges', () => {
|
||||
beforeEach(() => {
|
||||
Object.values(hoisted).forEach(fn => fn.mockReset());
|
||||
hoisted.listWorkflows.mockResolvedValue([]);
|
||||
hoisted.recentRuns.mockResolvedValue([]);
|
||||
hoisted.resolveRuntimes.mockResolvedValue({ runtimes: [] });
|
||||
hoisted.cronList.mockResolvedValue({ result: [] });
|
||||
hoisted.cronRuns.mockResolvedValue({ result: { runs: [] } });
|
||||
});
|
||||
|
||||
function makeRecentRun(status: string) {
|
||||
return {
|
||||
run_id: `run-${status.toLowerCase()}`,
|
||||
workflow_id: SKILL_ID,
|
||||
status,
|
||||
started: '2026-05-30T09:00:00Z',
|
||||
duration_ms: status === 'RUNNING' ? null : 5000,
|
||||
finished: status === 'RUNNING' ? null : '2026-05-30T09:00:05Z',
|
||||
log_path: `/tmp/run-${status.toLowerCase()}.log`,
|
||||
};
|
||||
}
|
||||
|
||||
it('renders DONE, DEGENERATE, and FAILED recent run rows', async () => {
|
||||
hoisted.recentRuns.mockResolvedValue([
|
||||
makeRecentRun('DONE'),
|
||||
makeRecentRun('DEGENERATE'),
|
||||
makeRecentRun('FAILED'),
|
||||
]);
|
||||
const Body = await importBody();
|
||||
renderBody(Body);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('DONE')).toBeInTheDocument();
|
||||
expect(screen.getByText('DEGENERATE')).toBeInTheDocument();
|
||||
expect(screen.getByText('FAILED')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('expands a recent run row and shows tailing indicator', async () => {
|
||||
hoisted.recentRuns.mockResolvedValue([makeRecentRun('DONE')]);
|
||||
hoisted.readRunLog.mockResolvedValue({
|
||||
bytes_read: 12,
|
||||
eof: false,
|
||||
complete: false,
|
||||
content: 'log content',
|
||||
offset: 12,
|
||||
});
|
||||
const Body = await importBody();
|
||||
renderBody(Body);
|
||||
|
||||
// Wait for the run row to appear
|
||||
const runBtn = await screen.findByText('DONE');
|
||||
fireEvent.click(runBtn.closest('button')!);
|
||||
|
||||
// After expansion, the tailing indicator should appear
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/settings.skillsRunner.viewer.tailing/)).toBeInTheDocument();
|
||||
});
|
||||
// The log content should be rendered in a pre block
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('log content')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows error in viewer when readRunLog fails', async () => {
|
||||
hoisted.recentRuns.mockResolvedValue([makeRecentRun('RUNNING')]);
|
||||
hoisted.readRunLog.mockRejectedValue(new Error('log read failed'));
|
||||
const Body = await importBody();
|
||||
renderBody(Body);
|
||||
|
||||
const runBtn = await screen.findByText('RUNNING');
|
||||
fireEvent.click(runBtn.closest('button')!);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/log read failed/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows collapse icon when run row is expanded and expand icon when collapsed', async () => {
|
||||
hoisted.recentRuns.mockResolvedValue([makeRecentRun('DONE')]);
|
||||
hoisted.readRunLog.mockResolvedValue({
|
||||
bytes_read: 0,
|
||||
eof: true,
|
||||
complete: true,
|
||||
content: '',
|
||||
offset: 0,
|
||||
});
|
||||
const Body = await importBody();
|
||||
renderBody(Body);
|
||||
|
||||
const runBtn = await screen.findByText('DONE');
|
||||
const btn = runBtn.closest('button')!;
|
||||
|
||||
// Before expand: shows ▸
|
||||
expect(btn).toHaveTextContent('▸');
|
||||
|
||||
fireEvent.click(btn);
|
||||
|
||||
// After expand: shows ▾
|
||||
await waitFor(() => {
|
||||
expect(btn).toHaveTextContent('▾');
|
||||
});
|
||||
|
||||
// Collapse again
|
||||
fireEvent.click(btn);
|
||||
await waitFor(() => {
|
||||
expect(btn).toHaveTextContent('▸');
|
||||
});
|
||||
});
|
||||
|
||||
it('shows all recent runs heading when no skill selected', async () => {
|
||||
const Body = await importBody();
|
||||
renderBody(Body);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText('settings.skillsRunner.recentRuns.headingAll')
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows skill-scoped heading when a skill is selected', async () => {
|
||||
hoisted.listWorkflows.mockResolvedValue([{ id: SKILL_ID, name: 'GitHub Issue Crusher' }]);
|
||||
hoisted.describeWorkflow.mockResolvedValue(skillDescription);
|
||||
const Body = await importBody();
|
||||
renderBody(Body);
|
||||
|
||||
await waitFor(() => expect(hoisted.listWorkflows).toHaveBeenCalled());
|
||||
const select = screen.getByLabelText('settings.skillsRunner.skill') as HTMLSelectElement;
|
||||
fireEvent.change(select, { target: { value: SKILL_ID } });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText('settings.skillsRunner.recentRuns.headingForSkill')
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── renderField type variants ────────────────────────────────────────
|
||||
|
||||
describe('WorkflowRunnerBody — renderField type variants', () => {
|
||||
beforeEach(() => {
|
||||
Object.values(hoisted).forEach(fn => fn.mockReset());
|
||||
hoisted.recentRuns.mockResolvedValue([]);
|
||||
hoisted.resolveRuntimes.mockResolvedValue({ runtimes: [] });
|
||||
hoisted.cronList.mockResolvedValue({ result: [] });
|
||||
hoisted.cronRuns.mockResolvedValue({ result: { runs: [] } });
|
||||
hoisted.listWorkflows.mockResolvedValue([{ id: 'multi-input', name: 'Multi Input' }]);
|
||||
});
|
||||
|
||||
it('renders boolean (checkbox), integer (number), and string inputs', async () => {
|
||||
hoisted.describeWorkflow.mockResolvedValue({
|
||||
id: 'multi-input',
|
||||
name: 'Multi Input',
|
||||
when_to_use: 'Multi.',
|
||||
inputs: [
|
||||
{ name: 'dry_run', type: 'boolean', required: false, description: 'Dry run?' },
|
||||
{ name: 'count', type: 'integer', required: false, description: 'Count' },
|
||||
{ name: 'message', type: 'string', required: true, description: 'Message' },
|
||||
],
|
||||
});
|
||||
const Body = await importBody();
|
||||
renderBody(Body);
|
||||
|
||||
await waitFor(() => expect(hoisted.listWorkflows).toHaveBeenCalled());
|
||||
const select = screen.getByLabelText('settings.skillsRunner.skill') as HTMLSelectElement;
|
||||
fireEvent.change(select, { target: { value: 'multi-input' } });
|
||||
|
||||
await waitFor(() => expect(hoisted.describeWorkflow).toHaveBeenCalledWith('multi-input'));
|
||||
|
||||
// boolean → checkbox
|
||||
const checkbox = await screen.findByRole('checkbox');
|
||||
expect(checkbox).toBeInTheDocument();
|
||||
|
||||
// integer → number input
|
||||
const numInput = screen.getByRole('spinbutton');
|
||||
expect(numInput).toBeInTheDocument();
|
||||
|
||||
// string → text input
|
||||
const textInputs = screen.getAllByRole('textbox');
|
||||
expect(textInputs.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ── handleRemoveJob ──────────────────────────────────────────────────
|
||||
|
||||
describe('WorkflowRunnerBody — handleRemoveJob', () => {
|
||||
beforeEach(() => {
|
||||
Object.values(hoisted).forEach(fn => fn.mockReset());
|
||||
hoisted.listWorkflows.mockResolvedValue(skillsList);
|
||||
hoisted.describeWorkflow.mockResolvedValue(skillDescription);
|
||||
hoisted.recentRuns.mockResolvedValue([]);
|
||||
hoisted.resolveRuntimes.mockResolvedValue({ runtimes: [] });
|
||||
hoisted.cronList.mockResolvedValue({ result: [makeJob({ enabled: true })] });
|
||||
hoisted.cronRemove.mockResolvedValue({ result: 'ok' });
|
||||
hoisted.cronRuns.mockResolvedValue({ result: { runs: [] } });
|
||||
});
|
||||
|
||||
it('calls openhumanCronRemove when the Remove button is clicked', async () => {
|
||||
hoisted.cronList
|
||||
.mockResolvedValueOnce({ result: [makeJob({ enabled: true })] })
|
||||
.mockResolvedValue({ result: [] });
|
||||
|
||||
const Body = await importBody();
|
||||
renderBody(Body);
|
||||
|
||||
await waitFor(() => expect(hoisted.listWorkflows).toHaveBeenCalled());
|
||||
const select = screen.getByLabelText('settings.skillsRunner.skill') as HTMLSelectElement;
|
||||
fireEvent.change(select, { target: { value: SKILL_ID } });
|
||||
|
||||
await waitFor(() => expect(hoisted.cronList).toHaveBeenCalled());
|
||||
|
||||
const removeBtn = await screen.findByText('settings.skillsRunner.schedule.remove');
|
||||
fireEvent.click(removeBtn);
|
||||
|
||||
await waitFor(() => expect(hoisted.cronRemove).toHaveBeenCalledWith('job-1'));
|
||||
// After removal the list is refreshed
|
||||
await waitFor(() => expect(hoisted.cronList).toHaveBeenCalledTimes(2));
|
||||
});
|
||||
});
|
||||
|
||||
// ── loadJobHistory error path ────────────────────────────────────────
|
||||
|
||||
describe('WorkflowRunnerBody — loadJobHistory error path', () => {
|
||||
beforeEach(() => {
|
||||
Object.values(hoisted).forEach(fn => fn.mockReset());
|
||||
hoisted.listWorkflows.mockResolvedValue(skillsList);
|
||||
hoisted.describeWorkflow.mockResolvedValue(skillDescription);
|
||||
hoisted.recentRuns.mockResolvedValue([]);
|
||||
hoisted.resolveRuntimes.mockResolvedValue({ runtimes: [] });
|
||||
hoisted.cronList.mockResolvedValue({ result: [makeJob({ enabled: true })] });
|
||||
hoisted.cronRuns.mockRejectedValue(new Error('history fetch failed'));
|
||||
});
|
||||
|
||||
it('handles cronRuns error gracefully (no crash, history stays collapsed-but-error)', async () => {
|
||||
const Body = await importBody();
|
||||
renderBody(Body);
|
||||
|
||||
await waitFor(() => expect(hoisted.listWorkflows).toHaveBeenCalled());
|
||||
const select = screen.getByLabelText('settings.skillsRunner.skill') as HTMLSelectElement;
|
||||
fireEvent.change(select, { target: { value: SKILL_ID } });
|
||||
|
||||
await waitFor(() => expect(hoisted.cronList).toHaveBeenCalled());
|
||||
|
||||
const historyToggle = await screen.findByTestId('history-toggle-job-1');
|
||||
fireEvent.click(historyToggle);
|
||||
|
||||
await waitFor(() => expect(hoisted.cronRuns).toHaveBeenCalledWith('job-1', 5));
|
||||
// The component should not crash and should still be in the DOM
|
||||
expect(screen.getByTestId('history-toggle-job-1')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ── history run with no output ───────────────────────────────────────
|
||||
|
||||
describe('WorkflowRunnerBody — history run with no output', () => {
|
||||
beforeEach(() => {
|
||||
Object.values(hoisted).forEach(fn => fn.mockReset());
|
||||
hoisted.listWorkflows.mockResolvedValue(skillsList);
|
||||
hoisted.describeWorkflow.mockResolvedValue(skillDescription);
|
||||
hoisted.recentRuns.mockResolvedValue([]);
|
||||
hoisted.resolveRuntimes.mockResolvedValue({ runtimes: [] });
|
||||
hoisted.cronList.mockResolvedValue({ result: [makeJob({ enabled: true })] });
|
||||
});
|
||||
|
||||
it('shows historyNoOutput placeholder when a run row has null output', async () => {
|
||||
hoisted.cronRuns.mockResolvedValue({
|
||||
result: { runs: [{ id: 1, job_id: 'job-1', started_at: '2026-05-30T09:00:00Z', finished_at: '2026-05-30T09:00:05Z', status: 'ok', output: null, duration_ms: 5000 }] },
|
||||
});
|
||||
|
||||
const Body = await importBody();
|
||||
renderBody(Body);
|
||||
|
||||
await waitFor(() => expect(hoisted.listWorkflows).toHaveBeenCalled());
|
||||
const select = screen.getByLabelText('settings.skillsRunner.skill') as HTMLSelectElement;
|
||||
fireEvent.change(select, { target: { value: SKILL_ID } });
|
||||
|
||||
await waitFor(() => expect(hoisted.cronList).toHaveBeenCalled());
|
||||
|
||||
const historyToggle = await screen.findByTestId('history-toggle-job-1');
|
||||
fireEvent.click(historyToggle);
|
||||
|
||||
await waitFor(() => expect(hoisted.cronRuns).toHaveBeenCalled());
|
||||
|
||||
const runRow = await screen.findByTestId('history-run-job-1-1');
|
||||
fireEvent.click(runRow);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText('settings.skillsRunner.schedule.historyNoOutput')
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── save schedule: missing required fields ───────────────────────────
|
||||
|
||||
describe('WorkflowRunnerBody — save schedule with missing required fields', () => {
|
||||
beforeEach(() => {
|
||||
Object.values(hoisted).forEach(fn => fn.mockReset());
|
||||
hoisted.recentRuns.mockResolvedValue([]);
|
||||
hoisted.resolveRuntimes.mockResolvedValue({ runtimes: [] });
|
||||
hoisted.cronList.mockResolvedValue({ result: [] });
|
||||
hoisted.cronRuns.mockResolvedValue({ result: { runs: [] } });
|
||||
hoisted.listWorkflows.mockResolvedValue([{ id: 'req-skill', name: 'Req Skill' }]);
|
||||
hoisted.describeWorkflow.mockResolvedValue({
|
||||
id: 'req-skill',
|
||||
name: 'Req Skill',
|
||||
when_to_use: 'req.',
|
||||
inputs: [
|
||||
{ name: 'required_param', type: 'string', required: true, description: 'Required param' },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('shows schedule error when required field is blank on save', async () => {
|
||||
const Body = await importBody();
|
||||
renderBody(Body);
|
||||
|
||||
await waitFor(() => expect(hoisted.listWorkflows).toHaveBeenCalled());
|
||||
const select = screen.getByLabelText('settings.skillsRunner.skill') as HTMLSelectElement;
|
||||
fireEvent.change(select, { target: { value: 'req-skill' } });
|
||||
|
||||
await waitFor(() => expect(hoisted.describeWorkflow).toHaveBeenCalledWith('req-skill'));
|
||||
|
||||
// Don't fill the required field, hit save
|
||||
const saveBtn = await screen.findByText('settings.skillsRunner.schedule.save');
|
||||
fireEvent.click(saveBtn);
|
||||
|
||||
await waitFor(() => {
|
||||
// The schedule error shows the missing field name
|
||||
expect(screen.getByText(/required_param/)).toBeInTheDocument();
|
||||
});
|
||||
// cronAdd should NOT have been called
|
||||
expect(hoisted.cronAdd).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ── buildCronJobName via save schedule ───────────────────────────────
|
||||
|
||||
describe('WorkflowRunnerBody — buildCronJobName with non-empty inputs', () => {
|
||||
beforeEach(() => {
|
||||
Object.values(hoisted).forEach(fn => fn.mockReset());
|
||||
hoisted.recentRuns.mockResolvedValue([]);
|
||||
hoisted.resolveRuntimes.mockResolvedValue({ runtimes: [] });
|
||||
hoisted.cronList.mockResolvedValue({ result: [] });
|
||||
hoisted.cronRuns.mockResolvedValue({ result: { runs: [] } });
|
||||
hoisted.cronAdd.mockResolvedValue({ result: makeJob() });
|
||||
hoisted.listWorkflows.mockResolvedValue([{ id: 'name-skill', name: 'Name Skill' }]);
|
||||
hoisted.describeWorkflow.mockResolvedValue({
|
||||
id: 'name-skill',
|
||||
name: 'Name Skill',
|
||||
when_to_use: 'test.',
|
||||
inputs: [
|
||||
{ name: 'owner', type: 'string', required: true, description: 'Owner' },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('builds cron job name including the input value and calls cronAdd', async () => {
|
||||
const Body = await importBody();
|
||||
renderBody(Body);
|
||||
|
||||
await waitFor(() => expect(hoisted.listWorkflows).toHaveBeenCalled());
|
||||
const select = screen.getByLabelText('settings.skillsRunner.skill') as HTMLSelectElement;
|
||||
fireEvent.change(select, { target: { value: 'name-skill' } });
|
||||
|
||||
await waitFor(() => expect(hoisted.describeWorkflow).toHaveBeenCalledWith('name-skill'));
|
||||
|
||||
const ownerInput = await screen.findByRole('textbox');
|
||||
fireEvent.change(ownerInput, { target: { value: 'tinyhumansai' } });
|
||||
|
||||
fireEvent.click(screen.getByText('settings.skillsRunner.schedule.save'));
|
||||
|
||||
await waitFor(() => expect(hoisted.cronAdd).toHaveBeenCalled());
|
||||
const [params] = hoisted.cronAdd.mock.calls[0];
|
||||
// The cron job name should include the skill id and input value
|
||||
expect(params.name).toContain('name-skill');
|
||||
expect(params.name).toContain('tinyhumansai');
|
||||
});
|
||||
});
|
||||
|
||||
// ── ensureRuntimeAvailability failure ────────────────────────────────
|
||||
|
||||
describe('WorkflowRunnerBody — ensureRuntimeAvailability failure', () => {
|
||||
beforeEach(() => {
|
||||
Object.values(hoisted).forEach(fn => fn.mockReset());
|
||||
hoisted.recentRuns.mockResolvedValue([]);
|
||||
hoisted.cronList.mockResolvedValue({ result: [] });
|
||||
hoisted.cronRuns.mockResolvedValue({ result: { runs: [] } });
|
||||
hoisted.runWorkflow.mockResolvedValue({ run_id: 'r', skill_id: 'x', log: '/tmp/l' });
|
||||
hoisted.listWorkflows.mockResolvedValue([{
|
||||
id: 'py-skill',
|
||||
name: 'Python Skill',
|
||||
resources: ['scripts/run.py'],
|
||||
}]);
|
||||
hoisted.describeWorkflow.mockResolvedValue({
|
||||
id: 'py-skill',
|
||||
name: 'Python Skill',
|
||||
when_to_use: 'run py.',
|
||||
inputs: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('surfaces runtime unavailable error on run when python runtime is missing', async () => {
|
||||
hoisted.resolveRuntimes.mockResolvedValue({
|
||||
runtimes: [
|
||||
{ runtime: 'python', enabled: true, available: false, source: 'managed', version: null, binary: null, binDir: null, error: 'not installed' },
|
||||
],
|
||||
});
|
||||
|
||||
const Body = await importBody();
|
||||
renderBody(Body);
|
||||
|
||||
await waitFor(() => expect(hoisted.listWorkflows).toHaveBeenCalled());
|
||||
const select = screen.getByLabelText('settings.skillsRunner.skill') as HTMLSelectElement;
|
||||
fireEvent.change(select, { target: { value: 'py-skill' } });
|
||||
|
||||
await waitFor(() => expect(hoisted.describeWorkflow).toHaveBeenCalledWith('py-skill'));
|
||||
|
||||
const runBtn = await screen.findByText('settings.skillsRunner.runNow');
|
||||
fireEvent.click(runBtn.closest('button')!);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('skill-run-error')).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByText(/python/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ── preflight gate failure pill ──────────────────────────────────────
|
||||
|
||||
describe('WorkflowRunnerBody — preflight gate failure', () => {
|
||||
beforeEach(() => {
|
||||
Object.values(hoisted).forEach(fn => fn.mockReset());
|
||||
hoisted.recentRuns.mockResolvedValue([]);
|
||||
hoisted.resolveRuntimes.mockResolvedValue({ runtimes: [] });
|
||||
hoisted.cronList.mockResolvedValue({ result: [] });
|
||||
hoisted.cronRuns.mockResolvedValue({ result: { runs: [] } });
|
||||
hoisted.listWorkflows.mockResolvedValue([{ id: 'gated-skill', name: 'Gated Skill' }]);
|
||||
hoisted.describeWorkflow.mockResolvedValue({
|
||||
id: 'gated-skill',
|
||||
name: 'Gated Skill',
|
||||
when_to_use: 'gated.',
|
||||
inputs: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('shows preflight gate pill when run fails with preflight error format', async () => {
|
||||
// The preflight error format is: [preflight:<gate>:<tag>] <body>
|
||||
hoisted.runWorkflow.mockRejectedValue(
|
||||
new Error('[preflight:github:token] GitHub token not configured')
|
||||
);
|
||||
|
||||
const Body = await importBody();
|
||||
renderBody(Body);
|
||||
|
||||
await waitFor(() => expect(hoisted.listWorkflows).toHaveBeenCalled());
|
||||
const select = screen.getByLabelText('settings.skillsRunner.skill') as HTMLSelectElement;
|
||||
fireEvent.change(select, { target: { value: 'gated-skill' } });
|
||||
|
||||
await waitFor(() => expect(hoisted.describeWorkflow).toHaveBeenCalledWith('gated-skill'));
|
||||
|
||||
const runBtn = await screen.findByText('settings.skillsRunner.runNow');
|
||||
fireEvent.click(runBtn.closest('button')!);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('skill-run-error')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
// Parse and present the structured error message that the
|
||||
// `openhuman.workflows_run` RPC returns when a skill's `[github]`
|
||||
// `openhuman.skill_runtime_run` RPC returns when a skill's `[github]`
|
||||
// preflight gate refuses the run.
|
||||
//
|
||||
// Backend contract (see src/openhuman/skills/schemas.rs's
|
||||
// `spawn_skill_run_background` → preflight branch and
|
||||
// src/openhuman/skills/preflight.rs's `GithubGateError::tag` /
|
||||
// Backend contract (see src/openhuman/skill_runtime's
|
||||
// `spawn_workflow_run_background` → preflight branch and
|
||||
// src/openhuman/workflows/preflight.rs's `GithubGateError::tag` /
|
||||
// `to_user_message`): the error string is shaped as
|
||||
//
|
||||
// `[preflight:<gate>:<tag>] <user-readable body>`
|
||||
@@ -22,7 +22,7 @@
|
||||
|
||||
const PREFLIGHT_PREFIX_RE = /^\[preflight:([a-z0-9_-]+):([a-z0-9_-]+)\]\s+/i;
|
||||
|
||||
/** Parsed shape of a backend RPC error returned by `openhuman.workflows_run`. */
|
||||
/** Parsed shape of a backend RPC error returned by `openhuman.skill_runtime_run`. */
|
||||
export interface WorkflowRunError {
|
||||
/** `'github'` when this is a github-gate failure; `null` for any other error. */
|
||||
gate: string | null;
|
||||
@@ -36,7 +36,7 @@ export interface WorkflowRunError {
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the message string returned by the `openhuman.workflows_run` RPC
|
||||
* Parse the message string returned by the `openhuman.skill_runtime_run` RPC
|
||||
* error path (or thrown by `workflowsApi.runWorkflow`). Anything that matches
|
||||
* the `[preflight:<gate>:<tag>]` prefix becomes a structured gate
|
||||
* failure; anything else falls through with `gate: null` so the caller
|
||||
|
||||
+12
-4
@@ -267,12 +267,12 @@ const messages: TranslationMap = {
|
||||
'skills.explorer.subtitle':
|
||||
'تصفح حزم SKILL.md، بما في ذلك markdown والسكربتات والمراجع والقوالب والأمثلة والمطالبات بنمط Hermes.',
|
||||
'skills.explorer.title': 'مستكشف المهارات',
|
||||
'skills.explorer.searchPlaceholder': 'Search skills...',
|
||||
'skills.explorer.searchPlaceholder': 'ابحث في المهارات...',
|
||||
'skills.explorer.scopeUser': 'User',
|
||||
'skills.explorer.scopeProject': 'Project',
|
||||
'skills.explorer.scopeLegacy': 'Legacy',
|
||||
'skills.explorer.noDescription': 'No description provided.',
|
||||
'skills.explorer.uninstallSuccess': 'Skill uninstalled successfully.',
|
||||
'skills.explorer.noDescription': 'لم يتم تقديم وصف.',
|
||||
'skills.explorer.uninstallSuccess': 'تم إلغاء تثبيت المهارة بنجاح.',
|
||||
'skills.explorer.registryTab': 'السجل',
|
||||
'skills.explorer.installedTab': 'المثبتة',
|
||||
'skills.explorer.allFormats': 'جميع الأنماط',
|
||||
@@ -280,6 +280,7 @@ const messages: TranslationMap = {
|
||||
'skills.explorer.registryEmptyTitle': 'لا توجد إدخالات في السجل',
|
||||
'skills.explorer.registryEmptyDescription':
|
||||
'تعذر تحميل سجل المهارات. تحقق من اتصالك وحاول التحديث.',
|
||||
'skills.explorer.showingOf': 'عرض {shown} من {total} نتيجة. حسّن بحثك لرؤية المزيد.',
|
||||
'skills.explorer.installed': 'مثبت',
|
||||
'skills.explorer.install': 'تثبيت',
|
||||
'skills.explorer.installing': 'جارٍ التثبيت…',
|
||||
@@ -293,7 +294,7 @@ const messages: TranslationMap = {
|
||||
'skills.tabs.composio': 'Composio',
|
||||
'skills.tabs.channels': 'القنوات',
|
||||
'skills.tabs.explorer': 'المهارات',
|
||||
'skills.tabs.meetings': 'Google Meet',
|
||||
'skills.tabs.meetings': 'اجتماعات Google Meet',
|
||||
'skills.tabs.mcp': 'MCP الخوادم',
|
||||
'memory.title': 'الذاكرة',
|
||||
'memory.search': 'البحث في الذكريات...',
|
||||
@@ -3559,6 +3560,8 @@ const messages: TranslationMap = {
|
||||
'settings.skillsRunner.error.missingRequired': 'المفقود يتطلب مدخلات (مساهمات):',
|
||||
'settings.skillsRunner.error.run': 'لم يبدأ التشغيل:',
|
||||
'settings.skillsRunner.error.preflightGate': 'البوابة الأمامية فشلت',
|
||||
'settings.skillsRunner.error.runtimeUnavailable': 'وقت التشغيل غير متاح',
|
||||
'settings.skillsRunner.error.runtimeUnavailableDefault': 'غير متاح',
|
||||
'settings.skillsRunner.schedule.heading': 'الجدول (المتكرر)',
|
||||
'settings.skillsRunner.schedule.help':
|
||||
'وفر هذه المهارة + المدخلات كعمل كرتوني متكرر. العميل سيتصل بـ "هريب" في كل مرة',
|
||||
@@ -4057,15 +4060,20 @@ const messages: TranslationMap = {
|
||||
'skills.create.title': 'مهارة جديدة',
|
||||
'skills.detail.allowedTools': 'الأدوات المسموح بها',
|
||||
'skills.detail.author': 'المؤلف',
|
||||
'skills.detail.license': 'الترخيص',
|
||||
'skills.detail.bundledResources': 'الموارد المجمّعة',
|
||||
'skills.detail.description': 'الوصف',
|
||||
'skills.detail.closeAriaLabel': 'إغلاق تفاصيل المهارة',
|
||||
'skills.detail.location': 'الموقع',
|
||||
'skills.detail.noBundledResources': 'لا توجد موارد مجمّعة.',
|
||||
'skills.detail.platforms': 'المنصات',
|
||||
'skills.detail.relatedSkills': 'مهارات ذات صلة',
|
||||
'skills.detail.source': 'رابط المصدر',
|
||||
'skills.detail.sourceFormat': 'التنسيق',
|
||||
'skills.detail.stars': 'النجوم',
|
||||
'skills.detail.tags': 'العلامات',
|
||||
'skills.detail.warnings': 'التحذيرات',
|
||||
'skills.detail.version': 'الإصدار',
|
||||
'skills.install.errors.alreadyInstalledHint':
|
||||
'توجد بالفعل مهارة بهذه البزاقة في مساحة العمل. قم بإزالته أولاً أو قم بتغيير المادة الأمامية `metadata.id` / `name`.',
|
||||
'skills.install.errors.alreadyInstalledTitle': 'تم تثبيت المهارة بالفعل',
|
||||
|
||||
+13
-4
@@ -269,12 +269,12 @@ const messages: TranslationMap = {
|
||||
'skills.explorer.subtitle':
|
||||
'Hermes-ধাঁচের markdown, scripts, references, templates, examples এবং prompts সহ SKILL.md প্যাকেজ ব্রাউজ করুন।',
|
||||
'skills.explorer.title': 'স্কিল এক্সপ্লোরার',
|
||||
'skills.explorer.searchPlaceholder': 'Search skills...',
|
||||
'skills.explorer.searchPlaceholder': 'স্কিল খুঁজুন...',
|
||||
'skills.explorer.scopeUser': 'User',
|
||||
'skills.explorer.scopeProject': 'Project',
|
||||
'skills.explorer.scopeLegacy': 'Legacy',
|
||||
'skills.explorer.noDescription': 'No description provided.',
|
||||
'skills.explorer.uninstallSuccess': 'Skill uninstalled successfully.',
|
||||
'skills.explorer.noDescription': 'কোনো বিবরণ দেওয়া হয়নি।',
|
||||
'skills.explorer.uninstallSuccess': 'স্কিল সফলভাবে আনইনস্টল করা হয়েছে।',
|
||||
'skills.explorer.registryTab': 'রেজিস্ট্রি',
|
||||
'skills.explorer.installedTab': 'ইনস্টল করা',
|
||||
'skills.explorer.allFormats': 'সব ফরম্যাট',
|
||||
@@ -282,6 +282,8 @@ const messages: TranslationMap = {
|
||||
'skills.explorer.registryEmptyTitle': 'কোনো রেজিস্ট্রি এন্ট্রি নেই',
|
||||
'skills.explorer.registryEmptyDescription':
|
||||
'দক্ষতা রেজিস্ট্রি লোড করা যায়নি। আপনার সংযোগ পরীক্ষা করে রিফ্রেশ করার চেষ্টা করুন।',
|
||||
'skills.explorer.showingOf':
|
||||
'{total} ফলাফলের মধ্যে {shown}টি দেখানো হচ্ছে। আরও দেখতে আপনার অনুসন্ধান পরিমার্জন করুন।',
|
||||
'skills.explorer.installed': 'ইনস্টল করা হয়েছে',
|
||||
'skills.explorer.install': 'ইনস্টল করুন',
|
||||
'skills.explorer.installing': 'ইনস্টল হচ্ছে…',
|
||||
@@ -295,7 +297,7 @@ const messages: TranslationMap = {
|
||||
'skills.tabs.composio': 'Composio',
|
||||
'skills.tabs.channels': 'চ্যানেল',
|
||||
'skills.tabs.explorer': 'স্কিল',
|
||||
'skills.tabs.meetings': 'Google Meet',
|
||||
'skills.tabs.meetings': 'Google Meet মিটিং',
|
||||
'skills.tabs.mcp': 'MCP সার্ভার',
|
||||
'memory.title': 'মেমোরি',
|
||||
'memory.search': 'মেমোরি খুঁজুন...',
|
||||
@@ -3631,6 +3633,8 @@ const messages: TranslationMap = {
|
||||
'settings.skillsRunner.error.missingRequired': 'প্রয়োজনীয় ইনপুট পাওয়া যায়নি:%s',
|
||||
'settings.skillsRunner.error.run': 'আরম্ভ করতে ব্যর্থ:',
|
||||
'settings.skillsRunner.error.preflightGate': 'প্রিমবট গেট ব্যর্থ হয়েছে',
|
||||
'settings.skillsRunner.error.runtimeUnavailable': 'রানটাইম পাওয়া যাচ্ছে না',
|
||||
'settings.skillsRunner.error.runtimeUnavailableDefault': 'পাওয়া যাচ্ছে না',
|
||||
'settings.skillsRunner.schedule.heading': 'নির্ধারিত সময় অবধি (বিপ)',
|
||||
'settings.skillsRunner.schedule.help':
|
||||
'এই দক্ষতা + ইনপুট একটি পুনরাবৃত্তিমূলক কাজ হিসাবে সংরক্ষণ করো । যে এজেন্টকে ফোন করা হবে প্রত্যেক টিকতে.',
|
||||
@@ -4138,15 +4142,20 @@ const messages: TranslationMap = {
|
||||
'skills.create.title': 'নতুন স্কিল',
|
||||
'skills.detail.allowedTools': 'অনুমোদিত টুলস',
|
||||
'skills.detail.author': 'লেখক',
|
||||
'skills.detail.license': 'লাইসেন্স',
|
||||
'skills.detail.bundledResources': 'বান্ডেলড রিসোর্স',
|
||||
'skills.detail.description': 'বিবরণ',
|
||||
'skills.detail.closeAriaLabel': 'স্কিলের বিবরণ বন্ধ করুন',
|
||||
'skills.detail.location': 'লোকেশন',
|
||||
'skills.detail.noBundledResources': 'কোনো বান্ডেলড রিসোর্স নেই।',
|
||||
'skills.detail.platforms': 'প্ল্যাটফর্ম',
|
||||
'skills.detail.relatedSkills': 'সম্পর্কিত স্কিল',
|
||||
'skills.detail.source': 'উৎস URL',
|
||||
'skills.detail.sourceFormat': 'ফরম্যাট',
|
||||
'skills.detail.stars': 'তারকা',
|
||||
'skills.detail.tags': 'ট্যাগ',
|
||||
'skills.detail.warnings': 'সতর্কতা',
|
||||
'skills.detail.version': 'সংস্করণ',
|
||||
'skills.install.errors.alreadyInstalledHint':
|
||||
'এই স্লাগের সাথে একটি দক্ষতা ইতিমধ্যেই কর্মক্ষেত্রে বিদ্যমান। প্রথমে এটি সরান বা সামনের বস্তু `metadata.id` / `name` পরিবর্তন করুন।',
|
||||
'skills.install.errors.alreadyInstalledTitle': 'দক্ষতা ইতিমধ্যেই ইনস্টল করা হয়েছে',
|
||||
|
||||
+13
-4
@@ -278,12 +278,12 @@ const messages: TranslationMap = {
|
||||
'skills.explorer.subtitle':
|
||||
'Durchsuche SKILL.md-Pakete, einschließlich Hermes-artigem Markdown, Skripten, Referenzen, Vorlagen, Beispielen und Prompts.',
|
||||
'skills.explorer.title': 'Skill-Explorer',
|
||||
'skills.explorer.searchPlaceholder': 'Search skills...',
|
||||
'skills.explorer.searchPlaceholder': 'Skills suchen...',
|
||||
'skills.explorer.scopeUser': 'User',
|
||||
'skills.explorer.scopeProject': 'Project',
|
||||
'skills.explorer.scopeLegacy': 'Legacy',
|
||||
'skills.explorer.noDescription': 'No description provided.',
|
||||
'skills.explorer.uninstallSuccess': 'Skill uninstalled successfully.',
|
||||
'skills.explorer.noDescription': 'Keine Beschreibung angegeben.',
|
||||
'skills.explorer.uninstallSuccess': 'Skill erfolgreich deinstalliert.',
|
||||
'skills.explorer.registryTab': 'Verzeichnis',
|
||||
'skills.explorer.installedTab': 'Installiert',
|
||||
'skills.explorer.allFormats': 'Alle Formate',
|
||||
@@ -291,6 +291,8 @@ const messages: TranslationMap = {
|
||||
'skills.explorer.registryEmptyTitle': 'Keine Verzeichniseinträge',
|
||||
'skills.explorer.registryEmptyDescription':
|
||||
'Das Skill-Verzeichnis konnte nicht geladen werden. Prüfen Sie Ihre Verbindung und versuchen Sie es erneut.',
|
||||
'skills.explorer.showingOf':
|
||||
'{shown} von {total} Ergebnissen angezeigt. Verfeinern Sie Ihre Suche für mehr.',
|
||||
'skills.explorer.installed': 'Installiert',
|
||||
'skills.explorer.install': 'Installieren',
|
||||
'skills.explorer.installing': 'Wird installiert…',
|
||||
@@ -304,7 +306,7 @@ const messages: TranslationMap = {
|
||||
'skills.tabs.composio': 'Composio',
|
||||
'skills.tabs.channels': 'Kanäle',
|
||||
'skills.tabs.explorer': 'Skills',
|
||||
'skills.tabs.meetings': 'Google Meet',
|
||||
'skills.tabs.meetings': 'Google Meet-Besprechungen',
|
||||
'skills.tabs.mcp': 'MCP Server',
|
||||
'memory.title': 'Erinnerung',
|
||||
'memory.search': 'Erinnerungen suchen...',
|
||||
@@ -3724,6 +3726,8 @@ const messages: TranslationMap = {
|
||||
'settings.skillsRunner.error.missingRequired': 'Fehlende erforderliche Eingabe(n):',
|
||||
'settings.skillsRunner.error.run': 'Der Lauf konnte nicht gestartet werden:',
|
||||
'settings.skillsRunner.error.preflightGate': 'Das Preflight-Gate ist ausgefallen',
|
||||
'settings.skillsRunner.error.runtimeUnavailable': 'Laufzeit nicht verfügbar',
|
||||
'settings.skillsRunner.error.runtimeUnavailableDefault': 'nicht verfügbar',
|
||||
'settings.skillsRunner.schedule.heading': 'Zeitplan (wiederkehrend)',
|
||||
'settings.skillsRunner.schedule.help':
|
||||
'Speichern Sie diesen Skill + Eingaben als wiederkehrenden Cronjob. Der Agent ruft bei jedem Tick run_workflow auf.',
|
||||
@@ -4248,15 +4252,20 @@ const messages: TranslationMap = {
|
||||
'skills.create.title': 'Neue Fähigkeit',
|
||||
'skills.detail.allowedTools': 'Erlaubte Werkzeuge',
|
||||
'skills.detail.author': 'Autor',
|
||||
'skills.detail.license': 'Lizenz',
|
||||
'skills.detail.bundledResources': 'Gebündelte Ressourcen',
|
||||
'skills.detail.description': 'Beschreibung',
|
||||
'skills.detail.closeAriaLabel': 'Fertigkeitsdetails schließen',
|
||||
'skills.detail.location': 'Standort',
|
||||
'skills.detail.noBundledResources': 'Keine gebündelten Ressourcen.',
|
||||
'skills.detail.platforms': 'Plattformen',
|
||||
'skills.detail.relatedSkills': 'Verwandte Skills',
|
||||
'skills.detail.source': 'Quell-URL',
|
||||
'skills.detail.sourceFormat': 'Format',
|
||||
'skills.detail.stars': 'Sterne',
|
||||
'skills.detail.tags': 'Schlagworte',
|
||||
'skills.detail.warnings': 'Warnungen',
|
||||
'skills.detail.version': 'Version',
|
||||
'skills.install.errors.alreadyInstalledHint':
|
||||
'Ein Skill mit diesem Slug ist bereits im Arbeitsbereich vorhanden. Entfernen Sie es zuerst oder ändern Sie die Frontmatter „metadata.id“ / „name“.',
|
||||
'skills.install.errors.alreadyInstalledTitle': 'Skill bereits installiert',
|
||||
|
||||
@@ -307,6 +307,8 @@ const en: TranslationMap = {
|
||||
'skills.explorer.registryEmptyTitle': 'No registry entries',
|
||||
'skills.explorer.registryEmptyDescription':
|
||||
'Could not load the skill registry. Check your connection and try refreshing.',
|
||||
'skills.explorer.showingOf':
|
||||
'Showing {shown} of {total} results. Refine your search to see more.',
|
||||
'skills.explorer.installed': 'Installed',
|
||||
'skills.explorer.install': 'Install',
|
||||
'skills.explorer.installing': 'Installing…',
|
||||
@@ -4150,6 +4152,8 @@ const en: TranslationMap = {
|
||||
'settings.skillsRunner.error.missingRequired': 'Missing required input(s):',
|
||||
'settings.skillsRunner.error.run': 'Run failed to start:',
|
||||
'settings.skillsRunner.error.preflightGate': 'Preflight gate failed',
|
||||
'settings.skillsRunner.error.runtimeUnavailable': 'Runtime unavailable',
|
||||
'settings.skillsRunner.error.runtimeUnavailableDefault': 'unavailable',
|
||||
'settings.skillsRunner.schedule.heading': 'Schedule (recurring)',
|
||||
'settings.skillsRunner.schedule.help':
|
||||
'Save this workflow + inputs as a recurring cron job. The agent will call run_workflow at each tick.',
|
||||
@@ -4666,6 +4670,8 @@ const en: TranslationMap = {
|
||||
'skills.create.title': 'New skill',
|
||||
'skills.detail.allowedTools': 'Allowed tools',
|
||||
'skills.detail.author': 'Author',
|
||||
'skills.detail.license': 'License',
|
||||
'skills.detail.description': 'Description',
|
||||
'skills.detail.bundledResources': 'Bundled resources',
|
||||
'skills.detail.run': 'Run',
|
||||
'skills.detail.runAriaLabel': 'Run this workflow',
|
||||
@@ -4675,8 +4681,11 @@ const en: TranslationMap = {
|
||||
'skills.detail.noBundledResources': 'No bundled resources.',
|
||||
'skills.detail.platforms': 'Platforms',
|
||||
'skills.detail.relatedSkills': 'Related skills',
|
||||
'skills.detail.source': 'Source URL',
|
||||
'skills.detail.sourceFormat': 'Format',
|
||||
'skills.detail.stars': 'Stars',
|
||||
'skills.detail.tags': 'Tags',
|
||||
'skills.detail.version': 'Version',
|
||||
'skills.detail.warnings': 'Warnings',
|
||||
'skills.install.errors.alreadyInstalledHint':
|
||||
'A skill with this slug already exists in the workspace. Remove it first or change the frontmatter `metadata.id` / `name`.',
|
||||
|
||||
+13
-4
@@ -279,12 +279,12 @@ const messages: TranslationMap = {
|
||||
'skills.explorer.subtitle':
|
||||
'Explora paquetes SKILL.md, incluidos markdown, scripts, referencias, plantillas, ejemplos y prompts estilo Hermes.',
|
||||
'skills.explorer.title': 'Explorador de skills',
|
||||
'skills.explorer.searchPlaceholder': 'Search skills...',
|
||||
'skills.explorer.searchPlaceholder': 'Buscar skills...',
|
||||
'skills.explorer.scopeUser': 'User',
|
||||
'skills.explorer.scopeProject': 'Project',
|
||||
'skills.explorer.scopeLegacy': 'Legacy',
|
||||
'skills.explorer.noDescription': 'No description provided.',
|
||||
'skills.explorer.uninstallSuccess': 'Skill uninstalled successfully.',
|
||||
'skills.explorer.noDescription': 'No se proporcionó descripción.',
|
||||
'skills.explorer.uninstallSuccess': 'Skill desinstalada correctamente.',
|
||||
'skills.explorer.registryTab': 'Registro',
|
||||
'skills.explorer.installedTab': 'Instaladas',
|
||||
'skills.explorer.allFormats': 'Todos los formatos',
|
||||
@@ -292,6 +292,8 @@ const messages: TranslationMap = {
|
||||
'skills.explorer.registryEmptyTitle': 'Sin entradas en el registro',
|
||||
'skills.explorer.registryEmptyDescription':
|
||||
'No se pudo cargar el registro de habilidades. Verifique su conexión e intente actualizar.',
|
||||
'skills.explorer.showingOf':
|
||||
'Mostrando {shown} de {total} resultados. Refina tu búsqueda para ver más.',
|
||||
'skills.explorer.installed': 'Instalada',
|
||||
'skills.explorer.install': 'Instalar',
|
||||
'skills.explorer.installing': 'Instalando…',
|
||||
@@ -305,7 +307,7 @@ const messages: TranslationMap = {
|
||||
'skills.tabs.composio': 'Composio',
|
||||
'skills.tabs.channels': 'Canales',
|
||||
'skills.tabs.explorer': 'Skills',
|
||||
'skills.tabs.meetings': 'Google Meet',
|
||||
'skills.tabs.meetings': 'Reuniones de Google Meet',
|
||||
'skills.tabs.mcp': 'MCP Servidores',
|
||||
'memory.title': 'Memoria',
|
||||
'memory.search': 'Buscar recuerdos...',
|
||||
@@ -3699,6 +3701,8 @@ const messages: TranslationMap = {
|
||||
'settings.skillsRunner.error.missingRequired': 'Faltan datos de entrada requeridos:',
|
||||
'settings.skillsRunner.error.run': 'Error al iniciar la ejecución:',
|
||||
'settings.skillsRunner.error.preflightGate': 'La puerta de preembarque falló',
|
||||
'settings.skillsRunner.error.runtimeUnavailable': 'Runtime no disponible',
|
||||
'settings.skillsRunner.error.runtimeUnavailableDefault': 'no disponible',
|
||||
'settings.skillsRunner.schedule.heading': 'Horario (recurrente)',
|
||||
'settings.skillsRunner.schedule.help':
|
||||
'Guarda esta habilidad + entradas como un trabajo cron recurrente. El agente llamará a run_workflow en cada tick.',
|
||||
@@ -4220,15 +4224,20 @@ const messages: TranslationMap = {
|
||||
'skills.create.title': 'Nueva habilidad',
|
||||
'skills.detail.allowedTools': 'Herramientas permitidas',
|
||||
'skills.detail.author': 'Autor',
|
||||
'skills.detail.license': 'Licencia',
|
||||
'skills.detail.bundledResources': 'Recursos incluidos',
|
||||
'skills.detail.description': 'Descripción',
|
||||
'skills.detail.closeAriaLabel': 'Cerrar detalles del skill',
|
||||
'skills.detail.location': 'Ubicación',
|
||||
'skills.detail.noBundledResources': 'Sin recursos incluidos.',
|
||||
'skills.detail.platforms': 'Plataformas',
|
||||
'skills.detail.relatedSkills': 'Skills relacionadas',
|
||||
'skills.detail.source': 'URL de origen',
|
||||
'skills.detail.sourceFormat': 'Formato',
|
||||
'skills.detail.stars': 'Estrellas',
|
||||
'skills.detail.tags': 'Etiquetas',
|
||||
'skills.detail.warnings': 'Advertencias',
|
||||
'skills.detail.version': 'Versión',
|
||||
'skills.install.errors.alreadyInstalledHint':
|
||||
'Ya existe una habilidad con esta babosa en el espacio de trabajo. Elimínelo primero o cambie el frontmatter `metadata.id`/`name`.',
|
||||
'skills.install.errors.alreadyInstalledTitle': 'Habilidad ya instalada',
|
||||
|
||||
+13
-4
@@ -277,12 +277,12 @@ const messages: TranslationMap = {
|
||||
'skills.explorer.subtitle':
|
||||
'Parcourez les paquets SKILL.md, y compris markdown, scripts, références, modèles, exemples et prompts de style Hermes.',
|
||||
'skills.explorer.title': 'Explorateur de skills',
|
||||
'skills.explorer.searchPlaceholder': 'Search skills...',
|
||||
'skills.explorer.searchPlaceholder': 'Rechercher des skills...',
|
||||
'skills.explorer.scopeUser': 'User',
|
||||
'skills.explorer.scopeProject': 'Project',
|
||||
'skills.explorer.scopeLegacy': 'Legacy',
|
||||
'skills.explorer.noDescription': 'No description provided.',
|
||||
'skills.explorer.uninstallSuccess': 'Skill uninstalled successfully.',
|
||||
'skills.explorer.noDescription': 'Aucune description fournie.',
|
||||
'skills.explorer.uninstallSuccess': 'Compétence désinstallée avec succès.',
|
||||
'skills.explorer.registryTab': 'Registre',
|
||||
'skills.explorer.installedTab': 'Installées',
|
||||
'skills.explorer.allFormats': 'Tous les formats',
|
||||
@@ -290,6 +290,8 @@ const messages: TranslationMap = {
|
||||
'skills.explorer.registryEmptyTitle': 'Aucune entrée dans le registre',
|
||||
'skills.explorer.registryEmptyDescription':
|
||||
'Impossible de charger le registre de compétences. Vérifiez votre connexion et réessayez.',
|
||||
'skills.explorer.showingOf':
|
||||
'Affichage de {shown} sur {total} résultats. Affinez votre recherche pour en voir plus.',
|
||||
'skills.explorer.installed': 'Installée',
|
||||
'skills.explorer.install': 'Installer',
|
||||
'skills.explorer.installing': 'Installation…',
|
||||
@@ -303,7 +305,7 @@ const messages: TranslationMap = {
|
||||
'skills.tabs.composio': 'Composio',
|
||||
'skills.tabs.channels': 'Canaux',
|
||||
'skills.tabs.explorer': 'Skills',
|
||||
'skills.tabs.meetings': 'Google Meet',
|
||||
'skills.tabs.meetings': 'Réunions Google Meet',
|
||||
'skills.tabs.mcp': 'MCP Serveurs',
|
||||
'memory.title': 'Mémoire',
|
||||
'memory.search': 'Rechercher dans la mémoire…',
|
||||
@@ -3714,6 +3716,8 @@ const messages: TranslationMap = {
|
||||
'settings.skillsRunner.error.missingRequired': 'Entrée(s) requise(s) manquante(s):',
|
||||
'settings.skillsRunner.error.run': "Échec du démarrage de l'exécution:",
|
||||
'settings.skillsRunner.error.preflightGate': 'La porte de prévol a échoué',
|
||||
'settings.skillsRunner.error.runtimeUnavailable': 'Runtime indisponible',
|
||||
'settings.skillsRunner.error.runtimeUnavailableDefault': 'indisponible',
|
||||
'settings.skillsRunner.schedule.heading': 'Planifier (récurrent)',
|
||||
'settings.skillsRunner.schedule.help':
|
||||
"Enregistrez cette compétence + les entrées comme un travail cron récurrent. L'agent appellera run_workflow à chaque tick.",
|
||||
@@ -4236,15 +4240,20 @@ const messages: TranslationMap = {
|
||||
'skills.create.title': 'Nouvelle compétence',
|
||||
'skills.detail.allowedTools': 'Outils autorisés',
|
||||
'skills.detail.author': 'Auteur',
|
||||
'skills.detail.license': 'Licence',
|
||||
'skills.detail.bundledResources': 'Ressources groupées',
|
||||
'skills.detail.description': 'Description',
|
||||
'skills.detail.closeAriaLabel': 'Fermer les détails de la compétence',
|
||||
'skills.detail.location': 'Emplacement',
|
||||
'skills.detail.noBundledResources': 'Aucune ressource groupée.',
|
||||
'skills.detail.platforms': 'Plateformes',
|
||||
'skills.detail.relatedSkills': 'Skills associées',
|
||||
'skills.detail.source': 'URL source',
|
||||
'skills.detail.sourceFormat': 'Format',
|
||||
'skills.detail.stars': 'Étoiles',
|
||||
'skills.detail.tags': 'Étiquettes',
|
||||
'skills.detail.warnings': 'Avertissements',
|
||||
'skills.detail.version': 'Version',
|
||||
'skills.install.errors.alreadyInstalledHint':
|
||||
"Une compétence avec ce slug existe déjà dans l'espace de travail. Supprimez-le d'abord ou modifiez le frontmatter `metadata.id` / `name`.",
|
||||
'skills.install.errors.alreadyInstalledTitle': 'Compétence déjà installée',
|
||||
|
||||
+13
-4
@@ -269,12 +269,12 @@ const messages: TranslationMap = {
|
||||
'skills.explorer.subtitle':
|
||||
'SKILL.md पैकेज ब्राउज़ करें, जिनमें Hermes-शैली markdown, scripts, references, templates, examples और prompts शामिल हैं।',
|
||||
'skills.explorer.title': 'स्किल एक्सप्लोरर',
|
||||
'skills.explorer.searchPlaceholder': 'Search skills...',
|
||||
'skills.explorer.searchPlaceholder': 'स्किल खोजें...',
|
||||
'skills.explorer.scopeUser': 'User',
|
||||
'skills.explorer.scopeProject': 'Project',
|
||||
'skills.explorer.scopeLegacy': 'Legacy',
|
||||
'skills.explorer.noDescription': 'No description provided.',
|
||||
'skills.explorer.uninstallSuccess': 'Skill uninstalled successfully.',
|
||||
'skills.explorer.noDescription': 'कोई विवरण नहीं दिया गया।',
|
||||
'skills.explorer.uninstallSuccess': 'स्किल सफलतापूर्वक अनइंस्टॉल हुई।',
|
||||
'skills.explorer.registryTab': 'रजिस्ट्री',
|
||||
'skills.explorer.installedTab': 'इंस्टॉल किए गए',
|
||||
'skills.explorer.allFormats': 'सभी प्रारूप',
|
||||
@@ -282,6 +282,8 @@ const messages: TranslationMap = {
|
||||
'skills.explorer.registryEmptyTitle': 'कोई रजिस्ट्री प्रविष्टियाँ नहीं',
|
||||
'skills.explorer.registryEmptyDescription':
|
||||
'कौशल रजिस्ट्री लोड नहीं हो सकी। अपना कनेक्शन जांचें और रिफ़्रेश करें।',
|
||||
'skills.explorer.showingOf':
|
||||
'{total} परिणामों में से {shown} दिखाए जा रहे हैं। अधिक देखने के लिए अपनी खोज को परिष्कृत करें।',
|
||||
'skills.explorer.installed': 'इंस्टॉल किया गया',
|
||||
'skills.explorer.install': 'इंस्टॉल करें',
|
||||
'skills.explorer.installing': 'इंस्टॉल हो रहा है…',
|
||||
@@ -295,7 +297,7 @@ const messages: TranslationMap = {
|
||||
'skills.tabs.composio': 'Composio',
|
||||
'skills.tabs.channels': 'चैनल',
|
||||
'skills.tabs.explorer': 'स्किल',
|
||||
'skills.tabs.meetings': 'Google Meet',
|
||||
'skills.tabs.meetings': 'Google Meet बैठकें',
|
||||
'skills.tabs.mcp': 'MCP सर्वर',
|
||||
'memory.title': 'मेमोरी',
|
||||
'memory.search': 'मेमोरी सर्च करें...',
|
||||
@@ -3639,6 +3641,8 @@ const messages: TranslationMap = {
|
||||
'settings.skillsRunner.error.missingRequired': 'मिसिंग आवश्यक इनपुट (s):',
|
||||
'settings.skillsRunner.error.run': 'रन शुरू करने में विफल रहा:',
|
||||
'settings.skillsRunner.error.preflightGate': 'Preflight गेट विफल',
|
||||
'settings.skillsRunner.error.runtimeUnavailable': 'रनटाइम उपलब्ध नहीं है',
|
||||
'settings.skillsRunner.error.runtimeUnavailableDefault': 'उपलब्ध नहीं',
|
||||
'settings.skillsRunner.schedule.heading': 'अनुसूची (आवर्ती)',
|
||||
'settings.skillsRunner.schedule.help':
|
||||
'इस कौशल + इनपुट को एक आवर्ती क्रोन नौकरी के रूप में सहेजें। एजेंट प्रत्येक टिक पर run skill कॉल करेगा।',
|
||||
@@ -4146,15 +4150,20 @@ const messages: TranslationMap = {
|
||||
'skills.create.title': 'नया स्किल',
|
||||
'skills.detail.allowedTools': 'अनुमत टूल्स',
|
||||
'skills.detail.author': 'लेखक',
|
||||
'skills.detail.license': 'लाइसेंस',
|
||||
'skills.detail.bundledResources': 'बंडल्ड रिसोर्सेज़',
|
||||
'skills.detail.description': 'विवरण',
|
||||
'skills.detail.closeAriaLabel': 'स्किल डिटेल्स बंद करें',
|
||||
'skills.detail.location': 'लोकेशन',
|
||||
'skills.detail.noBundledResources': 'कोई बंडल्ड रिसोर्स नहीं।',
|
||||
'skills.detail.platforms': 'प्लैटफ़ॉर्म',
|
||||
'skills.detail.relatedSkills': 'संबंधित स्किल',
|
||||
'skills.detail.source': 'स्रोत URL',
|
||||
'skills.detail.sourceFormat': 'फ़ॉर्मैट',
|
||||
'skills.detail.stars': 'तारे',
|
||||
'skills.detail.tags': 'टैग्स',
|
||||
'skills.detail.warnings': 'चेतावनियाँ',
|
||||
'skills.detail.version': 'संस्करण',
|
||||
'skills.install.errors.alreadyInstalledHint':
|
||||
'इस स्लग के साथ एक कौशल कार्यक्षेत्र में पहले से ही मौजूद है। पहले इसे हटाएँ या फ्रंटमैटर `metadata.id` / `name` बदलें।',
|
||||
'skills.install.errors.alreadyInstalledTitle': 'कौशल पहले से ही स्थापित है',
|
||||
|
||||
+13
-4
@@ -271,12 +271,12 @@ const messages: TranslationMap = {
|
||||
'skills.explorer.subtitle':
|
||||
'Jelajahi paket SKILL.md, termasuk markdown, skrip, referensi, templat, contoh, dan prompt bergaya Hermes.',
|
||||
'skills.explorer.title': 'Penjelajah skill',
|
||||
'skills.explorer.searchPlaceholder': 'Search skills...',
|
||||
'skills.explorer.searchPlaceholder': 'Cari skill...',
|
||||
'skills.explorer.scopeUser': 'User',
|
||||
'skills.explorer.scopeProject': 'Project',
|
||||
'skills.explorer.scopeLegacy': 'Legacy',
|
||||
'skills.explorer.noDescription': 'No description provided.',
|
||||
'skills.explorer.uninstallSuccess': 'Skill uninstalled successfully.',
|
||||
'skills.explorer.noDescription': 'Tidak ada deskripsi.',
|
||||
'skills.explorer.uninstallSuccess': 'Skill berhasil dihapus.',
|
||||
'skills.explorer.registryTab': 'Registri',
|
||||
'skills.explorer.installedTab': 'Terpasang',
|
||||
'skills.explorer.allFormats': 'Semua format',
|
||||
@@ -284,6 +284,8 @@ const messages: TranslationMap = {
|
||||
'skills.explorer.registryEmptyTitle': 'Tidak ada entri registri',
|
||||
'skills.explorer.registryEmptyDescription':
|
||||
'Tidak dapat memuat registri keterampilan. Periksa koneksi Anda dan coba segarkan.',
|
||||
'skills.explorer.showingOf':
|
||||
'Menampilkan {shown} dari {total} hasil. Perbaiki pencarian Anda untuk melihat lebih banyak.',
|
||||
'skills.explorer.installed': 'Terpasang',
|
||||
'skills.explorer.install': 'Pasang',
|
||||
'skills.explorer.installing': 'Memasang…',
|
||||
@@ -297,7 +299,7 @@ const messages: TranslationMap = {
|
||||
'skills.tabs.composio': 'Composio',
|
||||
'skills.tabs.channels': 'Saluran',
|
||||
'skills.tabs.explorer': 'Skill',
|
||||
'skills.tabs.meetings': 'Google Meet',
|
||||
'skills.tabs.meetings': 'Rapat Google Meet',
|
||||
'skills.tabs.mcp': 'MCP Server',
|
||||
'memory.title': 'Memori',
|
||||
'memory.search': 'Cari memori...',
|
||||
@@ -3644,6 +3646,8 @@ const messages: TranslationMap = {
|
||||
'settings.skillsRunner.error.missingRequired': 'Kehilangan masukan yang diperlukan:',
|
||||
'settings.skillsRunner.error.run': 'Jalankan gagal untuk dimulai:',
|
||||
'settings.skillsRunner.error.preflightGate': 'Gerbang prapenerbangan gagal',
|
||||
'settings.skillsRunner.error.runtimeUnavailable': 'Runtime tidak tersedia',
|
||||
'settings.skillsRunner.error.runtimeUnavailableDefault': 'tidak tersedia',
|
||||
'settings.skillsRunner.schedule.heading': 'Jadwal (berulang)',
|
||||
'settings.skillsRunner.schedule.help':
|
||||
'Simpan keterampilan ini + masukan sebagai tugas berulang cron. Agen akan memanggil run-skill di setiap tick.',
|
||||
@@ -4155,15 +4159,20 @@ const messages: TranslationMap = {
|
||||
'skills.create.title': 'Skill baru',
|
||||
'skills.detail.allowedTools': 'Alat yang diizinkan',
|
||||
'skills.detail.author': 'Penulis',
|
||||
'skills.detail.license': 'Lisensi',
|
||||
'skills.detail.bundledResources': 'Sumber daya bundel',
|
||||
'skills.detail.description': 'Deskripsi',
|
||||
'skills.detail.closeAriaLabel': 'Tutup detail skill',
|
||||
'skills.detail.location': 'Lokasi',
|
||||
'skills.detail.noBundledResources': 'Tidak ada sumber daya bundel.',
|
||||
'skills.detail.platforms': 'Platform',
|
||||
'skills.detail.relatedSkills': 'Skill terkait',
|
||||
'skills.detail.source': 'URL Sumber',
|
||||
'skills.detail.sourceFormat': 'Format',
|
||||
'skills.detail.stars': 'Bintang',
|
||||
'skills.detail.tags': 'Tag',
|
||||
'skills.detail.warnings': 'Peringatan',
|
||||
'skills.detail.version': 'Versi',
|
||||
'skills.install.errors.alreadyInstalledHint':
|
||||
'Keterampilan dengan siput ini sudah ada di ruang kerja. Hapus dulu atau ubah frontmatter `metadata.id` / `name`.',
|
||||
'skills.install.errors.alreadyInstalledTitle': 'Keterampilan sudah terpasang',
|
||||
|
||||
+13
-4
@@ -275,12 +275,12 @@ const messages: TranslationMap = {
|
||||
'skills.explorer.subtitle':
|
||||
'Sfoglia pacchetti SKILL.md, inclusi markdown, script, riferimenti, modelli, esempi e prompt in stile Hermes.',
|
||||
'skills.explorer.title': 'Esplora skill',
|
||||
'skills.explorer.searchPlaceholder': 'Search skills...',
|
||||
'skills.explorer.searchPlaceholder': 'Cerca skill...',
|
||||
'skills.explorer.scopeUser': 'User',
|
||||
'skills.explorer.scopeProject': 'Project',
|
||||
'skills.explorer.scopeLegacy': 'Legacy',
|
||||
'skills.explorer.noDescription': 'No description provided.',
|
||||
'skills.explorer.uninstallSuccess': 'Skill uninstalled successfully.',
|
||||
'skills.explorer.noDescription': 'Nessuna descrizione fornita.',
|
||||
'skills.explorer.uninstallSuccess': 'Skill disinstallata correttamente.',
|
||||
'skills.explorer.registryTab': 'Registro',
|
||||
'skills.explorer.installedTab': 'Installate',
|
||||
'skills.explorer.allFormats': 'Tutti i formati',
|
||||
@@ -288,6 +288,8 @@ const messages: TranslationMap = {
|
||||
'skills.explorer.registryEmptyTitle': 'Nessuna voce nel registro',
|
||||
'skills.explorer.registryEmptyDescription':
|
||||
'Impossibile caricare il registro delle competenze. Controlla la connessione e riprova.',
|
||||
'skills.explorer.showingOf':
|
||||
'Visualizzazione di {shown} su {total} risultati. Perfeziona la ricerca per vederne di più.',
|
||||
'skills.explorer.installed': 'Installata',
|
||||
'skills.explorer.install': 'Installa',
|
||||
'skills.explorer.installing': 'Installazione…',
|
||||
@@ -301,7 +303,7 @@ const messages: TranslationMap = {
|
||||
'skills.tabs.composio': 'Composio',
|
||||
'skills.tabs.channels': 'Canali',
|
||||
'skills.tabs.explorer': 'Skill',
|
||||
'skills.tabs.meetings': 'Google Meet',
|
||||
'skills.tabs.meetings': 'Riunioni Google Meet',
|
||||
'skills.tabs.mcp': 'MCP Server',
|
||||
'memory.title': 'Memoria',
|
||||
'memory.search': 'Cerca memorie...',
|
||||
@@ -3693,6 +3695,8 @@ const messages: TranslationMap = {
|
||||
'settings.skillsRunner.error.missingRequired': 'Input richiesto mancante:',
|
||||
'settings.skillsRunner.error.run': "Esecuzione fallita all'avvio:",
|
||||
'settings.skillsRunner.error.preflightGate': 'Il gate di prevolo non è riuscito',
|
||||
'settings.skillsRunner.error.runtimeUnavailable': 'Runtime non disponibile',
|
||||
'settings.skillsRunner.error.runtimeUnavailableDefault': 'non disponibile',
|
||||
'settings.skillsRunner.schedule.heading': 'Programma (ricorrente)',
|
||||
'settings.skillsRunner.schedule.help':
|
||||
"Salva questa abilità + input come un lavoro cron ricorrente. L'agente chiamerà run_workflow ad ogni ciclo.",
|
||||
@@ -4213,15 +4217,20 @@ const messages: TranslationMap = {
|
||||
'skills.create.title': 'Nuova skill',
|
||||
'skills.detail.allowedTools': 'Strumenti consentiti',
|
||||
'skills.detail.author': 'Autore',
|
||||
'skills.detail.license': 'Licenza',
|
||||
'skills.detail.bundledResources': 'Risorse incluse',
|
||||
'skills.detail.description': 'Descrizione',
|
||||
'skills.detail.closeAriaLabel': 'Chiudi dettagli skill',
|
||||
'skills.detail.location': 'Posizione',
|
||||
'skills.detail.noBundledResources': 'Nessuna risorsa inclusa.',
|
||||
'skills.detail.platforms': 'Piattaforme',
|
||||
'skills.detail.relatedSkills': 'Skill correlate',
|
||||
'skills.detail.source': 'URL sorgente',
|
||||
'skills.detail.sourceFormat': 'Formato',
|
||||
'skills.detail.stars': 'Stelle',
|
||||
'skills.detail.tags': 'Tag',
|
||||
'skills.detail.warnings': 'Avvertenze',
|
||||
'skills.detail.version': 'Versione',
|
||||
'skills.install.errors.alreadyInstalledHint':
|
||||
"Una skill con questo slug esiste già nell'area di lavoro. Rimuovilo prima o modifica il frontmatter `metadata.id` / `name`.",
|
||||
'skills.install.errors.alreadyInstalledTitle': 'Competenza già installata',
|
||||
|
||||
+13
-4
@@ -269,12 +269,12 @@ const messages: TranslationMap = {
|
||||
'skills.explorer.subtitle':
|
||||
'Hermes 스타일 markdown, scripts, references, templates, examples, prompts를 포함한 SKILL.md 패키지를 탐색합니다.',
|
||||
'skills.explorer.title': '스킬 탐색기',
|
||||
'skills.explorer.searchPlaceholder': 'Search skills...',
|
||||
'skills.explorer.searchPlaceholder': '스킬 검색...',
|
||||
'skills.explorer.scopeUser': 'User',
|
||||
'skills.explorer.scopeProject': 'Project',
|
||||
'skills.explorer.scopeLegacy': 'Legacy',
|
||||
'skills.explorer.noDescription': 'No description provided.',
|
||||
'skills.explorer.uninstallSuccess': 'Skill uninstalled successfully.',
|
||||
'skills.explorer.noDescription': '설명이 제공되지 않았습니다.',
|
||||
'skills.explorer.uninstallSuccess': '스킬이 성공적으로 제거되었습니다.',
|
||||
'skills.explorer.registryTab': '레지스트리',
|
||||
'skills.explorer.installedTab': '설치됨',
|
||||
'skills.explorer.allFormats': '모든 형식',
|
||||
@@ -282,6 +282,8 @@ const messages: TranslationMap = {
|
||||
'skills.explorer.registryEmptyTitle': '레지스트리 항목 없음',
|
||||
'skills.explorer.registryEmptyDescription':
|
||||
'스킬 레지스트리를 로드할 수 없습니다. 연결을 확인하고 새로고침해 보세요.',
|
||||
'skills.explorer.showingOf':
|
||||
'{total}개 결과 중 {shown}개 표시. 검색을 세분화하여 더 많이 보세요.',
|
||||
'skills.explorer.installed': '설치됨',
|
||||
'skills.explorer.install': '설치',
|
||||
'skills.explorer.installing': '설치 중…',
|
||||
@@ -295,7 +297,7 @@ const messages: TranslationMap = {
|
||||
'skills.tabs.composio': 'Composio',
|
||||
'skills.tabs.channels': '채널',
|
||||
'skills.tabs.explorer': '스킬',
|
||||
'skills.tabs.meetings': 'Google Meet',
|
||||
'skills.tabs.meetings': 'Google Meet 회의',
|
||||
'skills.tabs.mcp': 'MCP 서버',
|
||||
'memory.title': '메모리',
|
||||
'memory.search': '메모리 검색...',
|
||||
@@ -3598,6 +3600,8 @@ const messages: TranslationMap = {
|
||||
'settings.skillsRunner.error.missingRequired': '필수 입력 누락:',
|
||||
'settings.skillsRunner.error.run': '실행을 시작하지 못했습니다:',
|
||||
'settings.skillsRunner.error.preflightGate': '사전 점검 게이트 실패',
|
||||
'settings.skillsRunner.error.runtimeUnavailable': '런타임을 사용할 수 없음',
|
||||
'settings.skillsRunner.error.runtimeUnavailableDefault': '사용할 수 없음',
|
||||
'settings.skillsRunner.schedule.heading': '일정(반복)',
|
||||
'settings.skillsRunner.schedule.help':
|
||||
'이 스킬과 입력을 반복 cron 작업으로 저장합니다. 에이전트는 각 tick마다 run_workflow을 호출합니다.',
|
||||
@@ -4097,15 +4101,20 @@ const messages: TranslationMap = {
|
||||
'skills.create.title': '새 스킬',
|
||||
'skills.detail.allowedTools': '허용된 도구',
|
||||
'skills.detail.author': '작성자',
|
||||
'skills.detail.license': '라이선스',
|
||||
'skills.detail.bundledResources': '번들 리소스',
|
||||
'skills.detail.description': '설명',
|
||||
'skills.detail.closeAriaLabel': '스킬 세부 정보 닫기',
|
||||
'skills.detail.location': '위치',
|
||||
'skills.detail.noBundledResources': '번들 리소스가 없습니다.',
|
||||
'skills.detail.platforms': '플랫폼',
|
||||
'skills.detail.relatedSkills': '관련 스킬',
|
||||
'skills.detail.source': '소스 URL',
|
||||
'skills.detail.sourceFormat': '형식',
|
||||
'skills.detail.stars': '별점',
|
||||
'skills.detail.tags': '태그',
|
||||
'skills.detail.warnings': '경고',
|
||||
'skills.detail.version': '버전',
|
||||
'skills.install.errors.alreadyInstalledHint':
|
||||
'이 슬러그와 관련된 스킬이 이미 작업공간에 존재합니다. 먼저 제거하거나 `metadata.id` / `name` 머리말을 변경하세요.',
|
||||
'skills.install.errors.alreadyInstalledTitle': '스킬이 이미 설치되어 있습니다.',
|
||||
|
||||
+13
-4
@@ -273,12 +273,12 @@ const messages: TranslationMap = {
|
||||
'skills.explorer.subtitle':
|
||||
'Przeglądaj pakiety SKILL.md, w tym markdown, skrypty, referencje, szablony, przykłady i prompty w stylu Hermes.',
|
||||
'skills.explorer.title': 'Eksplorator skilli',
|
||||
'skills.explorer.searchPlaceholder': 'Search skills...',
|
||||
'skills.explorer.searchPlaceholder': 'Szukaj skilli...',
|
||||
'skills.explorer.scopeUser': 'User',
|
||||
'skills.explorer.scopeProject': 'Project',
|
||||
'skills.explorer.scopeLegacy': 'Legacy',
|
||||
'skills.explorer.noDescription': 'No description provided.',
|
||||
'skills.explorer.uninstallSuccess': 'Skill uninstalled successfully.',
|
||||
'skills.explorer.noDescription': 'Nie podano opisu.',
|
||||
'skills.explorer.uninstallSuccess': 'Skill odinstalowany pomyślnie.',
|
||||
'skills.explorer.registryTab': 'Rejestr',
|
||||
'skills.explorer.installedTab': 'Zainstalowane',
|
||||
'skills.explorer.allFormats': 'Wszystkie formaty',
|
||||
@@ -286,6 +286,8 @@ const messages: TranslationMap = {
|
||||
'skills.explorer.registryEmptyTitle': 'Brak wpisów w rejestrze',
|
||||
'skills.explorer.registryEmptyDescription':
|
||||
'Nie udało się załadować rejestru umiejętności. Sprawdź połączenie i spróbuj odświeżyć.',
|
||||
'skills.explorer.showingOf':
|
||||
'Wyświetlanie {shown} z {total} wyników. Zawęź wyszukiwanie, aby zobaczyć więcej.',
|
||||
'skills.explorer.installed': 'Zainstalowano',
|
||||
'skills.explorer.install': 'Zainstaluj',
|
||||
'skills.explorer.installing': 'Instalowanie…',
|
||||
@@ -299,7 +301,7 @@ const messages: TranslationMap = {
|
||||
'skills.tabs.composio': 'Composio',
|
||||
'skills.tabs.channels': 'Kanały',
|
||||
'skills.tabs.explorer': 'Skille',
|
||||
'skills.tabs.meetings': 'Google Meet',
|
||||
'skills.tabs.meetings': 'Spotkania Google Meet',
|
||||
'skills.tabs.mcp': 'Serwery MCP',
|
||||
'memory.title': 'Pamięć',
|
||||
'memory.search': 'Szukaj w pamięci...',
|
||||
@@ -3693,6 +3695,8 @@ const messages: TranslationMap = {
|
||||
'settings.skillsRunner.error.missingRequired': 'Brak wymaganych danych wejściowych:',
|
||||
'settings.skillsRunner.error.run': 'Nie udało się rozpocząć uruchomienia:',
|
||||
'settings.skillsRunner.error.preflightGate': 'Brama preflight nie powiodła się',
|
||||
'settings.skillsRunner.error.runtimeUnavailable': 'Runtime niedostępny',
|
||||
'settings.skillsRunner.error.runtimeUnavailableDefault': 'niedostępny',
|
||||
'settings.skillsRunner.schedule.heading': 'Harmonogram (cykliczny)',
|
||||
'settings.skillsRunner.schedule.help':
|
||||
'Zapisz tę umiejętność i dane wejściowe jako cykliczne zadanie cron. Agent wywoła run_workflow przy każdym tyknięciu.',
|
||||
@@ -4208,15 +4212,20 @@ const messages: TranslationMap = {
|
||||
'skills.create.title': 'Nowa umiejętność',
|
||||
'skills.detail.allowedTools': 'Dozwolone narzędzia',
|
||||
'skills.detail.author': 'Autor',
|
||||
'skills.detail.license': 'Licencja',
|
||||
'skills.detail.bundledResources': 'Dołączone zasoby',
|
||||
'skills.detail.description': 'Opis',
|
||||
'skills.detail.closeAriaLabel': 'Zamknij szczegóły umiejętności',
|
||||
'skills.detail.location': 'Lokalizacja',
|
||||
'skills.detail.noBundledResources': 'Brak dołączonych zasobów.',
|
||||
'skills.detail.platforms': 'Platformy',
|
||||
'skills.detail.relatedSkills': 'Powiązane skille',
|
||||
'skills.detail.source': 'URL źródła',
|
||||
'skills.detail.sourceFormat': 'Format',
|
||||
'skills.detail.stars': 'Gwiazdki',
|
||||
'skills.detail.tags': 'Tagi',
|
||||
'skills.detail.warnings': 'Ostrzeżenia',
|
||||
'skills.detail.version': 'Wersja',
|
||||
'skills.install.errors.alreadyInstalledHint':
|
||||
'Umiejętność z tym slugiem już istnieje w przestrzeni roboczej. Usuń ją najpierw lub zmień `metadata.id` / `name` w frontmatter.',
|
||||
'skills.install.errors.alreadyInstalledTitle': 'Umiejętność już zainstalowana',
|
||||
|
||||
+13
-4
@@ -277,12 +277,12 @@ const messages: TranslationMap = {
|
||||
'skills.explorer.subtitle':
|
||||
'Explore pacotes SKILL.md, incluindo markdown, scripts, referências, modelos, exemplos e prompts no estilo Hermes.',
|
||||
'skills.explorer.title': 'Explorador de skills',
|
||||
'skills.explorer.searchPlaceholder': 'Search skills...',
|
||||
'skills.explorer.searchPlaceholder': 'Buscar skills...',
|
||||
'skills.explorer.scopeUser': 'User',
|
||||
'skills.explorer.scopeProject': 'Project',
|
||||
'skills.explorer.scopeLegacy': 'Legacy',
|
||||
'skills.explorer.noDescription': 'No description provided.',
|
||||
'skills.explorer.uninstallSuccess': 'Skill uninstalled successfully.',
|
||||
'skills.explorer.noDescription': 'Nenhuma descrição fornecida.',
|
||||
'skills.explorer.uninstallSuccess': 'Skill desinstalada com sucesso.',
|
||||
'skills.explorer.registryTab': 'Registro',
|
||||
'skills.explorer.installedTab': 'Instaladas',
|
||||
'skills.explorer.allFormats': 'Todos os formatos',
|
||||
@@ -290,6 +290,8 @@ const messages: TranslationMap = {
|
||||
'skills.explorer.registryEmptyTitle': 'Sem entradas no registro',
|
||||
'skills.explorer.registryEmptyDescription':
|
||||
'Não foi possível carregar o registro de habilidades. Verifique sua conexão e tente atualizar.',
|
||||
'skills.explorer.showingOf':
|
||||
'Mostrando {shown} de {total} resultados. Refine sua busca para ver mais.',
|
||||
'skills.explorer.installed': 'Instalada',
|
||||
'skills.explorer.install': 'Instalar',
|
||||
'skills.explorer.installing': 'Instalando…',
|
||||
@@ -303,7 +305,7 @@ const messages: TranslationMap = {
|
||||
'skills.tabs.composio': 'Composio',
|
||||
'skills.tabs.channels': 'Canais',
|
||||
'skills.tabs.explorer': 'Skills',
|
||||
'skills.tabs.meetings': 'Google Meet',
|
||||
'skills.tabs.meetings': 'Reuniões do Google Meet',
|
||||
'skills.tabs.mcp': 'MCP Servidores',
|
||||
'memory.title': 'Memória',
|
||||
'memory.search': 'Pesquisar memórias...',
|
||||
@@ -3693,6 +3695,8 @@ const messages: TranslationMap = {
|
||||
'settings.skillsRunner.error.missingRequired': 'Entrada(s) obrigatória(s) ausente(s):',
|
||||
'settings.skillsRunner.error.run': 'Falha ao iniciar a execução:',
|
||||
'settings.skillsRunner.error.preflightGate': 'Portão de embarque pré-voo falhou',
|
||||
'settings.skillsRunner.error.runtimeUnavailable': 'Runtime indisponível',
|
||||
'settings.skillsRunner.error.runtimeUnavailableDefault': 'indisponível',
|
||||
'settings.skillsRunner.schedule.heading': 'Agenda (recorrente)',
|
||||
'settings.skillsRunner.schedule.help':
|
||||
'Salve esta habilidade + entradas como um trabalho cron recorrente. O agente chamará run_workflow a cada tick.',
|
||||
@@ -4213,15 +4217,20 @@ const messages: TranslationMap = {
|
||||
'skills.create.title': 'Nova skill',
|
||||
'skills.detail.allowedTools': 'Ferramentas permitidas',
|
||||
'skills.detail.author': 'Autor',
|
||||
'skills.detail.license': 'Licença',
|
||||
'skills.detail.bundledResources': 'Recursos incluídos',
|
||||
'skills.detail.description': 'Descrição',
|
||||
'skills.detail.closeAriaLabel': 'Fechar detalhes da habilidade',
|
||||
'skills.detail.location': 'Localização',
|
||||
'skills.detail.noBundledResources': 'Sem recursos incluídos.',
|
||||
'skills.detail.platforms': 'Plataformas',
|
||||
'skills.detail.relatedSkills': 'Skills relacionadas',
|
||||
'skills.detail.source': 'URL de origem',
|
||||
'skills.detail.sourceFormat': 'Formato',
|
||||
'skills.detail.stars': 'Estrelas',
|
||||
'skills.detail.tags': 'Tags',
|
||||
'skills.detail.warnings': 'Avisos',
|
||||
'skills.detail.version': 'Versão',
|
||||
'skills.install.errors.alreadyInstalledHint':
|
||||
'Uma habilidade com este slug já existe no espaço de trabalho. Remova-o primeiro ou altere o frontmatter `metadata.id` / `name`.',
|
||||
'skills.install.errors.alreadyInstalledTitle': 'Habilidade já instalada',
|
||||
|
||||
+13
-4
@@ -271,12 +271,12 @@ const messages: TranslationMap = {
|
||||
'skills.explorer.subtitle':
|
||||
'Просматривайте пакеты SKILL.md, включая markdown, скрипты, справочные материалы, шаблоны, примеры и промпты в стиле Hermes.',
|
||||
'skills.explorer.title': 'Обозреватель навыков',
|
||||
'skills.explorer.searchPlaceholder': 'Search skills...',
|
||||
'skills.explorer.searchPlaceholder': 'Искать навыки...',
|
||||
'skills.explorer.scopeUser': 'User',
|
||||
'skills.explorer.scopeProject': 'Project',
|
||||
'skills.explorer.scopeLegacy': 'Legacy',
|
||||
'skills.explorer.noDescription': 'No description provided.',
|
||||
'skills.explorer.uninstallSuccess': 'Skill uninstalled successfully.',
|
||||
'skills.explorer.noDescription': 'Описание не указано.',
|
||||
'skills.explorer.uninstallSuccess': 'Навык успешно удалён.',
|
||||
'skills.explorer.registryTab': 'Реестр',
|
||||
'skills.explorer.installedTab': 'Установленные',
|
||||
'skills.explorer.allFormats': 'Все форматы',
|
||||
@@ -284,6 +284,8 @@ const messages: TranslationMap = {
|
||||
'skills.explorer.registryEmptyTitle': 'Нет записей в реестре',
|
||||
'skills.explorer.registryEmptyDescription':
|
||||
'Не удалось загрузить реестр навыков. Проверьте соединение и попробуйте обновить.',
|
||||
'skills.explorer.showingOf':
|
||||
'Показано {shown} из {total} результатов. Уточните поиск, чтобы увидеть больше.',
|
||||
'skills.explorer.installed': 'Установлено',
|
||||
'skills.explorer.install': 'Установить',
|
||||
'skills.explorer.installing': 'Установка…',
|
||||
@@ -297,7 +299,7 @@ const messages: TranslationMap = {
|
||||
'skills.tabs.composio': 'Composio',
|
||||
'skills.tabs.channels': 'Каналы',
|
||||
'skills.tabs.explorer': 'Навыки',
|
||||
'skills.tabs.meetings': 'Google Meet',
|
||||
'skills.tabs.meetings': 'Встречи Google Meet',
|
||||
'skills.tabs.mcp': 'MCP Серверы',
|
||||
'memory.title': 'Память',
|
||||
'memory.search': 'Поиск воспоминаний...',
|
||||
@@ -3662,6 +3664,8 @@ const messages: TranslationMap = {
|
||||
'settings.skillsRunner.error.missingRequired': 'Отсутствуют необходимые входные данные:',
|
||||
'settings.skillsRunner.error.run': 'Не удалось запустить запуск:',
|
||||
'settings.skillsRunner.error.preflightGate': 'Предполетный шлюз вышел из строя',
|
||||
'settings.skillsRunner.error.runtimeUnavailable': 'Среда выполнения недоступна',
|
||||
'settings.skillsRunner.error.runtimeUnavailableDefault': 'недоступно',
|
||||
'settings.skillsRunner.schedule.heading': 'Расписание (повторяющееся)',
|
||||
'settings.skillsRunner.schedule.help':
|
||||
'Сохраните этот навык + входные данные как повторяющееся задание cron. Агент будет вызывать run_workflow при каждом тике.',
|
||||
@@ -4177,15 +4181,20 @@ const messages: TranslationMap = {
|
||||
'skills.create.title': 'Новый навык',
|
||||
'skills.detail.allowedTools': 'Разрешённые инструменты',
|
||||
'skills.detail.author': 'Автор',
|
||||
'skills.detail.license': 'Лицензия',
|
||||
'skills.detail.bundledResources': 'Встроенные ресурсы',
|
||||
'skills.detail.description': 'Описание',
|
||||
'skills.detail.closeAriaLabel': 'Закрыть детали навыка',
|
||||
'skills.detail.location': 'Расположение',
|
||||
'skills.detail.noBundledResources': 'Встроенных ресурсов нет.',
|
||||
'skills.detail.platforms': 'Платформы',
|
||||
'skills.detail.relatedSkills': 'Связанные навыки',
|
||||
'skills.detail.source': 'URL источника',
|
||||
'skills.detail.sourceFormat': 'Формат',
|
||||
'skills.detail.stars': 'Звёзды',
|
||||
'skills.detail.tags': 'Теги',
|
||||
'skills.detail.warnings': 'Предупреждения',
|
||||
'skills.detail.version': 'Версия',
|
||||
'skills.install.errors.alreadyInstalledHint':
|
||||
'Навык работы с этим пулеметом уже существует в рабочей области. Сначала удалите его или измените заголовок `metadata.id`/`name`.',
|
||||
'skills.install.errors.alreadyInstalledTitle': 'Навык уже установлен.',
|
||||
|
||||
@@ -258,18 +258,19 @@ const messages: TranslationMap = {
|
||||
'skills.explorer.subtitle':
|
||||
'浏览 SKILL.md 包,包括 Hermes 风格的 Markdown、脚本、参考资料、模板、示例和提示词。',
|
||||
'skills.explorer.title': '技能浏览器',
|
||||
'skills.explorer.searchPlaceholder': 'Search skills...',
|
||||
'skills.explorer.searchPlaceholder': '搜索技能...',
|
||||
'skills.explorer.scopeUser': 'User',
|
||||
'skills.explorer.scopeProject': 'Project',
|
||||
'skills.explorer.scopeLegacy': 'Legacy',
|
||||
'skills.explorer.noDescription': 'No description provided.',
|
||||
'skills.explorer.uninstallSuccess': 'Skill uninstalled successfully.',
|
||||
'skills.explorer.noDescription': '未提供描述。',
|
||||
'skills.explorer.uninstallSuccess': '技能已成功卸载。',
|
||||
'skills.explorer.registryTab': '注册表',
|
||||
'skills.explorer.installedTab': '已安装',
|
||||
'skills.explorer.allFormats': '所有格式',
|
||||
'skills.explorer.refreshRegistry': '刷新注册表',
|
||||
'skills.explorer.registryEmptyTitle': '没有注册表条目',
|
||||
'skills.explorer.registryEmptyDescription': '无法加载技能注册表。请检查您的连接并尝试刷新。',
|
||||
'skills.explorer.showingOf': '显示 {total} 个结果中的 {shown} 个。请优化搜索以查看更多。',
|
||||
'skills.explorer.installed': '已安装',
|
||||
'skills.explorer.install': '安装',
|
||||
'skills.explorer.installing': '安装中…',
|
||||
@@ -283,7 +284,7 @@ const messages: TranslationMap = {
|
||||
'skills.tabs.composio': 'Composio',
|
||||
'skills.tabs.channels': '渠道',
|
||||
'skills.tabs.explorer': '技能',
|
||||
'skills.tabs.meetings': 'Google Meet',
|
||||
'skills.tabs.meetings': 'Google Meet 会议',
|
||||
'skills.tabs.mcp': 'MCP 服务器',
|
||||
'memory.title': '记忆',
|
||||
'memory.search': '搜索记忆...',
|
||||
@@ -3456,6 +3457,8 @@ const messages: TranslationMap = {
|
||||
'settings.skillsRunner.error.missingRequired': '缺少必填输入:',
|
||||
'settings.skillsRunner.error.run': '启动运行失败:',
|
||||
'settings.skillsRunner.error.preflightGate': '预检门禁失败',
|
||||
'settings.skillsRunner.error.runtimeUnavailable': '运行时不可用',
|
||||
'settings.skillsRunner.error.runtimeUnavailableDefault': '不可用',
|
||||
'settings.skillsRunner.schedule.heading': '计划(重复)',
|
||||
'settings.skillsRunner.schedule.help':
|
||||
'将此技能和输入保存为重复 cron 任务。智能体会在每次触发时调用 run_workflow。',
|
||||
@@ -3936,15 +3939,20 @@ const messages: TranslationMap = {
|
||||
'skills.create.title': '新建技能',
|
||||
'skills.detail.allowedTools': '允许的工具',
|
||||
'skills.detail.author': '作者',
|
||||
'skills.detail.license': '许可证',
|
||||
'skills.detail.bundledResources': '捆绑资源',
|
||||
'skills.detail.description': '描述',
|
||||
'skills.detail.closeAriaLabel': '关闭技能详情',
|
||||
'skills.detail.location': '位置',
|
||||
'skills.detail.noBundledResources': '无捆绑资源。',
|
||||
'skills.detail.platforms': '平台',
|
||||
'skills.detail.relatedSkills': '相关技能',
|
||||
'skills.detail.source': '来源 URL',
|
||||
'skills.detail.sourceFormat': '格式',
|
||||
'skills.detail.stars': '星标',
|
||||
'skills.detail.tags': '标签',
|
||||
'skills.detail.warnings': '警告',
|
||||
'skills.detail.version': '版本',
|
||||
'skills.install.errors.alreadyInstalledHint':
|
||||
'工作区中已存在此 slug 的技能。首先将其删除或更改 frontmatter `metadata.id` / `name`。',
|
||||
'skills.install.errors.alreadyInstalledTitle': '技能已安装',
|
||||
|
||||
@@ -333,7 +333,7 @@ describe('workflowsApi.uninstallWorkflow', () => {
|
||||
const result = await workflowsApi.uninstallWorkflow('weather-helper');
|
||||
|
||||
expect(callCoreRpc).toHaveBeenCalledWith({
|
||||
method: 'openhuman.workflows_uninstall',
|
||||
method: 'openhuman.skill_registry_uninstall',
|
||||
params: { name: 'weather-helper' },
|
||||
});
|
||||
expect(result).toEqual({
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { skillRegistryApi } from './skillRegistryApi';
|
||||
|
||||
const mockCallCoreRpc = vi.fn();
|
||||
vi.mock('../coreRpcClient', () => ({ callCoreRpc: (...a: unknown[]) => mockCallCoreRpc(...a) }));
|
||||
|
||||
describe('skillRegistryApi', () => {
|
||||
beforeEach(() => {
|
||||
mockCallCoreRpc.mockReset();
|
||||
});
|
||||
|
||||
it('normalizes install new_skills to newSkills', async () => {
|
||||
mockCallCoreRpc.mockResolvedValue({
|
||||
url: 'https://example.com/SKILL.md',
|
||||
stdout: 'ok',
|
||||
stderr: '',
|
||||
new_skills: ['demo'],
|
||||
});
|
||||
|
||||
const result = await skillRegistryApi.install('demo');
|
||||
|
||||
expect(mockCallCoreRpc).toHaveBeenCalledWith({
|
||||
method: 'openhuman.skill_registry_install',
|
||||
params: { entry_id: 'demo' },
|
||||
});
|
||||
expect(result.newSkills).toEqual(['demo']);
|
||||
});
|
||||
|
||||
it('calls skill_registry_uninstall and normalizes removed_path', async () => {
|
||||
mockCallCoreRpc.mockResolvedValue({
|
||||
name: 'demo',
|
||||
removed_path: '/Users/test/.openhuman/skills/demo',
|
||||
scope: 'user',
|
||||
});
|
||||
|
||||
const result = await skillRegistryApi.uninstall('demo');
|
||||
|
||||
expect(mockCallCoreRpc).toHaveBeenCalledWith({
|
||||
method: 'openhuman.skill_registry_uninstall',
|
||||
params: { name: 'demo' },
|
||||
});
|
||||
expect(result.removedPath).toBe('/Users/test/.openhuman/skills/demo');
|
||||
});
|
||||
|
||||
it('fetches skill_registry schemas for smoke script generation', async () => {
|
||||
mockCallCoreRpc.mockResolvedValue({
|
||||
schemas: [{ namespace: 'skill_registry', function: 'install', inputs: [], outputs: [] }],
|
||||
});
|
||||
|
||||
const result = await skillRegistryApi.schemas();
|
||||
|
||||
expect(mockCallCoreRpc).toHaveBeenCalledWith({ method: 'openhuman.skill_registry_schemas' });
|
||||
expect(result[0].function).toBe('install');
|
||||
});
|
||||
|
||||
it('search calls skill_registry_search with query only when source/category absent', async () => {
|
||||
mockCallCoreRpc.mockResolvedValue({ entries: [{ id: 'demo', name: 'Demo' }] });
|
||||
|
||||
const result = await skillRegistryApi.search('demo');
|
||||
|
||||
expect(mockCallCoreRpc).toHaveBeenCalledWith({
|
||||
method: 'openhuman.skill_registry_search',
|
||||
params: { query: 'demo' },
|
||||
});
|
||||
expect(result[0].id).toBe('demo');
|
||||
});
|
||||
|
||||
it('search forwards source and category when both are provided', async () => {
|
||||
mockCallCoreRpc.mockResolvedValue({ entries: [] });
|
||||
|
||||
await skillRegistryApi.search('q', 'ClawHub', 'devops');
|
||||
|
||||
expect(mockCallCoreRpc).toHaveBeenCalledWith({
|
||||
method: 'openhuman.skill_registry_search',
|
||||
params: { query: 'q', source: 'ClawHub', category: 'devops' },
|
||||
});
|
||||
});
|
||||
|
||||
it('search unwraps data-envelope shape', async () => {
|
||||
mockCallCoreRpc.mockResolvedValue({ data: { entries: [{ id: 'env-skill' }] } });
|
||||
|
||||
const result = await skillRegistryApi.search('env');
|
||||
|
||||
expect(result[0].id).toBe('env-skill');
|
||||
});
|
||||
|
||||
it('sources calls skill_registry_sources and returns array', async () => {
|
||||
mockCallCoreRpc.mockResolvedValue({ sources: ['built-in', 'ClawHub'] });
|
||||
|
||||
const result = await skillRegistryApi.sources();
|
||||
|
||||
expect(mockCallCoreRpc).toHaveBeenCalledWith({ method: 'openhuman.skill_registry_sources' });
|
||||
expect(result).toEqual(['built-in', 'ClawHub']);
|
||||
});
|
||||
|
||||
it('sources unwraps data-envelope shape', async () => {
|
||||
mockCallCoreRpc.mockResolvedValue({ data: { sources: ['optional'] } });
|
||||
|
||||
const result = await skillRegistryApi.sources();
|
||||
|
||||
expect(result).toEqual(['optional']);
|
||||
});
|
||||
|
||||
it('categories calls skill_registry_categories and returns array', async () => {
|
||||
mockCallCoreRpc.mockResolvedValue({ categories: ['productivity', 'devops'] });
|
||||
|
||||
const result = await skillRegistryApi.categories();
|
||||
|
||||
expect(mockCallCoreRpc).toHaveBeenCalledWith({ method: 'openhuman.skill_registry_categories' });
|
||||
expect(result).toEqual(['productivity', 'devops']);
|
||||
});
|
||||
|
||||
it('categories unwraps data-envelope shape', async () => {
|
||||
mockCallCoreRpc.mockResolvedValue({ data: { categories: ['automation'] } });
|
||||
|
||||
const result = await skillRegistryApi.categories();
|
||||
|
||||
expect(result).toEqual(['automation']);
|
||||
});
|
||||
|
||||
it('install falls back to empty newSkills when new_skills is missing', async () => {
|
||||
mockCallCoreRpc.mockResolvedValue({
|
||||
url: 'https://example.com/SKILL.md',
|
||||
stdout: 'ok',
|
||||
stderr: '',
|
||||
// new_skills deliberately omitted
|
||||
});
|
||||
|
||||
const result = await skillRegistryApi.install('demo');
|
||||
|
||||
expect(result.newSkills).toEqual([]);
|
||||
});
|
||||
|
||||
it('browse with forceRefresh=true forwards force_refresh=true', async () => {
|
||||
mockCallCoreRpc.mockResolvedValue({ entries: [] });
|
||||
|
||||
await skillRegistryApi.browse(true);
|
||||
|
||||
expect(mockCallCoreRpc).toHaveBeenCalledWith({
|
||||
method: 'openhuman.skill_registry_browse',
|
||||
params: { force_refresh: true },
|
||||
});
|
||||
});
|
||||
|
||||
it('browse default arg passes force_refresh=false', async () => {
|
||||
mockCallCoreRpc.mockResolvedValue({ entries: [] });
|
||||
|
||||
await skillRegistryApi.browse();
|
||||
|
||||
expect(mockCallCoreRpc).toHaveBeenCalledWith({
|
||||
method: 'openhuman.skill_registry_browse',
|
||||
params: { force_refresh: false },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -4,26 +4,55 @@ import { callCoreRpc } from '../coreRpcClient';
|
||||
|
||||
const log = debug('skillRegistryApi');
|
||||
|
||||
export interface RegistrySource {
|
||||
id: string;
|
||||
name: string;
|
||||
url: string;
|
||||
kind: 'github_index' | 'http_catalog';
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export interface CatalogEntry {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
format: string;
|
||||
source: string;
|
||||
category: string;
|
||||
author: string | null;
|
||||
version: string | null;
|
||||
tags: string[];
|
||||
platforms: string[];
|
||||
download_url: string;
|
||||
source_id: string;
|
||||
stars: number | null;
|
||||
updated_at: string | null;
|
||||
docs_path: string | null;
|
||||
commands: string[];
|
||||
env_vars: string[];
|
||||
license: string | null;
|
||||
}
|
||||
|
||||
export interface RegistryInstallResult {
|
||||
url: string;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
newSkills: string[];
|
||||
}
|
||||
|
||||
interface RawRegistryInstallResult {
|
||||
url: string;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
new_skills: string[];
|
||||
}
|
||||
|
||||
export interface RegistryUninstallResult {
|
||||
name: string;
|
||||
removedPath: string;
|
||||
scope: string;
|
||||
}
|
||||
|
||||
interface RawRegistryUninstallResult {
|
||||
name: string;
|
||||
removed_path: string;
|
||||
scope: string;
|
||||
}
|
||||
|
||||
export interface ControllerSchemaSummary {
|
||||
namespace: string;
|
||||
function: string;
|
||||
description: string;
|
||||
inputs: Array<Record<string, unknown>>;
|
||||
outputs: Array<Record<string, unknown>>;
|
||||
}
|
||||
|
||||
interface Envelope<T> {
|
||||
@@ -49,66 +78,77 @@ export const skillRegistryApi = {
|
||||
return result.entries;
|
||||
},
|
||||
|
||||
search: async (query: string, format?: string, source?: string): Promise<CatalogEntry[]> => {
|
||||
log('search: query=%s format=%s source=%s', query, format, source);
|
||||
search: async (query: string, source?: string, category?: string): Promise<CatalogEntry[]> => {
|
||||
log('search: query=%s source=%s category=%s', query, source, category);
|
||||
const response = await callCoreRpc<
|
||||
Envelope<{ entries: CatalogEntry[] }> | { entries: CatalogEntry[] }
|
||||
>({
|
||||
method: 'openhuman.skill_registry_search',
|
||||
params: { query, ...(format ? { format } : {}), ...(source ? { source } : {}) },
|
||||
params: { query, ...(source ? { source } : {}), ...(category ? { category } : {}) },
|
||||
});
|
||||
const result = unwrap(response);
|
||||
log('search: count=%d', result.entries.length);
|
||||
return result.entries;
|
||||
},
|
||||
|
||||
sources: async (): Promise<RegistrySource[]> => {
|
||||
sources: async (): Promise<string[]> => {
|
||||
log('sources: request');
|
||||
const response = await callCoreRpc<
|
||||
Envelope<{ sources: RegistrySource[] }> | { sources: RegistrySource[] }
|
||||
>({ method: 'openhuman.skill_registry_sources' });
|
||||
const response = await callCoreRpc<Envelope<{ sources: string[] }> | { sources: string[] }>({
|
||||
method: 'openhuman.skill_registry_sources',
|
||||
});
|
||||
const result = unwrap(response);
|
||||
log('sources: count=%d', result.sources.length);
|
||||
return result.sources;
|
||||
},
|
||||
|
||||
addSource: async (params: {
|
||||
id: string;
|
||||
name: string;
|
||||
url: string;
|
||||
kind?: string;
|
||||
}): Promise<RegistrySource[]> => {
|
||||
log('addSource: id=%s', params.id);
|
||||
categories: async (): Promise<string[]> => {
|
||||
log('categories: request');
|
||||
const response = await callCoreRpc<
|
||||
Envelope<{ sources: RegistrySource[] }> | { sources: RegistrySource[] }
|
||||
>({ method: 'openhuman.skill_registry_add_source', params });
|
||||
Envelope<{ categories: string[] }> | { categories: string[] }
|
||||
>({ method: 'openhuman.skill_registry_categories' });
|
||||
const result = unwrap(response);
|
||||
return result.sources;
|
||||
log('categories: count=%d', result.categories.length);
|
||||
return result.categories;
|
||||
},
|
||||
|
||||
removeSource: async (id: string): Promise<RegistrySource[]> => {
|
||||
log('removeSource: id=%s', id);
|
||||
install: async (entryId: string): Promise<RegistryInstallResult> => {
|
||||
log('install: entryId=%s', entryId);
|
||||
const response = await callCoreRpc<
|
||||
Envelope<{ sources: RegistrySource[] }> | { sources: RegistrySource[] }
|
||||
>({ method: 'openhuman.skill_registry_remove_source', params: { id } });
|
||||
const result = unwrap(response);
|
||||
return result.sources;
|
||||
},
|
||||
|
||||
install: async (
|
||||
entryId: string,
|
||||
sourceId: string
|
||||
): Promise<{ url: string; stdout: string; stderr: string; new_skills: string[] }> => {
|
||||
log('install: entryId=%s sourceId=%s', entryId, sourceId);
|
||||
const response = await callCoreRpc<
|
||||
| Envelope<{ url: string; stdout: string; stderr: string; new_skills: string[] }>
|
||||
| { url: string; stdout: string; stderr: string; new_skills: string[] }
|
||||
>({
|
||||
method: 'openhuman.skill_registry_install',
|
||||
params: { entry_id: entryId, source_id: sourceId },
|
||||
});
|
||||
const result = unwrap(response);
|
||||
log('install: newSkills=%d', result.new_skills.length);
|
||||
Envelope<RawRegistryInstallResult> | RawRegistryInstallResult
|
||||
>({ method: 'openhuman.skill_registry_install', params: { entry_id: entryId } });
|
||||
const raw = unwrap(response);
|
||||
const result: RegistryInstallResult = {
|
||||
url: raw.url,
|
||||
stdout: raw.stdout,
|
||||
stderr: raw.stderr,
|
||||
newSkills: raw.new_skills ?? [],
|
||||
};
|
||||
log('install: newSkills=%d', result.newSkills.length);
|
||||
return result;
|
||||
},
|
||||
|
||||
uninstall: async (name: string): Promise<RegistryUninstallResult> => {
|
||||
log('uninstall: name=%s', name);
|
||||
const response = await callCoreRpc<
|
||||
Envelope<RawRegistryUninstallResult> | RawRegistryUninstallResult
|
||||
>({ method: 'openhuman.skill_registry_uninstall', params: { name } });
|
||||
const raw = unwrap(response);
|
||||
const result: RegistryUninstallResult = {
|
||||
name: raw.name,
|
||||
removedPath: raw.removed_path,
|
||||
scope: raw.scope,
|
||||
};
|
||||
log('uninstall: removedPath=%s', result.removedPath);
|
||||
return result;
|
||||
},
|
||||
|
||||
schemas: async (): Promise<ControllerSchemaSummary[]> => {
|
||||
log('schemas: request');
|
||||
const response = await callCoreRpc<
|
||||
Envelope<{ schemas: ControllerSchemaSummary[] }> | { schemas: ControllerSchemaSummary[] }
|
||||
>({ method: 'openhuman.skill_registry_schemas' });
|
||||
const result = unwrap(response);
|
||||
log('schemas: count=%d', result.schemas.length);
|
||||
return result.schemas;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -54,21 +54,27 @@ describe('workflowsApi', () => {
|
||||
});
|
||||
|
||||
describe('runWorkflow', () => {
|
||||
it('calls openhuman.workflows_run with workflow_id and inputs', async () => {
|
||||
mockCallCoreRpc.mockResolvedValue({ run_id: 'run-1', workflow_id: 's', log: '/tmp/log' });
|
||||
it('calls openhuman.skill_runtime_run with skill_id and inputs', async () => {
|
||||
mockCallCoreRpc.mockResolvedValue({
|
||||
run_id: 'run-1',
|
||||
status: 'started',
|
||||
skill_id: 's',
|
||||
log: '/tmp/log',
|
||||
});
|
||||
const result = await workflowsApi.runWorkflow('s', { repo: 'owner/repo' });
|
||||
expect(mockCallCoreRpc).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
method: 'openhuman.workflows_run',
|
||||
params: { workflow_id: 's', inputs: { repo: 'owner/repo' } },
|
||||
method: 'openhuman.skill_runtime_run',
|
||||
params: { skill_id: 's', inputs: { repo: 'owner/repo' } },
|
||||
})
|
||||
);
|
||||
expect(result.run_id).toBe('run-1');
|
||||
expect(result.workflow_id).toBe('s');
|
||||
});
|
||||
});
|
||||
|
||||
describe('readRunLog', () => {
|
||||
it('calls workflows_read_run_log with run_id', async () => {
|
||||
it('calls skill_runtime_read_run_log with run_id', async () => {
|
||||
mockCallCoreRpc.mockResolvedValue({
|
||||
bytes_read: 100,
|
||||
eof: false,
|
||||
@@ -79,7 +85,7 @@ describe('workflowsApi', () => {
|
||||
const result = await workflowsApi.readRunLog('run-1');
|
||||
expect(mockCallCoreRpc).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
method: 'openhuman.workflows_read_run_log',
|
||||
method: 'openhuman.skill_runtime_read_run_log',
|
||||
params: expect.objectContaining({ run_id: 'run-1' }),
|
||||
})
|
||||
);
|
||||
@@ -115,7 +121,8 @@ describe('workflowsApi', () => {
|
||||
await workflowsApi.recentRuns('dev-workflow', 5);
|
||||
expect(mockCallCoreRpc).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
params: expect.objectContaining({ workflow_id: 'dev-workflow', limit: 5 }),
|
||||
method: 'openhuman.skill_runtime_recent_runs',
|
||||
params: expect.objectContaining({ skill_id: 'dev-workflow', limit: 5 }),
|
||||
})
|
||||
);
|
||||
});
|
||||
@@ -195,12 +202,12 @@ describe('workflowsApi', () => {
|
||||
});
|
||||
|
||||
describe('cancelRun', () => {
|
||||
it('calls openhuman.workflows_cancel with run_id and returns cancelled', async () => {
|
||||
it('calls openhuman.skill_runtime_cancel with run_id and returns cancelled', async () => {
|
||||
mockCallCoreRpc.mockResolvedValue({ cancelled: true });
|
||||
const result = await workflowsApi.cancelRun('run-9');
|
||||
expect(mockCallCoreRpc).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
method: 'openhuman.workflows_cancel',
|
||||
method: 'openhuman.skill_runtime_cancel',
|
||||
params: { run_id: 'run-9' },
|
||||
})
|
||||
);
|
||||
@@ -213,4 +220,64 @@ describe('workflowsApi', () => {
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('uninstallWorkflow', () => {
|
||||
it('calls openhuman.skill_registry_uninstall and normalizes removed_path', async () => {
|
||||
mockCallCoreRpc.mockResolvedValue({ name: 'demo', removed_path: '/tmp/demo', scope: 'user' });
|
||||
const result = await workflowsApi.uninstallWorkflow('demo');
|
||||
expect(mockCallCoreRpc).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
method: 'openhuman.skill_registry_uninstall',
|
||||
params: { name: 'demo' },
|
||||
})
|
||||
);
|
||||
expect(result.removedPath).toBe('/tmp/demo');
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveRuntimes', () => {
|
||||
it('calls openhuman.skill_runtime_resolve_runtimes and normalizes bin_dir', async () => {
|
||||
mockCallCoreRpc.mockResolvedValue({
|
||||
runtimes: [
|
||||
{
|
||||
runtime: 'node',
|
||||
enabled: true,
|
||||
available: true,
|
||||
source: 'system',
|
||||
version: '22.11.0',
|
||||
binary: '/usr/bin/node',
|
||||
bin_dir: '/usr/bin',
|
||||
error: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
const result = await workflowsApi.resolveRuntimes('node');
|
||||
expect(mockCallCoreRpc).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
method: 'openhuman.skill_runtime_resolve_runtimes',
|
||||
params: { runtime: 'node' },
|
||||
})
|
||||
);
|
||||
expect(result.runtimes[0].binDir).toBe('/usr/bin');
|
||||
});
|
||||
|
||||
it('uses empty params object when runtime is "all" (the default)', async () => {
|
||||
mockCallCoreRpc.mockResolvedValue({ runtimes: [] });
|
||||
|
||||
await workflowsApi.resolveRuntimes('all');
|
||||
|
||||
expect(mockCallCoreRpc).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ method: 'openhuman.skill_runtime_resolve_runtimes', params: {} })
|
||||
);
|
||||
});
|
||||
|
||||
it('uses empty params when called with no argument (default = all)', async () => {
|
||||
mockCallCoreRpc.mockResolvedValue({ runtimes: [] });
|
||||
|
||||
await workflowsApi.resolveRuntimes();
|
||||
|
||||
const call = mockCallCoreRpc.mock.calls[0][0];
|
||||
expect(call.params).toEqual({});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -172,7 +172,7 @@ interface RawInstallWorkflowFromUrlResult {
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of `openhuman.workflows_uninstall`.
|
||||
* Result of `openhuman.skill_registry_uninstall`.
|
||||
*
|
||||
* Mirrors the Rust-side `UninstallSkillOutcome`. `removedPath` is the
|
||||
* canonicalised on-disk path that was deleted — surface it in success toasts
|
||||
@@ -190,6 +190,36 @@ interface RawUninstallWorkflowResult {
|
||||
scope: WorkflowScope;
|
||||
}
|
||||
|
||||
export interface SkillRuntimeSummary {
|
||||
runtime: 'node' | 'python' | string;
|
||||
enabled: boolean;
|
||||
available: boolean;
|
||||
source: 'system' | 'managed' | string | null;
|
||||
version: string | null;
|
||||
binary: string | null;
|
||||
binDir: string | null;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
interface RawSkillRuntimeSummary {
|
||||
runtime: string;
|
||||
enabled: boolean;
|
||||
available: boolean;
|
||||
source: string | null;
|
||||
version: string | null;
|
||||
binary: string | null;
|
||||
bin_dir: string | null;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export interface ResolveSkillRuntimesResult {
|
||||
runtimes: SkillRuntimeSummary[];
|
||||
}
|
||||
|
||||
interface RawResolveSkillRuntimesResult {
|
||||
runtimes: RawSkillRuntimeSummary[];
|
||||
}
|
||||
|
||||
interface Envelope<T> {
|
||||
data?: T;
|
||||
}
|
||||
@@ -361,7 +391,7 @@ export const workflowsApi = {
|
||||
},
|
||||
|
||||
/**
|
||||
* Remove an installed user-scope SKILL.md skill via `openhuman.workflows_uninstall`.
|
||||
* Remove an installed user-scope SKILL.md skill via `openhuman.skill_registry_uninstall`.
|
||||
*
|
||||
* Only user-scope installs (`~/.openhuman/skills/<name>/`) are supported.
|
||||
* Project-scope and legacy skills are read-only — trying to uninstall one
|
||||
@@ -373,7 +403,7 @@ export const workflowsApi = {
|
||||
log('uninstallWorkflow: request name=%s', name);
|
||||
const response = await callCoreRpc<
|
||||
Envelope<RawUninstallWorkflowResult> | RawUninstallWorkflowResult
|
||||
>({ method: 'openhuman.workflows_uninstall', params: { name } });
|
||||
>({ method: 'openhuman.skill_registry_uninstall', params: { name } });
|
||||
const raw = unwrapEnvelope(response);
|
||||
const normalized: UninstallWorkflowResult = {
|
||||
name: raw.name,
|
||||
@@ -408,9 +438,9 @@ export const workflowsApi = {
|
||||
},
|
||||
|
||||
/**
|
||||
* Fire-and-forget invocation of `openhuman.workflows_run`. Returns
|
||||
* Fire-and-forget invocation of `openhuman.skill_runtime_run`. Returns
|
||||
* immediately with the new background run's `run_id`, the canonical
|
||||
* `workflow_id`, and the log path the run is streaming into; the actual
|
||||
* skill/workflow id, and the log path the run is streaming into; the actual
|
||||
* autonomous work continues in the background and finishes with
|
||||
* status `DONE` / `DEGENERATE` / `FAILED` in the run log.
|
||||
*/
|
||||
@@ -419,24 +449,29 @@ export const workflowsApi = {
|
||||
inputs: Record<string, unknown>
|
||||
): Promise<WorkflowRunStarted> => {
|
||||
log('runWorkflow: request workflowId=%s', workflowId);
|
||||
const response = await callCoreRpc<Envelope<WorkflowRunStarted> | WorkflowRunStarted>({
|
||||
method: 'openhuman.workflows_run',
|
||||
params: { workflow_id: workflowId, inputs },
|
||||
const response = await callCoreRpc<Envelope<RawSkillRunStarted> | RawSkillRunStarted>({
|
||||
method: 'openhuman.skill_runtime_run',
|
||||
params: { skill_id: workflowId, inputs },
|
||||
});
|
||||
const raw = unwrapEnvelope(response);
|
||||
log('runWorkflow: response runId=%s log=%s', raw.run_id, raw.log);
|
||||
return raw;
|
||||
const normalized: WorkflowRunStarted = {
|
||||
run_id: raw.run_id,
|
||||
status: raw.status,
|
||||
workflow_id: raw.workflow_id ?? raw.skill_id,
|
||||
log: raw.log,
|
||||
};
|
||||
log('runWorkflow: response runId=%s log=%s', normalized.run_id, normalized.log);
|
||||
return normalized;
|
||||
},
|
||||
|
||||
/**
|
||||
* Request cancellation of an in-flight run via `openhuman.workflows_cancel`.
|
||||
* Request cancellation of an in-flight run via `openhuman.skill_runtime_cancel`.
|
||||
* Returns `true` if a live run with this id was found and signalled; the run
|
||||
* stops at its next await and lands a CANCELLED footer.
|
||||
*/
|
||||
cancelRun: async (runId: string): Promise<boolean> => {
|
||||
log('cancelRun: request runId=%s', runId);
|
||||
const response = await callCoreRpc<Envelope<{ cancelled: boolean }> | { cancelled: boolean }>({
|
||||
method: 'openhuman.workflows_cancel',
|
||||
method: 'openhuman.skill_runtime_cancel',
|
||||
params: { run_id: runId },
|
||||
});
|
||||
const raw = unwrapEnvelope(response);
|
||||
@@ -460,7 +495,7 @@ export const workflowsApi = {
|
||||
if (offset !== undefined) params.offset = offset;
|
||||
if (maxBytes !== undefined) params.max_bytes = maxBytes;
|
||||
const response = await callCoreRpc<Envelope<RunLogSlice> | RunLogSlice>({
|
||||
method: 'openhuman.workflows_read_run_log',
|
||||
method: 'openhuman.skill_runtime_read_run_log',
|
||||
params,
|
||||
});
|
||||
const raw = unwrapEnvelope(response);
|
||||
@@ -476,16 +511,48 @@ export const workflowsApi = {
|
||||
recentRuns: async (workflowId?: string, limit?: number): Promise<ScannedRun[]> => {
|
||||
log('recentRuns: request workflowId=%s limit=%s', workflowId ?? '*', limit ?? 'default');
|
||||
const params: Record<string, unknown> = {};
|
||||
if (workflowId !== undefined) params.workflow_id = workflowId;
|
||||
if (workflowId !== undefined) params.skill_id = workflowId;
|
||||
if (limit !== undefined) params.limit = limit;
|
||||
const response = await callCoreRpc<Envelope<{ runs: ScannedRun[] }> | { runs: ScannedRun[] }>({
|
||||
method: 'openhuman.workflows_recent_runs',
|
||||
method: 'openhuman.skill_runtime_recent_runs',
|
||||
params,
|
||||
});
|
||||
const raw = unwrapEnvelope(response);
|
||||
log('recentRuns: response count=%d', raw.runs.length);
|
||||
return raw.runs;
|
||||
},
|
||||
|
||||
/**
|
||||
* Resolve the reusable Node/Python runtimes backing script-based skills.
|
||||
* The backend reuses `runtime_node` and `runtime_python`; this call is a
|
||||
* cheap UI/prod-smoke probe unless it has to bootstrap a missing managed runtime.
|
||||
*/
|
||||
resolveRuntimes: async (
|
||||
runtime: 'all' | 'node' | 'python' = 'all'
|
||||
): Promise<ResolveSkillRuntimesResult> => {
|
||||
log('resolveRuntimes: request runtime=%s', runtime);
|
||||
const response = await callCoreRpc<
|
||||
Envelope<RawResolveSkillRuntimesResult> | RawResolveSkillRuntimesResult
|
||||
>({
|
||||
method: 'openhuman.skill_runtime_resolve_runtimes',
|
||||
params: runtime === 'all' ? {} : { runtime },
|
||||
});
|
||||
const raw = unwrapEnvelope(response);
|
||||
const result: ResolveSkillRuntimesResult = {
|
||||
runtimes: (raw.runtimes ?? []).map(item => ({
|
||||
runtime: item.runtime,
|
||||
enabled: item.enabled,
|
||||
available: item.available,
|
||||
source: item.source,
|
||||
version: item.version,
|
||||
binary: item.binary,
|
||||
binDir: item.bin_dir,
|
||||
error: item.error,
|
||||
})),
|
||||
};
|
||||
log('resolveRuntimes: response count=%d', result.runtimes.length);
|
||||
return result;
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -509,7 +576,7 @@ export interface WorkflowDescription {
|
||||
inputs: WorkflowInputDescription[];
|
||||
}
|
||||
|
||||
/** Wire shape returned by `openhuman.workflows_run` (fire-and-forget). */
|
||||
/** Wire shape returned by `openhuman.skill_runtime_run` (fire-and-forget). */
|
||||
export interface WorkflowRunStarted {
|
||||
run_id: string;
|
||||
status: string; // "started"
|
||||
@@ -517,8 +584,16 @@ export interface WorkflowRunStarted {
|
||||
log: string; // absolute path to the streaming log
|
||||
}
|
||||
|
||||
interface RawSkillRunStarted {
|
||||
run_id: string;
|
||||
status: string;
|
||||
workflow_id?: string;
|
||||
skill_id: string;
|
||||
log: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Slice of a run log file returned by `openhuman.workflows_read_run_log`.
|
||||
* Slice of a run log file returned by `openhuman.skill_runtime_read_run_log`.
|
||||
* Mirrors `crate::openhuman::skills::run_log::RunLogSlice`. The FE
|
||||
* passes the returned `offset` as the next call's `offset` to tail
|
||||
* forward; polling can stop once `complete: true` (the `--- result ---`
|
||||
@@ -536,7 +611,7 @@ export interface RunLogSlice {
|
||||
}
|
||||
|
||||
/**
|
||||
* One run entry returned by `openhuman.workflows_recent_runs`. Wire shape
|
||||
* One run entry returned by `openhuman.skill_runtime_recent_runs`. Wire shape
|
||||
* mirrors `crate::openhuman::skills::run_log::ScannedRun`. `status` is
|
||||
* `"RUNNING"` while the run hasn't written its `--- result ---` footer
|
||||
* yet; after the footer lands it becomes `"DONE"` / `"DEGENERATE"` /
|
||||
|
||||
@@ -2,6 +2,7 @@ import { expect, test } from '@playwright/test';
|
||||
|
||||
import {
|
||||
bootRuntimeReadyGuestPage,
|
||||
callCoreRpc,
|
||||
dismissWalkthroughIfPresent,
|
||||
signInViaCallbackToken,
|
||||
waitForAppReady,
|
||||
@@ -64,3 +65,86 @@ test.describe('Skills registry flow', () => {
|
||||
await expect(page.getByText(/^All$|^Installed$|^Registry$/i).first()).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Skill registry RPC smoke', () => {
|
||||
test('sources returns upstream source names', async () => {
|
||||
const result = await callCoreRpc<{ sources: string[] }>('openhuman.skill_registry_sources');
|
||||
expect(result.sources).toBeDefined();
|
||||
expect(result.sources.length).toBeGreaterThan(0);
|
||||
|
||||
for (const source of result.sources) {
|
||||
expect(typeof source).toBe('string');
|
||||
expect(source.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
test('browse returns catalog entries', async () => {
|
||||
test.setTimeout(30_000);
|
||||
const result = await callCoreRpc<{
|
||||
entries: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
source: string;
|
||||
category: string;
|
||||
download_url: string;
|
||||
}>;
|
||||
}>('openhuman.skill_registry_browse', { force_refresh: true });
|
||||
expect(result.entries).toBeDefined();
|
||||
expect(result.entries.length).toBeGreaterThan(0);
|
||||
|
||||
for (const entry of result.entries.slice(0, 5)) {
|
||||
expect(entry.id).toBeTruthy();
|
||||
expect(entry.name).toBeTruthy();
|
||||
expect(entry.source).toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
test('search filters entries by query', async () => {
|
||||
test.setTimeout(30_000);
|
||||
const result = await callCoreRpc<{
|
||||
entries: Array<{ id: string; name: string; description: string }>;
|
||||
}>('openhuman.skill_registry_search', { query: 'git' });
|
||||
expect(result.entries).toBeDefined();
|
||||
expect(result.entries.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('search for docker returns results', async () => {
|
||||
test.setTimeout(30_000);
|
||||
const result = await callCoreRpc<{
|
||||
entries: Array<{ id: string; name: string; description: string; source: string }>;
|
||||
}>('openhuman.skill_registry_search', { query: 'docker' });
|
||||
expect(result.entries).toBeDefined();
|
||||
expect(result.entries.length).toBeGreaterThan(0);
|
||||
|
||||
const hasDockerMatch = result.entries.some(
|
||||
e => e.name.toLowerCase().includes('docker') || e.description.toLowerCase().includes('docker')
|
||||
);
|
||||
expect(hasDockerMatch).toBe(true);
|
||||
});
|
||||
|
||||
test('search with source filter narrows results', async () => {
|
||||
test.setTimeout(30_000);
|
||||
const all = await callCoreRpc<{ entries: Array<{ id: string; source: string }> }>(
|
||||
'openhuman.skill_registry_search',
|
||||
{ query: 'git' }
|
||||
);
|
||||
|
||||
const filtered = await callCoreRpc<{ entries: Array<{ id: string; source: string }> }>(
|
||||
'openhuman.skill_registry_search',
|
||||
{ query: 'git', source: 'built-in' }
|
||||
);
|
||||
|
||||
expect(filtered.entries.length).toBeLessThanOrEqual(all.entries.length);
|
||||
for (const entry of filtered.entries) {
|
||||
expect(entry.source).toBe('built-in');
|
||||
}
|
||||
});
|
||||
|
||||
test('search with empty query returns all entries', async () => {
|
||||
test.setTimeout(30_000);
|
||||
const all = await callCoreRpc<{ entries: Array<unknown> }>('openhuman.skill_registry_search', {
|
||||
query: '',
|
||||
});
|
||||
expect(all.entries.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -55,6 +55,7 @@ ALL_E2E_SUITES=(
|
||||
memory_tree_walk_e2e
|
||||
ollama_embeddings_fallback_e2e
|
||||
screen_intelligence_vision_e2e
|
||||
skill_registry_e2e
|
||||
subconscious_e2e
|
||||
worker_b_domain_e2e
|
||||
worker_c_modules_e2e
|
||||
|
||||
+5
-1
@@ -198,6 +198,8 @@ fn build_registered_controllers() -> Vec<RegisteredController> {
|
||||
controllers.extend(crate::openhuman::javascript::all_javascript_registered_controllers());
|
||||
// Discovered SKILL.md skills and their bundled resources
|
||||
controllers.extend(crate::openhuman::workflows::all_workflows_registered_controllers());
|
||||
// Skill runtime: run/cancel/log skill executions and resolve Node/Python toolchains
|
||||
controllers.extend(crate::openhuman::skill_runtime::all_skill_runtime_registered_controllers());
|
||||
// Skill registry: browse, search, install from remote registries
|
||||
controllers
|
||||
.extend(crate::openhuman::skill_registry::all_skill_registry_registered_controllers());
|
||||
@@ -361,6 +363,7 @@ fn build_declared_controller_schemas() -> Vec<ControllerSchema> {
|
||||
schemas.extend(crate::openhuman::socket::all_socket_controller_schemas());
|
||||
schemas.extend(crate::openhuman::javascript::all_javascript_controller_schemas());
|
||||
schemas.extend(crate::openhuman::workflows::all_workflows_controller_schemas());
|
||||
schemas.extend(crate::openhuman::skill_runtime::all_skill_runtime_controller_schemas());
|
||||
schemas.extend(crate::openhuman::skill_registry::all_skill_registry_controller_schemas());
|
||||
schemas.extend(crate::openhuman::workspace::all_workspace_controller_schemas());
|
||||
schemas.extend(crate::openhuman::tools::all_tools_controller_schemas());
|
||||
@@ -475,7 +478,8 @@ pub fn namespace_description(namespace: &str) -> Option<&'static str> {
|
||||
"screen_intelligence" => Some("Screen capture, permissions, and accessibility automation."),
|
||||
"security" => Some("Security policy and autonomy guardrail metadata."),
|
||||
"service" => Some("Desktop service lifecycle management."),
|
||||
"skill_registry" => Some("Browse, search, and install skills from remote registries (OpenHuman, Hermes, OpenClaw)."),
|
||||
"skill_registry" => Some("Browse, search, install, and uninstall skills from remote registries (OpenHuman, Hermes, OpenClaw)."),
|
||||
"skill_runtime" => Some("Run installed skills, inspect run logs, and resolve Node/Python skill runtimes."),
|
||||
"workflows" => Some("Discovered workflows (WORKFLOW.md/SKILL.md bundles) and their resources."),
|
||||
"socket" => Some("Backend Socket.IO bridge controls."),
|
||||
"memory" => Some("Document storage, vector search, key-value store, and knowledge graph."),
|
||||
|
||||
+4
-1
@@ -2011,7 +2011,6 @@ fn register_domain_subscribers(
|
||||
/// surface a banner; under CLI / Docker the override is honored (with a
|
||||
/// noisy log + a domain event so any connected dashboard can flag it).
|
||||
pub async fn bootstrap_core_runtime(host_kind: crate::core::types::HostKind) {
|
||||
use crate::core::types::HostKind;
|
||||
use crate::openhuman::socket::{set_global_socket_manager, SocketManager};
|
||||
use std::sync::Arc;
|
||||
// `embedded_core` derived from host_kind so the rest of the function (which
|
||||
@@ -2034,6 +2033,10 @@ pub async fn bootstrap_core_runtime(host_kind: crate::core::types::HostKind) {
|
||||
// Uses a Once guard so repeated calls to bootstrap_core_runtime()
|
||||
// cannot double-subscribe.
|
||||
register_domain_subscribers(workspace_dir.clone(), cfg.clone(), embedded_core);
|
||||
// Warm the remote skills catalog on every core load. This updates the
|
||||
// cached registry used by skill discovery/search, but runs best-effort in
|
||||
// the background so Hermes/network latency cannot block core readiness.
|
||||
crate::openhuman::skill_registry::ops::start_boot_catalog_refresh();
|
||||
|
||||
// --- Turn-state recovery -------------------------------------------
|
||||
// Any per-thread turn snapshots left on disk from a previous process
|
||||
|
||||
@@ -33,10 +33,9 @@
|
||||
use async_trait::async_trait;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::openhuman::skill_runtime::{await_run_outcome, spawn_workflow_run_background};
|
||||
use crate::openhuman::tools::traits::{PermissionLevel, Tool, ToolResult};
|
||||
use crate::openhuman::workflows::schemas::{
|
||||
await_run_outcome, resolve_workspace_dir, spawn_workflow_run_background,
|
||||
};
|
||||
use crate::openhuman::workflows::schemas::resolve_workspace_dir;
|
||||
|
||||
/// Tool name surfaced to the LLM's function-calling schema.
|
||||
pub const RUN_WORKFLOW_TOOL_NAME: &str = "run_workflow";
|
||||
|
||||
@@ -188,6 +188,16 @@ pub const BUILTINS: &[BuiltinAgent] = &[
|
||||
toml: include_str!("mcp_setup/agent.toml"),
|
||||
prompt_fn: super::mcp_setup::prompt::build,
|
||||
},
|
||||
BuiltinAgent {
|
||||
id: "skill_setup",
|
||||
toml: include_str!("../../skill_registry/agent/skill_setup/agent.toml"),
|
||||
prompt_fn: crate::openhuman::skill_registry::agent::skill_setup::prompt::build,
|
||||
},
|
||||
BuiltinAgent {
|
||||
id: "skill_executor",
|
||||
toml: include_str!("../../skill_runtime/agent/skill_executor/agent.toml"),
|
||||
prompt_fn: crate::openhuman::skill_runtime::agent::skill_executor::prompt::build,
|
||||
},
|
||||
BuiltinAgent {
|
||||
id: "agent_memory",
|
||||
toml: include_str!("../../agent_memory/agent/agent.toml"),
|
||||
|
||||
@@ -14,7 +14,7 @@ agent_tier = "chat"
|
||||
omit_identity = true
|
||||
omit_memory_context = true
|
||||
omit_safety_preamble = true
|
||||
omit_skills_catalog = true
|
||||
omit_skills_catalog = false
|
||||
# The orchestrator is the user-facing front-line agent — it routes,
|
||||
# synthesises, and speaks to the user in their voice. PROFILE.md
|
||||
# (onboarding enrichment output) anchors that voice.
|
||||
@@ -106,6 +106,12 @@ allowlist = [
|
||||
# this entry the agent-native install path (issue #3039 gap B5) is unreachable
|
||||
# from chat.
|
||||
"mcp_setup",
|
||||
# Skill registry specialists. `skill_setup` discovers, installs, and
|
||||
# manages agent skills from community registries (OpenHuman, HermesHub,
|
||||
# ClawHub). `skill_executor` runs an installed skill by loading its
|
||||
# SKILL.md instructions and executing the procedure.
|
||||
"skill_setup",
|
||||
"skill_executor",
|
||||
# NOTE: `summarizer` used to be listed here for the runtime-only
|
||||
# oversized-tool-result hook. That path is currently disabled
|
||||
# (`context.summarizer_payload_threshold_tokens = 0`) after recursive
|
||||
@@ -177,6 +183,13 @@ named = [
|
||||
"read_workflow_run_log",
|
||||
"run_workflow",
|
||||
"await_workflow",
|
||||
# Skill registry tools — browse/search/install from community registries.
|
||||
# These complement list_workflows (which shows already-installed skills)
|
||||
# by exposing the remote catalog for discovery and installation.
|
||||
"skill_registry_browse",
|
||||
"skill_registry_search",
|
||||
"skill_registry_install",
|
||||
"skill_registry_sources",
|
||||
# Self-update — lets the orchestrator answer "am I up to date" /
|
||||
# "update OpenHuman" without sending the user to Settings →
|
||||
# Developer Options. `update_check` is read-only; `update_apply`
|
||||
|
||||
@@ -33,6 +33,8 @@ Follow this sequence for every user message:
|
||||
- If the request is about a **crypto wallet or market action** — balances, transfers, swaps, contract calls, on-chain positions, or trading on a connected exchange — use `delegate_do_crypto`. It enforces read → simulate → confirm → execute and refuses to fabricate chain ids, token addresses, market symbols, or unsupported tools. **Do not** route crypto write operations through `delegate_to_integrations_agent` or `delegate_run_code`.
|
||||
- **Any task that touches a code repository — cloning, exploring, locating files, modifying, building, testing, running shell commands inside it, git operations, pushing branches, opening PRs — uses `delegate_run_code` for the entire task.** Treat "locate where to edit", "investigate the bug", "find the function", "read the file" as code-repo work the moment they're scoped to a repo: they belong inside the same `delegate_run_code` worker as the edit / build / git steps. **Never** route code-repo work through `tools_agent` / `spawn_worker_thread`; those workers lack `edit` / `apply_patch` / `file_write` / `git_operations` / `codegraph_search` and will silently stall in read-mode. `tools_agent` is for *non-repo* work only — ad-hoc shell against the host, web fetch, memory helpers, etc.
|
||||
- **Do not stall after reading code-repo files.** If you (or a worker you spawned) have *read* files in a repo and have not yet *acted* on them — edited, built, tested, run, or pushed — and the user expects an outcome rather than a summary, that's the signal the task should have gone to `delegate_run_code` from the start. Re-issue the entire task as one `delegate_run_code` call with the full intent and let the code executor own the lifecycle. Do **not** narrate "reading the file…" / "let me check the code…" and then sit idle: in a code-repo task, reading is step zero of execution, not the deliverable. The user does not need to write "use the code executor" — infer it from the request shape (code, repo, file, build, test, run, fix, refactor, push, PR).
|
||||
- If the request is to find, browse, install, or manage agent skills from community registries — or to follow a SKILL.md URL — use `setup_skills`.
|
||||
- If the request is to run or execute an installed agent skill by name, use `run_skill`.
|
||||
- If web/doc crawling is required, use `research`.
|
||||
- If the user asks for live/current/time-sensitive facts that are not covered by a direct tool — weather, forecasts, current temperatures, recent news, fresh web facts, or "use Grok/web/live data" — call `research` with a prompt that asks for live sources. Do **not** stop at "on it", and do **not** wait for the exact named provider if it is not wired in. Use the available research tool and then answer with the result.
|
||||
- If complex multi-step decomposition is required, use `delegate_plan`.
|
||||
|
||||
@@ -16,6 +16,7 @@ use crate::openhuman::context::prompt::{
|
||||
PromptContext,
|
||||
};
|
||||
use crate::openhuman::tools::orchestrator_tools::sanitise_slug;
|
||||
use crate::openhuman::workflows::ops_types::Workflow;
|
||||
use anyhow::Result;
|
||||
use std::fmt::Write;
|
||||
|
||||
@@ -38,6 +39,12 @@ pub fn build(ctx: &PromptContext<'_>) -> Result<String> {
|
||||
out.push_str("\n\n");
|
||||
}
|
||||
|
||||
let skills = render_installed_skills(ctx.workflows);
|
||||
if !skills.trim().is_empty() {
|
||||
out.push_str(skills.trim_end());
|
||||
out.push_str("\n\n");
|
||||
}
|
||||
|
||||
let integrations = render_delegation_guide(ctx.connected_integrations);
|
||||
if !integrations.trim().is_empty() {
|
||||
out.push_str(integrations.trim_end());
|
||||
@@ -65,6 +72,41 @@ pub fn build(ctx: &PromptContext<'_>) -> Result<String> {
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Render the `## Installed Skills` section listing locally installed
|
||||
/// workflows so the orchestrator knows what's available without calling
|
||||
/// `list_workflows` on every turn. Omitted when no skills are installed.
|
||||
fn render_installed_skills(skills: &[Workflow]) -> String {
|
||||
if skills.is_empty() {
|
||||
tracing::debug!("[orchestrator-prompt] no installed skills, section omitted");
|
||||
return String::new();
|
||||
}
|
||||
tracing::debug!(
|
||||
count = skills.len(),
|
||||
"[orchestrator-prompt] rendering installed skills section"
|
||||
);
|
||||
let mut out = String::from(
|
||||
"## Installed Skills\n\n\
|
||||
The following skills are installed locally. Run them with `run_workflow` \
|
||||
(pass the skill's id as `workflow_id`). Use `describe_workflow` for full \
|
||||
details. Use `skill_registry_browse` / `skill_registry_search` to find \
|
||||
and install new skills.\n\n",
|
||||
);
|
||||
for skill in skills {
|
||||
let id = if skill.dir_name.is_empty() {
|
||||
&skill.name
|
||||
} else {
|
||||
&skill.dir_name
|
||||
};
|
||||
let desc = if skill.description.is_empty() {
|
||||
"(no description)"
|
||||
} else {
|
||||
&skill.description
|
||||
};
|
||||
let _ = writeln!(out, "- **{id}**: {desc}");
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Render the delegator-voice `## Connected Integrations` block. Only
|
||||
/// toolkits the user has actively connected are listed — unauthorised
|
||||
/// toolkits are hidden so the orchestrator cannot hallucinate a delegation
|
||||
|
||||
@@ -217,6 +217,18 @@ const RESOURCE_CATALOG: &[PromptResource] = &[
|
||||
description: "Background reasoning agent that maintains subconscious scratchpad context.",
|
||||
content: include_str!("../subconscious/agent/prompt.md"),
|
||||
},
|
||||
PromptResource {
|
||||
uri: "openhuman://prompts/agents/skill_setup",
|
||||
name: "skill_setup",
|
||||
description: "Worker that guides skill installation and backend configuration.",
|
||||
content: include_str!("../skill_registry/agent/skill_setup/prompt.md"),
|
||||
},
|
||||
PromptResource {
|
||||
uri: "openhuman://prompts/agents/skill_executor",
|
||||
name: "skill_executor",
|
||||
description: "Sandboxed worker that runs installed skill packages.",
|
||||
content: include_str!("../skill_runtime/agent/skill_executor/prompt.md"),
|
||||
},
|
||||
];
|
||||
|
||||
/// Returns the `resources/list` result payload listing every catalog entry.
|
||||
|
||||
@@ -99,6 +99,7 @@ pub mod security;
|
||||
pub mod service;
|
||||
pub mod session_db;
|
||||
pub mod skill_registry;
|
||||
pub mod skill_runtime;
|
||||
pub mod socket;
|
||||
pub mod startup;
|
||||
pub mod subconscious;
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
# Skill Registry
|
||||
|
||||
`skill_registry` owns remote skill catalogs and installed-skill lifecycle.
|
||||
|
||||
Responsibilities:
|
||||
|
||||
- Fetch and cache registry catalogs.
|
||||
- Refresh the remote catalog asynchronously on core load.
|
||||
- Browse/search registry entries.
|
||||
- Derive install URLs for Hermes bundled and optional skills.
|
||||
- Install catalog entries into the user skills directory.
|
||||
- Uninstall user-scope skills.
|
||||
- Host the built-in `skill_setup` agent.
|
||||
|
||||
Default catalog:
|
||||
|
||||
```text
|
||||
https://hermes-agent.nousresearch.com/docs/api/skills.json
|
||||
```
|
||||
|
||||
Useful environment overrides for prod scripts and deterministic tests:
|
||||
|
||||
```bash
|
||||
OPENHUMAN_SKILL_REGISTRY_CATALOG_URL=https://example.com/skills.json
|
||||
OPENHUMAN_SKILL_REGISTRY_DOWNLOAD_BASE_URL=https://example.com/skills
|
||||
OPENHUMAN_SKILL_REGISTRY_REFRESH_ON_BOOT=0
|
||||
```
|
||||
|
||||
`OPENHUMAN_SKILL_REGISTRY_REFRESH_ON_BOOT=0` disables the best-effort startup
|
||||
refresh. By default, core startup spawns a background task that force-refreshes
|
||||
the remote catalog and updates the local cache without blocking core readiness.
|
||||
|
||||
Production smoke examples:
|
||||
|
||||
```bash
|
||||
openhuman skill_registry schemas
|
||||
openhuman skill_registry browse --force_refresh true
|
||||
openhuman skill_registry search --query git
|
||||
openhuman skill_registry sources
|
||||
openhuman skill_registry install --entry_id git-helper
|
||||
openhuman skill_registry uninstall --name git-helper
|
||||
```
|
||||
|
||||
Security notes:
|
||||
|
||||
- Production installs still go through the hardened `workflows` URL installer.
|
||||
- HTTP localhost installs require `OPENHUMAN_SKILL_INSTALL_ALLOW_LOCAL_HTTP=1` and are intended for local fixtures only.
|
||||
@@ -0,0 +1 @@
|
||||
pub mod skill_setup;
|
||||
@@ -0,0 +1,29 @@
|
||||
id = "skill_setup"
|
||||
display_name = "Skill Setup Agent"
|
||||
delegate_name = "setup_skills"
|
||||
when_to_use = "Skill discovery and installation specialist — browses community skill registries (OpenHuman, HermesHub, ClawHub), searches for skills by keyword or category, installs skills from remote sources, and manages installed skills. Use when the user wants to find, install, update, or remove agent skills."
|
||||
temperature = 0.3
|
||||
max_iterations = 10
|
||||
sandbox_mode = "none"
|
||||
agent_tier = "worker"
|
||||
omit_identity = true
|
||||
omit_memory_context = true
|
||||
omit_safety_preamble = false
|
||||
omit_skills_catalog = true
|
||||
|
||||
[model]
|
||||
hint = "chat"
|
||||
|
||||
[tools]
|
||||
named = [
|
||||
"list_workflows",
|
||||
"describe_workflow",
|
||||
"skill_registry_browse",
|
||||
"skill_registry_search",
|
||||
"skill_registry_sources",
|
||||
"skill_registry_install",
|
||||
"skill_registry_uninstall",
|
||||
"install_workflow_from_url",
|
||||
"uninstall_workflow",
|
||||
"ask_user_clarification",
|
||||
]
|
||||
@@ -0,0 +1 @@
|
||||
pub mod prompt;
|
||||
@@ -0,0 +1,33 @@
|
||||
You are the **Skill Setup Agent**, a specialist in discovering, installing, and managing agent skills from community registries.
|
||||
|
||||
## Your role
|
||||
|
||||
You help the user find and install skills from three registries:
|
||||
1. **OpenHuman Community** — curated skills at `tinyhumansai/skill-registry`
|
||||
2. **HermesHub** — community skills from the Hermes ecosystem
|
||||
3. **ClawHub** — the OpenClaw skill marketplace (13,000+ skills)
|
||||
|
||||
## Capabilities
|
||||
|
||||
- **Browse** available skills across all registries
|
||||
- **Search** for skills by keyword, category, or tag
|
||||
- **Install** skills from remote SKILL.md URLs
|
||||
- **List** currently installed skills
|
||||
- **Uninstall** skills that are no longer needed
|
||||
- **Describe** installed skills in detail
|
||||
|
||||
## Workflow
|
||||
|
||||
1. When the user asks to find a skill, search across registries.
|
||||
2. Present results clearly: name, description, source, install count.
|
||||
3. Ask the user which skill(s) to install if multiple match.
|
||||
4. Install the selected skill and confirm it was added.
|
||||
5. If installation fails, explain the error and suggest alternatives.
|
||||
|
||||
## Important rules
|
||||
|
||||
- Always show the source registry for each skill (OpenHuman, HermesHub, ClawHub).
|
||||
- Warn the user about unverified skills — community skills may not be security-audited.
|
||||
- Never install a skill without the user's confirmation.
|
||||
- For ClawHub skills that cannot be installed directly, explain the alternative (OpenClaw CLI).
|
||||
- When listing installed skills, indicate scope (user vs. project).
|
||||
@@ -0,0 +1,38 @@
|
||||
//! System prompt builder for the `skill_setup` built-in agent.
|
||||
|
||||
use crate::openhuman::context::prompt::{
|
||||
render_safety, render_tools, render_user_files, render_workspace, PromptContext,
|
||||
};
|
||||
use anyhow::Result;
|
||||
|
||||
const ARCHETYPE: &str = include_str!("prompt.md");
|
||||
|
||||
pub fn build(ctx: &PromptContext<'_>) -> Result<String> {
|
||||
let mut out = String::with_capacity(4096);
|
||||
out.push_str(ARCHETYPE.trim_end());
|
||||
out.push_str("\n\n");
|
||||
|
||||
let user_files = render_user_files(ctx)?;
|
||||
if !user_files.trim().is_empty() {
|
||||
out.push_str(user_files.trim_end());
|
||||
out.push_str("\n\n");
|
||||
}
|
||||
|
||||
let tools = render_tools(ctx)?;
|
||||
if !tools.trim().is_empty() {
|
||||
out.push_str(tools.trim_end());
|
||||
out.push_str("\n\n");
|
||||
}
|
||||
|
||||
let safety = render_safety();
|
||||
out.push_str(safety.trim_end());
|
||||
out.push_str("\n\n");
|
||||
|
||||
let workspace = render_workspace(ctx)?;
|
||||
if !workspace.trim().is_empty() {
|
||||
out.push_str(workspace.trim_end());
|
||||
out.push('\n');
|
||||
}
|
||||
|
||||
Ok(out)
|
||||
}
|
||||
@@ -1,11 +1,12 @@
|
||||
//! Skill registry: browse, search, and install skills from remote registries.
|
||||
//!
|
||||
//! Supports multiple source formats (OpenHuman SKILL.md, Hermes, OpenClaw)
|
||||
//! unified behind a single catalog interface with local caching.
|
||||
//! Skill registry: browse, search, and install skills from the aggregated
|
||||
//! Hermes catalog (HermesHub, ClawHub, skills.sh, LobeHub, browse.sh)
|
||||
//! with local caching.
|
||||
|
||||
pub mod agent;
|
||||
pub mod ops;
|
||||
pub mod schemas;
|
||||
pub mod store;
|
||||
pub mod tools;
|
||||
pub mod types;
|
||||
|
||||
pub use schemas::{
|
||||
|
||||
+346
-264
@@ -1,119 +1,81 @@
|
||||
//! Business logic for the skill registry: fetch catalogs, search, and install.
|
||||
|
||||
use serde::Deserialize;
|
||||
//! Business logic for the skill registry: fetch, index, search, and install.
|
||||
//!
|
||||
//! The catalog is sourced from the HermesHub aggregated JSON API which
|
||||
//! includes skills from HermesHub (built-in + optional), ClawHub, skills.sh,
|
||||
//! LobeHub, and browse.sh — all accessible from a single endpoint.
|
||||
|
||||
use super::store;
|
||||
use super::types::{CatalogEntry, RegistryKind, RegistrySource};
|
||||
use super::types::CatalogEntry;
|
||||
|
||||
const MAX_CATALOG_BYTES: usize = 5 * 1024 * 1024;
|
||||
const FETCH_TIMEOUT_SECS: u64 = 30;
|
||||
const CATALOG_URL: &str = "https://hermes-agent.nousresearch.com/docs/api/skills.json";
|
||||
const CATALOG_URL_ENV: &str = "OPENHUMAN_SKILL_REGISTRY_CATALOG_URL";
|
||||
const DOWNLOAD_BASE_URL_ENV: &str = "OPENHUMAN_SKILL_REGISTRY_DOWNLOAD_BASE_URL";
|
||||
const REFRESH_ON_BOOT_ENV: &str = "OPENHUMAN_SKILL_REGISTRY_REFRESH_ON_BOOT";
|
||||
const FETCH_TIMEOUT_SECS: u64 = 180;
|
||||
|
||||
/// Default registry sources shipped with the app.
|
||||
pub fn default_sources() -> Vec<RegistrySource> {
|
||||
vec![
|
||||
RegistrySource {
|
||||
id: "openhuman-community".into(),
|
||||
name: "OpenHuman Community Skills".into(),
|
||||
url: "https://raw.githubusercontent.com/tinyhumansai/skill-registry/main/index.json"
|
||||
.into(),
|
||||
kind: RegistryKind::GithubIndex,
|
||||
enabled: true,
|
||||
},
|
||||
RegistrySource {
|
||||
id: "awesome-openclaw".into(),
|
||||
name: "OpenClaw Skills".into(),
|
||||
url: "https://raw.githubusercontent.com/VoltAgent/awesome-openclaw-skills/main/index.json"
|
||||
.into(),
|
||||
kind: RegistryKind::GithubIndex,
|
||||
enabled: true,
|
||||
},
|
||||
RegistrySource {
|
||||
id: "hermes-community".into(),
|
||||
name: "Hermes Community Skills".into(),
|
||||
url: "https://raw.githubusercontent.com/hermes-agent/skill-index/main/index.json"
|
||||
.into(),
|
||||
kind: RegistryKind::GithubIndex,
|
||||
enabled: true,
|
||||
},
|
||||
]
|
||||
}
|
||||
/// Start a one-shot background refresh of the remote skills catalog.
|
||||
///
|
||||
/// This is intended for core startup: it warms the explorer/search cache without
|
||||
/// making core readiness depend on registry availability. Set
|
||||
/// `OPENHUMAN_SKILL_REGISTRY_REFRESH_ON_BOOT=0` to disable it in constrained
|
||||
/// environments.
|
||||
pub fn start_boot_catalog_refresh() {
|
||||
static STARTED: std::sync::Once = std::sync::Once::new();
|
||||
|
||||
/// Resolve the active list of registry sources: defaults + any user-added custom ones.
|
||||
pub fn list_sources() -> Vec<RegistrySource> {
|
||||
let mut sources = default_sources();
|
||||
let custom = store::load_custom_sources();
|
||||
tracing::debug!(
|
||||
default_count = sources.len(),
|
||||
custom_count = custom.len(),
|
||||
"[skill_registry] list_sources"
|
||||
);
|
||||
for c in custom {
|
||||
if !sources.iter().any(|s| s.id == c.id) {
|
||||
sources.push(c);
|
||||
STARTED.call_once(|| {
|
||||
if !refresh_on_boot_enabled(std::env::var(REFRESH_ON_BOOT_ENV).ok().as_deref()) {
|
||||
tracing::info!(
|
||||
env = REFRESH_ON_BOOT_ENV,
|
||||
"[skill_registry] boot catalog refresh disabled"
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
tracing::debug!(total = sources.len(), "[skill_registry] list_sources done");
|
||||
sources
|
||||
|
||||
tracing::info!("[skill_registry] scheduling boot catalog refresh");
|
||||
tokio::spawn(async {
|
||||
let started = std::time::Instant::now();
|
||||
match browse_catalog(true).await {
|
||||
Ok(entries) => {
|
||||
tracing::info!(
|
||||
count = entries.len(),
|
||||
elapsed_ms = started.elapsed().as_millis(),
|
||||
"[skill_registry] boot catalog refresh complete"
|
||||
);
|
||||
}
|
||||
Err(error) => {
|
||||
tracing::warn!(
|
||||
error = %error,
|
||||
elapsed_ms = started.elapsed().as_millis(),
|
||||
"[skill_registry] boot catalog refresh failed"
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// Add a custom registry source.
|
||||
pub fn add_source(source: RegistrySource) -> Result<(), String> {
|
||||
tracing::debug!(
|
||||
source_id = %source.id,
|
||||
source_url = %source.url,
|
||||
"[skill_registry] add_source"
|
||||
);
|
||||
if source.id.is_empty() {
|
||||
return Err("source id must not be empty".into());
|
||||
}
|
||||
if source.url.is_empty() {
|
||||
return Err("source url must not be empty".into());
|
||||
}
|
||||
let mut custom = store::load_custom_sources();
|
||||
if custom.iter().any(|s| s.id == source.id) {
|
||||
return Err(format!("source '{}' already exists", source.id));
|
||||
}
|
||||
custom.push(source);
|
||||
store::save_custom_sources(&custom);
|
||||
store::clear_cache();
|
||||
tracing::info!("[skill_registry] source added, cache cleared");
|
||||
Ok(())
|
||||
fn refresh_on_boot_enabled(raw: Option<&str>) -> bool {
|
||||
let Some(raw) = raw else { return true };
|
||||
let value = raw.trim();
|
||||
!(value == "0"
|
||||
|| value.eq_ignore_ascii_case("false")
|
||||
|| value.eq_ignore_ascii_case("no")
|
||||
|| value.eq_ignore_ascii_case("off"))
|
||||
}
|
||||
|
||||
/// Remove a custom registry source by id.
|
||||
pub fn remove_source(id: &str) -> Result<(), String> {
|
||||
tracing::debug!(source_id = %id, "[skill_registry] remove_source");
|
||||
let mut custom = store::load_custom_sources();
|
||||
let before = custom.len();
|
||||
custom.retain(|s| s.id != id);
|
||||
if custom.len() == before {
|
||||
return Err(format!("source '{id}' not found in custom sources"));
|
||||
}
|
||||
store::save_custom_sources(&custom);
|
||||
store::clear_cache();
|
||||
tracing::info!(source_id = %id, "[skill_registry] source removed, cache cleared");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Fetch the full catalog from all enabled sources, using cache when fresh.
|
||||
/// Fetch the full catalog, using cache when fresh.
|
||||
pub async fn browse_catalog(force_refresh: bool) -> Result<Vec<CatalogEntry>, String> {
|
||||
if !force_refresh {
|
||||
if let Some(cached) = store::load_cached_catalog() {
|
||||
tracing::debug!(count = cached.len(), "[skill_registry] serving from cache");
|
||||
return Ok(cached);
|
||||
}
|
||||
}
|
||||
|
||||
let sources = list_sources();
|
||||
let enabled: Vec<&RegistrySource> = sources.iter().filter(|s| s.enabled).collect();
|
||||
|
||||
if enabled.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let catalog_url = catalog_url();
|
||||
tracing::info!(
|
||||
count = enabled.len(),
|
||||
"[skill_registry] fetching catalogs from {} source(s)",
|
||||
enabled.len()
|
||||
catalog_url = %redact_url_for_log(&catalog_url),
|
||||
"[skill_registry] fetching catalog"
|
||||
);
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
@@ -121,59 +83,74 @@ pub async fn browse_catalog(force_refresh: bool) -> Result<Vec<CatalogEntry>, St
|
||||
.build()
|
||||
.map_err(|e| format!("failed to build http client: {e}"))?;
|
||||
|
||||
let mut all_entries: Vec<CatalogEntry> = Vec::new();
|
||||
let mut errors: Vec<String> = Vec::new();
|
||||
let response = client
|
||||
.get(&catalog_url)
|
||||
.header("User-Agent", "openhuman-core")
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("catalog fetch failed: {e}"))?;
|
||||
|
||||
for source in &enabled {
|
||||
match fetch_source_catalog(&client, source).await {
|
||||
Ok(entries) => {
|
||||
tracing::debug!(
|
||||
source = %source.id,
|
||||
count = entries.len(),
|
||||
"[skill_registry] fetched catalog"
|
||||
);
|
||||
all_entries.extend(entries);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
source = %source.id,
|
||||
error = %e,
|
||||
"[skill_registry] failed to fetch catalog"
|
||||
);
|
||||
errors.push(format!("{}: {e}", source.id));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
all_entries.sort_by(|a, b| {
|
||||
b.stars
|
||||
.unwrap_or(0)
|
||||
.cmp(&a.stars.unwrap_or(0))
|
||||
.then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase()))
|
||||
});
|
||||
|
||||
store::save_catalog_cache(&all_entries);
|
||||
|
||||
if all_entries.is_empty() && !errors.is_empty() {
|
||||
if !response.status().is_success() {
|
||||
return Err(format!(
|
||||
"all registry sources failed: {}",
|
||||
errors.join("; ")
|
||||
"catalog returned status {}",
|
||||
response.status().as_u16()
|
||||
));
|
||||
}
|
||||
|
||||
Ok(all_entries)
|
||||
let body = response
|
||||
.text()
|
||||
.await
|
||||
.map_err(|e| format!("failed to read response: {e}"))?;
|
||||
|
||||
let raw_items: Vec<serde_json::Value> = parse_catalog_json(&body)?;
|
||||
|
||||
tracing::info!(
|
||||
total_raw = raw_items.len(),
|
||||
"[skill_registry] parsing catalog"
|
||||
);
|
||||
|
||||
let entries: Vec<CatalogEntry> = raw_items.iter().filter_map(parse_hermes_entry).collect();
|
||||
|
||||
tracing::info!(count = entries.len(), "[skill_registry] catalog indexed");
|
||||
|
||||
store::save_catalog_cache(&entries);
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
/// Search the catalog by query string, matching against name, description, tags, and format.
|
||||
fn catalog_url() -> String {
|
||||
std::env::var(CATALOG_URL_ENV)
|
||||
.ok()
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or_else(|| CATALOG_URL.to_string())
|
||||
}
|
||||
|
||||
fn redact_url_for_log(raw: &str) -> String {
|
||||
match url::Url::parse(raw) {
|
||||
Ok(parsed) => {
|
||||
let scheme = parsed.scheme();
|
||||
let host = parsed.host_str().unwrap_or("");
|
||||
let path = parsed.path();
|
||||
format!("{scheme}://{host}{path}")
|
||||
}
|
||||
Err(_) => "<unparseable>".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn parse_catalog_json(body: &str) -> Result<Vec<serde_json::Value>, String> {
|
||||
serde_json::from_str(body).map_err(|e| format!("invalid catalog json: {e}"))
|
||||
}
|
||||
|
||||
/// Search the catalog by query string.
|
||||
pub async fn search_catalog(
|
||||
query: &str,
|
||||
format_filter: Option<&str>,
|
||||
source_filter: Option<&str>,
|
||||
category_filter: Option<&str>,
|
||||
) -> Result<Vec<CatalogEntry>, String> {
|
||||
tracing::debug!(
|
||||
query = %query,
|
||||
format_filter = ?format_filter,
|
||||
source_filter = ?source_filter,
|
||||
category_filter = ?category_filter,
|
||||
"[skill_registry] search_catalog"
|
||||
);
|
||||
let catalog = browse_catalog(false).await?;
|
||||
@@ -182,13 +159,13 @@ pub async fn search_catalog(
|
||||
let filtered: Vec<CatalogEntry> = catalog
|
||||
.into_iter()
|
||||
.filter(|entry| {
|
||||
if let Some(fmt) = format_filter {
|
||||
if entry.format != fmt {
|
||||
if let Some(src) = source_filter {
|
||||
if !entry.source.eq_ignore_ascii_case(src) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if let Some(src) = source_filter {
|
||||
if entry.source_id != src {
|
||||
if let Some(cat) = category_filter {
|
||||
if !entry.category.eq_ignore_ascii_case(cat) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -198,7 +175,7 @@ pub async fn search_catalog(
|
||||
entry.name.to_lowercase().contains(&q)
|
||||
|| entry.description.to_lowercase().contains(&q)
|
||||
|| entry.tags.iter().any(|t| t.to_lowercase().contains(&q))
|
||||
|| entry.format.to_lowercase().contains(&q)
|
||||
|| entry.category.to_lowercase().contains(&q)
|
||||
|| entry
|
||||
.author
|
||||
.as_deref()
|
||||
@@ -209,21 +186,47 @@ pub async fn search_catalog(
|
||||
|
||||
tracing::debug!(
|
||||
result_count = filtered.len(),
|
||||
"[skill_registry] search_catalog complete"
|
||||
"[skill_registry] search complete"
|
||||
);
|
||||
Ok(filtered)
|
||||
}
|
||||
|
||||
/// Install a skill from the catalog by its entry. Delegates to the existing
|
||||
/// `install_workflow_from_url` in the workflows module.
|
||||
/// Return the distinct set of upstream sources present in the catalog.
|
||||
pub async fn list_sources() -> Result<Vec<String>, String> {
|
||||
let catalog = browse_catalog(false).await?;
|
||||
let mut sources: Vec<String> = catalog
|
||||
.iter()
|
||||
.map(|e| e.source.clone())
|
||||
.collect::<std::collections::BTreeSet<_>>()
|
||||
.into_iter()
|
||||
.collect();
|
||||
sources.sort();
|
||||
Ok(sources)
|
||||
}
|
||||
|
||||
/// Return the distinct set of categories present in the catalog.
|
||||
pub async fn list_categories() -> Result<Vec<String>, String> {
|
||||
let catalog = browse_catalog(false).await?;
|
||||
let mut categories: Vec<String> = catalog
|
||||
.iter()
|
||||
.map(|e| e.category.clone())
|
||||
.filter(|c| !c.is_empty())
|
||||
.collect::<std::collections::BTreeSet<_>>()
|
||||
.into_iter()
|
||||
.collect();
|
||||
categories.sort();
|
||||
Ok(categories)
|
||||
}
|
||||
|
||||
/// Install a skill from the catalog by its entry id.
|
||||
pub async fn install_from_catalog(
|
||||
workspace_dir: &std::path::Path,
|
||||
entry: &CatalogEntry,
|
||||
) -> Result<crate::openhuman::workflows::ops_install::InstallWorkflowFromUrlOutcome, String> {
|
||||
tracing::info!(
|
||||
entry_id = %entry.id,
|
||||
source = %entry.source_id,
|
||||
format = %entry.format,
|
||||
source = %entry.source,
|
||||
download_url = %entry.download_url,
|
||||
"[skill_registry] installing from catalog"
|
||||
);
|
||||
|
||||
@@ -235,132 +238,211 @@ pub async fn install_from_catalog(
|
||||
crate::openhuman::workflows::ops_install::install_workflow_from_url(workspace_dir, params).await
|
||||
}
|
||||
|
||||
async fn fetch_source_catalog(
|
||||
client: &reqwest::Client,
|
||||
source: &RegistrySource,
|
||||
) -> Result<Vec<CatalogEntry>, String> {
|
||||
let response = client
|
||||
.get(&source.url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("fetch failed: {e}"))?;
|
||||
pub(crate) fn parse_hermes_entry(item: &serde_json::Value) -> Option<CatalogEntry> {
|
||||
let name = item.get("name").and_then(|v| v.as_str())?.to_string();
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(format!(
|
||||
"fetch returned status {}",
|
||||
response.status().as_u16()
|
||||
));
|
||||
}
|
||||
let description = item
|
||||
.get("description")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
|
||||
if let Some(len) = response.content_length() {
|
||||
if len > MAX_CATALOG_BYTES as u64 {
|
||||
return Err(format!("catalog too large: {len} bytes"));
|
||||
}
|
||||
}
|
||||
let source = item
|
||||
.get("source")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("hermes")
|
||||
.to_string();
|
||||
|
||||
let body = response
|
||||
.text()
|
||||
.await
|
||||
.map_err(|e| format!("failed to read body: {e}"))?;
|
||||
let category = item
|
||||
.get("category")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
|
||||
if body.len() > MAX_CATALOG_BYTES {
|
||||
return Err(format!("catalog too large: {} bytes", body.len()));
|
||||
}
|
||||
let author = item
|
||||
.get("author")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
match source.kind {
|
||||
RegistryKind::GithubIndex | RegistryKind::HttpCatalog => {
|
||||
parse_index_json(&body, &source.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
let version = item
|
||||
.get("version")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
/// Parse a JSON index file. Expects either:
|
||||
/// - An array of entry objects directly
|
||||
/// - An object with a "skills" or "entries" array field
|
||||
fn parse_index_json(body: &str, source_id: &str) -> Result<Vec<CatalogEntry>, String> {
|
||||
let value: serde_json::Value =
|
||||
serde_json::from_str(body).map_err(|e| format!("invalid JSON: {e}"))?;
|
||||
let license = item
|
||||
.get("license")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
let entries_value = if value.is_array() {
|
||||
value
|
||||
} else if let Some(arr) = value.get("skills").or(value.get("entries")) {
|
||||
arr.clone()
|
||||
} else {
|
||||
return Err("index JSON must be an array or contain a 'skills'/'entries' field".into());
|
||||
};
|
||||
|
||||
let raw_entries: Vec<RawIndexEntry> = serde_json::from_value(entries_value)
|
||||
.map_err(|e| format!("failed to parse index entries: {e}"))?;
|
||||
|
||||
let entries = raw_entries
|
||||
.into_iter()
|
||||
.filter_map(|raw| {
|
||||
let name = raw.name.as_deref().or(raw.title.as_deref())?.to_string();
|
||||
let description = raw
|
||||
.description
|
||||
.as_deref()
|
||||
.or(raw.summary.as_deref())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
let id = raw
|
||||
.id
|
||||
.as_deref()
|
||||
.or(raw.slug.as_deref())
|
||||
.unwrap_or(&name)
|
||||
.to_string();
|
||||
|
||||
let download_url = raw
|
||||
.download_url
|
||||
.as_deref()
|
||||
.or(raw.url.as_deref())
|
||||
.or(raw.raw_url.as_deref())?
|
||||
.to_string();
|
||||
|
||||
let format = raw
|
||||
.format
|
||||
.as_deref()
|
||||
.or(raw.skill_format.as_deref())
|
||||
.unwrap_or("openhuman")
|
||||
.to_string();
|
||||
|
||||
Some(CatalogEntry {
|
||||
id,
|
||||
name,
|
||||
description,
|
||||
format,
|
||||
author: raw.author,
|
||||
version: raw.version,
|
||||
tags: raw.tags.unwrap_or_default(),
|
||||
download_url,
|
||||
source_id: source_id.to_string(),
|
||||
stars: raw.stars.or(raw.star_count),
|
||||
updated_at: raw.updated_at.or(raw.last_updated),
|
||||
})
|
||||
let tags = item
|
||||
.get("tags")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|arr| {
|
||||
arr.iter()
|
||||
.filter_map(|t| t.as_str())
|
||||
.map(str::to_string)
|
||||
.collect()
|
||||
})
|
||||
.collect();
|
||||
.unwrap_or_default();
|
||||
|
||||
Ok(entries)
|
||||
let platforms = item
|
||||
.get("platforms")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|arr| {
|
||||
arr.iter()
|
||||
.filter_map(|t| t.as_str())
|
||||
.map(str::to_string)
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
let commands = item
|
||||
.get("commands")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|arr| {
|
||||
arr.iter()
|
||||
.filter_map(|t| t.as_str())
|
||||
.map(str::to_string)
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
let env_vars = item
|
||||
.get("envVars")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|arr| {
|
||||
arr.iter()
|
||||
.filter_map(|t| t.as_str())
|
||||
.map(str::to_string)
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
let docs_path = item
|
||||
.get("docsPath")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
let download_url = derive_download_url(&source, &category, &name, docs_path.as_deref());
|
||||
|
||||
Some(CatalogEntry {
|
||||
id: name.clone(),
|
||||
name,
|
||||
description,
|
||||
source,
|
||||
category,
|
||||
author,
|
||||
version,
|
||||
tags,
|
||||
platforms,
|
||||
download_url,
|
||||
docs_path,
|
||||
commands,
|
||||
env_vars,
|
||||
license,
|
||||
})
|
||||
}
|
||||
|
||||
/// Flexible raw index entry that handles varying field names across registries.
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct RawIndexEntry {
|
||||
id: Option<String>,
|
||||
slug: Option<String>,
|
||||
name: Option<String>,
|
||||
title: Option<String>,
|
||||
description: Option<String>,
|
||||
summary: Option<String>,
|
||||
format: Option<String>,
|
||||
skill_format: Option<String>,
|
||||
author: Option<String>,
|
||||
version: Option<String>,
|
||||
tags: Option<Vec<String>>,
|
||||
download_url: Option<String>,
|
||||
url: Option<String>,
|
||||
raw_url: Option<String>,
|
||||
stars: Option<u32>,
|
||||
star_count: Option<u32>,
|
||||
updated_at: Option<String>,
|
||||
last_updated: Option<String>,
|
||||
fn derive_download_url(
|
||||
source: &str,
|
||||
category: &str,
|
||||
name: &str,
|
||||
docs_path: Option<&str>,
|
||||
) -> String {
|
||||
if let Ok(base) = std::env::var(DOWNLOAD_BASE_URL_ENV) {
|
||||
let base = base.trim().trim_end_matches('/');
|
||||
if !base.is_empty() {
|
||||
return format!("{base}/{name}/SKILL.md");
|
||||
}
|
||||
}
|
||||
if let Some(url) = docs_path.and_then(download_url_from_docs_path) {
|
||||
return url;
|
||||
}
|
||||
let root = match source {
|
||||
"optional" => "optional-skills",
|
||||
_ => "skills",
|
||||
};
|
||||
format!(
|
||||
"https://raw.githubusercontent.com/NousResearch/hermes-agent/main/{root}/{category}/{name}/SKILL.md"
|
||||
)
|
||||
}
|
||||
|
||||
fn download_url_from_docs_path(docs_path: &str) -> Option<String> {
|
||||
let parts: Vec<&str> = docs_path.split('/').collect();
|
||||
if parts.len() != 3 {
|
||||
return None;
|
||||
}
|
||||
let root = match parts[0] {
|
||||
"bundled" => "skills",
|
||||
"optional" => "optional-skills",
|
||||
_ => return None,
|
||||
};
|
||||
let category = parts[1];
|
||||
let prefixed_slug = parts[2];
|
||||
let skill = prefixed_slug
|
||||
.strip_prefix(&format!("{category}-"))
|
||||
.unwrap_or(prefixed_slug);
|
||||
Some(format!(
|
||||
"https://raw.githubusercontent.com/NousResearch/hermes-agent/main/{root}/{category}/{skill}/SKILL.md"
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn parse_hermes_entry_derives_bundled_download_url_from_docs_path() {
|
||||
let item = json!({
|
||||
"name": "apple-notes",
|
||||
"description": "Manage Apple Notes",
|
||||
"category": "apple",
|
||||
"source": "built-in",
|
||||
"docsPath": "bundled/apple/apple-apple-notes",
|
||||
"tags": ["Apple"],
|
||||
"platforms": ["macos"],
|
||||
"commands": ["memo"],
|
||||
"envVars": []
|
||||
});
|
||||
let entry = parse_hermes_entry(&item).expect("entry");
|
||||
assert_eq!(
|
||||
entry.download_url,
|
||||
"https://raw.githubusercontent.com/NousResearch/hermes-agent/main/skills/apple/apple-notes/SKILL.md"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_hermes_entry_derives_optional_download_url_from_docs_path() {
|
||||
let item = json!({
|
||||
"name": "docker-management",
|
||||
"description": "Manage Docker",
|
||||
"category": "devops",
|
||||
"source": "optional",
|
||||
"docsPath": "optional/devops/devops-docker-management"
|
||||
});
|
||||
let entry = parse_hermes_entry(&item).expect("entry");
|
||||
assert_eq!(
|
||||
entry.download_url,
|
||||
"https://raw.githubusercontent.com/NousResearch/hermes-agent/main/optional-skills/devops/docker-management/SKILL.md"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_catalog_json_rejects_invalid_payloads() {
|
||||
let error = parse_catalog_json("{").expect_err("invalid json");
|
||||
assert!(error.contains("invalid catalog json"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refresh_on_boot_enabled_defaults_on_and_accepts_common_false_values() {
|
||||
assert!(refresh_on_boot_enabled(None));
|
||||
assert!(refresh_on_boot_enabled(Some("1")));
|
||||
assert!(refresh_on_boot_enabled(Some("true")));
|
||||
|
||||
assert!(!refresh_on_boot_enabled(Some("0")));
|
||||
assert!(!refresh_on_boot_enabled(Some("false")));
|
||||
assert!(!refresh_on_boot_enabled(Some(" no ")));
|
||||
assert!(!refresh_on_boot_enabled(Some("OFF")));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,8 +4,8 @@ use crate::core::all::RegisteredController;
|
||||
use crate::core::{ControllerSchema, FieldSchema, TypeSchema};
|
||||
|
||||
use super::handlers::{
|
||||
handle_add_source, handle_browse, handle_install, handle_remove_source, handle_search,
|
||||
handle_sources,
|
||||
handle_browse, handle_categories, handle_install, handle_schemas, handle_search,
|
||||
handle_sources, handle_uninstall,
|
||||
};
|
||||
|
||||
pub fn all_skill_registry_controller_schemas() -> Vec<ControllerSchema> {
|
||||
@@ -13,9 +13,10 @@ pub fn all_skill_registry_controller_schemas() -> Vec<ControllerSchema> {
|
||||
skill_registry_schemas("browse"),
|
||||
skill_registry_schemas("search"),
|
||||
skill_registry_schemas("sources"),
|
||||
skill_registry_schemas("add_source"),
|
||||
skill_registry_schemas("remove_source"),
|
||||
skill_registry_schemas("categories"),
|
||||
skill_registry_schemas("install"),
|
||||
skill_registry_schemas("uninstall"),
|
||||
skill_registry_schemas("schemas"),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -34,17 +35,21 @@ pub fn all_skill_registry_registered_controllers() -> Vec<RegisteredController>
|
||||
handler: handle_sources,
|
||||
},
|
||||
RegisteredController {
|
||||
schema: skill_registry_schemas("add_source"),
|
||||
handler: handle_add_source,
|
||||
},
|
||||
RegisteredController {
|
||||
schema: skill_registry_schemas("remove_source"),
|
||||
handler: handle_remove_source,
|
||||
schema: skill_registry_schemas("categories"),
|
||||
handler: handle_categories,
|
||||
},
|
||||
RegisteredController {
|
||||
schema: skill_registry_schemas("install"),
|
||||
handler: handle_install,
|
||||
},
|
||||
RegisteredController {
|
||||
schema: skill_registry_schemas("uninstall"),
|
||||
handler: handle_uninstall,
|
||||
},
|
||||
RegisteredController {
|
||||
schema: skill_registry_schemas("schemas"),
|
||||
handler: handle_schemas,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
@@ -53,24 +58,24 @@ pub fn skill_registry_schemas(function: &str) -> ControllerSchema {
|
||||
"browse" => ControllerSchema {
|
||||
namespace: "skill_registry",
|
||||
function: "browse",
|
||||
description: "Browse the skill registry catalog from all enabled sources. Returns cached results unless force_refresh is true.",
|
||||
description: "Browse the skill registry catalog (aggregated from HermesHub). Returns cached results unless force_refresh is true.",
|
||||
inputs: vec![FieldSchema {
|
||||
name: "force_refresh",
|
||||
ty: TypeSchema::Bool,
|
||||
comment: "Force re-fetch from remote sources, ignoring the local cache.",
|
||||
comment: "Force re-fetch from the Hermes API, ignoring the local cache.",
|
||||
required: false,
|
||||
}],
|
||||
outputs: vec![FieldSchema {
|
||||
name: "entries",
|
||||
ty: TypeSchema::Json,
|
||||
comment: "Array of catalog entries from all enabled registry sources.",
|
||||
comment: "Array of catalog entries.",
|
||||
required: true,
|
||||
}],
|
||||
},
|
||||
"search" => ControllerSchema {
|
||||
namespace: "skill_registry",
|
||||
function: "search",
|
||||
description: "Search the registry catalog by query string. Matches against name, description, tags, format, and author.",
|
||||
description: "Search the registry catalog by query string. Matches against name, description, tags, category, and author.",
|
||||
inputs: vec![
|
||||
FieldSchema {
|
||||
name: "query",
|
||||
@@ -79,15 +84,15 @@ pub fn skill_registry_schemas(function: &str) -> ControllerSchema {
|
||||
required: false,
|
||||
},
|
||||
FieldSchema {
|
||||
name: "format",
|
||||
name: "source",
|
||||
ty: TypeSchema::String,
|
||||
comment: "Filter by skill format: openhuman, hermes, or openclaw.",
|
||||
comment: "Filter by upstream source (e.g. 'ClawHub', 'skills.sh', 'built-in').",
|
||||
required: false,
|
||||
},
|
||||
FieldSchema {
|
||||
name: "source",
|
||||
name: "category",
|
||||
ty: TypeSchema::String,
|
||||
comment: "Filter by source id.",
|
||||
comment: "Filter by category.",
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
@@ -101,73 +106,31 @@ pub fn skill_registry_schemas(function: &str) -> ControllerSchema {
|
||||
"sources" => ControllerSchema {
|
||||
namespace: "skill_registry",
|
||||
function: "sources",
|
||||
description: "List all configured registry sources (default + custom).",
|
||||
description: "List the distinct upstream sources present in the catalog (e.g. 'built-in', 'ClawHub', 'skills.sh').",
|
||||
inputs: vec![],
|
||||
outputs: vec![FieldSchema {
|
||||
name: "sources",
|
||||
ty: TypeSchema::Json,
|
||||
comment: "Array of registry sources with id, name, url, kind, and enabled status.",
|
||||
comment: "Array of source name strings.",
|
||||
required: true,
|
||||
}],
|
||||
},
|
||||
"add_source" => ControllerSchema {
|
||||
"categories" => ControllerSchema {
|
||||
namespace: "skill_registry",
|
||||
function: "add_source",
|
||||
description: "Add a custom registry source. Clears the catalog cache.",
|
||||
inputs: vec![
|
||||
FieldSchema {
|
||||
name: "id",
|
||||
ty: TypeSchema::String,
|
||||
comment: "Unique identifier for the source.",
|
||||
required: true,
|
||||
},
|
||||
FieldSchema {
|
||||
name: "name",
|
||||
ty: TypeSchema::String,
|
||||
comment: "Display name.",
|
||||
required: true,
|
||||
},
|
||||
FieldSchema {
|
||||
name: "url",
|
||||
ty: TypeSchema::String,
|
||||
comment: "URL to the index.json catalog.",
|
||||
required: true,
|
||||
},
|
||||
FieldSchema {
|
||||
name: "kind",
|
||||
ty: TypeSchema::String,
|
||||
comment: "Registry kind: github_index or http_catalog. Default: github_index.",
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
function: "categories",
|
||||
description: "List the distinct categories present in the catalog.",
|
||||
inputs: vec![],
|
||||
outputs: vec![FieldSchema {
|
||||
name: "sources",
|
||||
name: "categories",
|
||||
ty: TypeSchema::Json,
|
||||
comment: "Updated list of all sources.",
|
||||
required: true,
|
||||
}],
|
||||
},
|
||||
"remove_source" => ControllerSchema {
|
||||
namespace: "skill_registry",
|
||||
function: "remove_source",
|
||||
description: "Remove a custom registry source by id. Default sources cannot be removed.",
|
||||
inputs: vec![FieldSchema {
|
||||
name: "id",
|
||||
ty: TypeSchema::String,
|
||||
comment: "Id of the custom source to remove.",
|
||||
required: true,
|
||||
}],
|
||||
outputs: vec![FieldSchema {
|
||||
name: "sources",
|
||||
ty: TypeSchema::Json,
|
||||
comment: "Updated list of all sources.",
|
||||
comment: "Array of category name strings.",
|
||||
required: true,
|
||||
}],
|
||||
},
|
||||
"install" => ControllerSchema {
|
||||
namespace: "skill_registry",
|
||||
function: "install",
|
||||
description: "Install a skill from the registry by its catalog entry id and source id. Fetches the SKILL.md and installs to user scope.",
|
||||
description: "Install a skill from the catalog by its entry id. Fetches the SKILL.md and installs to user scope.",
|
||||
inputs: vec![
|
||||
FieldSchema {
|
||||
name: "entry_id",
|
||||
@@ -175,12 +138,6 @@ pub fn skill_registry_schemas(function: &str) -> ControllerSchema {
|
||||
comment: "Catalog entry id of the skill to install.",
|
||||
required: true,
|
||||
},
|
||||
FieldSchema {
|
||||
name: "source_id",
|
||||
ty: TypeSchema::String,
|
||||
comment: "Registry source id the entry belongs to.",
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
outputs: vec![
|
||||
FieldSchema {
|
||||
@@ -209,6 +166,49 @@ pub fn skill_registry_schemas(function: &str) -> ControllerSchema {
|
||||
},
|
||||
],
|
||||
},
|
||||
"uninstall" => ControllerSchema {
|
||||
namespace: "skill_registry",
|
||||
function: "uninstall",
|
||||
description: "Uninstall an installed user-scope skill by slug.",
|
||||
inputs: vec![FieldSchema {
|
||||
name: "name",
|
||||
ty: TypeSchema::String,
|
||||
comment: "Installed skill slug to remove from the user skills directory.",
|
||||
required: true,
|
||||
}],
|
||||
outputs: vec![
|
||||
FieldSchema {
|
||||
name: "name",
|
||||
ty: TypeSchema::String,
|
||||
comment: "Removed skill slug.",
|
||||
required: true,
|
||||
},
|
||||
FieldSchema {
|
||||
name: "removed_path",
|
||||
ty: TypeSchema::String,
|
||||
comment: "Absolute path removed from disk.",
|
||||
required: true,
|
||||
},
|
||||
FieldSchema {
|
||||
name: "scope",
|
||||
ty: TypeSchema::String,
|
||||
comment: "Scope removed; currently user.",
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
"schemas" => ControllerSchema {
|
||||
namespace: "skill_registry",
|
||||
function: "schemas",
|
||||
description: "Return the skill_registry controller schemas for CLI/RPC smoke-test script generation.",
|
||||
inputs: vec![],
|
||||
outputs: vec![FieldSchema {
|
||||
name: "schemas",
|
||||
ty: TypeSchema::Json,
|
||||
comment: "Array of skill_registry controller schemas.",
|
||||
required: true,
|
||||
}],
|
||||
},
|
||||
_ => ControllerSchema {
|
||||
namespace: "skill_registry",
|
||||
function: "unknown",
|
||||
|
||||
@@ -4,12 +4,12 @@ use serde_json::{Map, Value};
|
||||
|
||||
use crate::core::all::ControllerFuture;
|
||||
use crate::openhuman::skill_registry::ops;
|
||||
use crate::openhuman::skill_registry::types::RegistrySource;
|
||||
use crate::rpc::RpcOutcome;
|
||||
|
||||
use super::controller_schemas::all_skill_registry_controller_schemas;
|
||||
use super::wire_types::{
|
||||
AddSourceParams, BrowseParams, BrowseResult, InstallParams, InstallResult, RemoveSourceParams,
|
||||
SearchParams, SearchResult, SourcesResult,
|
||||
BrowseParams, BrowseResult, CategoriesResult, InstallParams, InstallResult, SchemasResult,
|
||||
SearchParams, SearchResult, SourcesResult, UninstallParams, UninstallResult,
|
||||
};
|
||||
|
||||
fn deserialize_params<T: serde::de::DeserializeOwned>(
|
||||
@@ -40,12 +40,12 @@ pub(super) fn handle_search(params: Map<String, Value>) -> ControllerFuture {
|
||||
let p = deserialize_params::<SearchParams>(params)?;
|
||||
tracing::debug!(
|
||||
query = %p.query,
|
||||
format = ?p.format,
|
||||
source = ?p.source,
|
||||
category = ?p.category,
|
||||
"[skill_registry][rpc] search"
|
||||
);
|
||||
let entries =
|
||||
ops::search_catalog(&p.query, p.format.as_deref(), p.source.as_deref()).await?;
|
||||
ops::search_catalog(&p.query, p.source.as_deref(), p.category.as_deref()).await?;
|
||||
tracing::debug!(count = entries.len(), "[skill_registry][rpc] search result");
|
||||
to_json(RpcOutcome::new(SearchResult { entries }, Vec::new()))
|
||||
})
|
||||
@@ -54,35 +54,16 @@ pub(super) fn handle_search(params: Map<String, Value>) -> ControllerFuture {
|
||||
pub(super) fn handle_sources(params: Map<String, Value>) -> ControllerFuture {
|
||||
Box::pin(async move {
|
||||
let _ = params;
|
||||
let sources = ops::list_sources();
|
||||
let sources = ops::list_sources().await?;
|
||||
to_json(RpcOutcome::new(SourcesResult { sources }, Vec::new()))
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn handle_add_source(params: Map<String, Value>) -> ControllerFuture {
|
||||
pub(super) fn handle_categories(params: Map<String, Value>) -> ControllerFuture {
|
||||
Box::pin(async move {
|
||||
let p = deserialize_params::<AddSourceParams>(params)?;
|
||||
tracing::info!(id = %p.id, url = %p.url, "[skill_registry][rpc] add_source");
|
||||
let source = RegistrySource {
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
url: p.url,
|
||||
kind: p.kind,
|
||||
enabled: true,
|
||||
};
|
||||
ops::add_source(source)?;
|
||||
let sources = ops::list_sources();
|
||||
to_json(RpcOutcome::new(SourcesResult { sources }, Vec::new()))
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn handle_remove_source(params: Map<String, Value>) -> ControllerFuture {
|
||||
Box::pin(async move {
|
||||
let p = deserialize_params::<RemoveSourceParams>(params)?;
|
||||
tracing::info!(id = %p.id, "[skill_registry][rpc] remove_source");
|
||||
ops::remove_source(&p.id)?;
|
||||
let sources = ops::list_sources();
|
||||
to_json(RpcOutcome::new(SourcesResult { sources }, Vec::new()))
|
||||
let _ = params;
|
||||
let categories = ops::list_categories().await?;
|
||||
to_json(RpcOutcome::new(CategoriesResult { categories }, Vec::new()))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -91,18 +72,17 @@ pub(super) fn handle_install(params: Map<String, Value>) -> ControllerFuture {
|
||||
let p = deserialize_params::<InstallParams>(params)?;
|
||||
tracing::info!(
|
||||
entry_id = %p.entry_id,
|
||||
source_id = %p.source_id,
|
||||
"[skill_registry][rpc] install"
|
||||
);
|
||||
|
||||
let catalog = ops::browse_catalog(false).await?;
|
||||
let entry = catalog
|
||||
.iter()
|
||||
.find(|e| e.id == p.entry_id && e.source_id == p.source_id)
|
||||
.find(|e| e.id == p.entry_id)
|
||||
.ok_or_else(|| {
|
||||
format!(
|
||||
"entry '{}' not found in source '{}'",
|
||||
p.entry_id, p.source_id
|
||||
"entry '{}' not found in catalog. Run skill_registry_browse with force_refresh first.",
|
||||
p.entry_id
|
||||
)
|
||||
})?;
|
||||
|
||||
@@ -120,3 +100,38 @@ pub(super) fn handle_install(params: Map<String, Value>) -> ControllerFuture {
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn handle_schemas(params: Map<String, Value>) -> ControllerFuture {
|
||||
Box::pin(async move {
|
||||
let _ = params;
|
||||
to_json(RpcOutcome::new(
|
||||
SchemasResult {
|
||||
schemas: all_skill_registry_controller_schemas(),
|
||||
},
|
||||
Vec::new(),
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn handle_uninstall(params: Map<String, Value>) -> ControllerFuture {
|
||||
Box::pin(async move {
|
||||
let payload = deserialize_params::<UninstallParams>(params)?;
|
||||
tracing::info!(
|
||||
name = %payload.name,
|
||||
"[skill_registry][rpc] uninstall"
|
||||
);
|
||||
let workflow_params = crate::openhuman::workflows::ops_install::UninstallWorkflowParams {
|
||||
name: payload.name,
|
||||
};
|
||||
let outcome =
|
||||
crate::openhuman::workflows::ops_install::uninstall_workflow(workflow_params, None)?;
|
||||
to_json(RpcOutcome::new(
|
||||
UninstallResult {
|
||||
name: outcome.name,
|
||||
removed_path: outcome.removed_path,
|
||||
scope: outcome.scope,
|
||||
},
|
||||
Vec::new(),
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::openhuman::skill_registry::types::{CatalogEntry, RegistryKind, RegistrySource};
|
||||
use crate::core::ControllerSchema;
|
||||
use crate::openhuman::skill_registry::types::CatalogEntry;
|
||||
use crate::openhuman::workflows::ops_types::WorkflowScope;
|
||||
|
||||
// ── Params ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -17,33 +19,19 @@ pub(super) struct SearchParams {
|
||||
#[serde(default)]
|
||||
pub(super) query: String,
|
||||
#[serde(default)]
|
||||
pub(super) format: Option<String>,
|
||||
#[serde(default)]
|
||||
pub(super) source: Option<String>,
|
||||
#[serde(default)]
|
||||
pub(super) category: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(super) struct InstallParams {
|
||||
pub(super) entry_id: String,
|
||||
pub(super) source_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(super) struct AddSourceParams {
|
||||
pub(super) id: String,
|
||||
pub(super) struct UninstallParams {
|
||||
pub(super) name: String,
|
||||
pub(super) url: String,
|
||||
#[serde(default = "default_kind")]
|
||||
pub(super) kind: RegistryKind,
|
||||
}
|
||||
|
||||
fn default_kind() -> RegistryKind {
|
||||
RegistryKind::GithubIndex
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(super) struct RemoveSourceParams {
|
||||
pub(super) id: String,
|
||||
}
|
||||
|
||||
// ── Results ─────────────────────────────────────────────────────────────────
|
||||
@@ -60,7 +48,12 @@ pub(super) struct SearchResult {
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub(super) struct SourcesResult {
|
||||
pub(super) sources: Vec<RegistrySource>,
|
||||
pub(super) sources: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub(super) struct CategoriesResult {
|
||||
pub(super) categories: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
@@ -70,3 +63,15 @@ pub(super) struct InstallResult {
|
||||
pub(super) stderr: String,
|
||||
pub(super) new_skills: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub(super) struct UninstallResult {
|
||||
pub(super) name: String,
|
||||
pub(super) removed_path: String,
|
||||
pub(super) scope: WorkflowScope,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub(super) struct SchemasResult {
|
||||
pub(super) schemas: Vec<ControllerSchema>,
|
||||
}
|
||||
|
||||
@@ -1,17 +1,15 @@
|
||||
//! Persistence for the skill registry: cached catalog entries and custom sources.
|
||||
//! Persistence for the skill registry: cached catalog entries.
|
||||
//!
|
||||
//! The cache lives at `~/.openhuman/skill-registry/cache.json` with a TTL.
|
||||
//! Custom sources are persisted at `~/.openhuman/skill-registry/sources.json`.
|
||||
//! The cache lives at `~/.openhuman/skill-registry/cache.json` with a 1-hour TTL.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::path::PathBuf;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::types::{CatalogEntry, RegistrySource};
|
||||
use super::types::CatalogEntry;
|
||||
|
||||
const CACHE_DIR: &str = "skill-registry";
|
||||
const CACHE_FILE: &str = "cache.json";
|
||||
const SOURCES_FILE: &str = "sources.json";
|
||||
const CACHE_TTL_SECS: u64 = 3600;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
@@ -75,28 +73,6 @@ pub fn save_catalog_cache(entries: &[CatalogEntry]) {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn load_custom_sources() -> Vec<RegistrySource> {
|
||||
let Some(dir) = registry_dir() else {
|
||||
return Vec::new();
|
||||
};
|
||||
let path = dir.join(SOURCES_FILE);
|
||||
let Ok(data) = std::fs::read_to_string(&path) else {
|
||||
return Vec::new();
|
||||
};
|
||||
serde_json::from_str(&data).unwrap_or_default()
|
||||
}
|
||||
|
||||
pub fn save_custom_sources(sources: &[RegistrySource]) {
|
||||
let Some(dir) = registry_dir() else { return };
|
||||
let _ = std::fs::create_dir_all(&dir);
|
||||
let path = dir.join(SOURCES_FILE);
|
||||
if let Ok(json) = serde_json::to_string_pretty(sources) {
|
||||
if let Err(e) = std::fs::write(&path, json) {
|
||||
tracing::warn!(error = %e, "[skill_registry] failed to write sources");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn clear_cache() {
|
||||
let Some(dir) = registry_dir() else { return };
|
||||
let path = dir.join(CACHE_FILE);
|
||||
|
||||
@@ -0,0 +1,288 @@
|
||||
//! LLM-callable tools for the skill registry domain.
|
||||
//!
|
||||
//! These tools let the orchestrator (and other agents) browse the aggregated
|
||||
//! Hermes catalog, search for skills, and install from catalog entries.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::openhuman::config::Config;
|
||||
use crate::openhuman::tools::traits::{PermissionLevel, Tool, ToolResult};
|
||||
|
||||
use super::ops;
|
||||
|
||||
pub struct SkillRegistryBrowseTool;
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for SkillRegistryBrowseTool {
|
||||
fn name(&self) -> &str {
|
||||
"skill_registry_browse"
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"Browse the aggregated skill catalog (HermesHub, ClawHub, skills.sh, \
|
||||
LobeHub, browse.sh). Returns all available skills with metadata. \
|
||||
Use `force_refresh: true` to bypass the cache."
|
||||
}
|
||||
|
||||
fn parameters_schema(&self) -> serde_json::Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"force_refresh": {
|
||||
"type": "boolean",
|
||||
"description": "Force re-fetch from the Hermes API, bypassing cache.",
|
||||
"default": false
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
|
||||
let force = args
|
||||
.get("force_refresh")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
tracing::debug!(force_refresh = force, "[tool][skill_registry] browse");
|
||||
|
||||
match ops::browse_catalog(force).await {
|
||||
Ok(entries) => Ok(ToolResult::success(serde_json::to_string(&json!({
|
||||
"count": entries.len(),
|
||||
"entries": entries,
|
||||
}))?)),
|
||||
Err(e) => Ok(ToolResult::error(format!(
|
||||
"Failed to browse skill catalog: {e}"
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
fn is_concurrency_safe(&self, _args: &serde_json::Value) -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
pub struct SkillRegistrySearchTool;
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for SkillRegistrySearchTool {
|
||||
fn name(&self) -> &str {
|
||||
"skill_registry_search"
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"Search available skills by keyword. Matches against name, description, \
|
||||
tags, category, and author. Optionally filter by source or category."
|
||||
}
|
||||
|
||||
fn parameters_schema(&self) -> serde_json::Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {
|
||||
"type": "string",
|
||||
"description": "Search query to match against skill name, description, tags, category, or author."
|
||||
},
|
||||
"source": {
|
||||
"type": "string",
|
||||
"description": "Filter by upstream source (e.g. 'ClawHub', 'skills.sh', 'built-in', 'LobeHub')."
|
||||
},
|
||||
"category": {
|
||||
"type": "string",
|
||||
"description": "Filter by category."
|
||||
}
|
||||
},
|
||||
"required": ["query"]
|
||||
})
|
||||
}
|
||||
|
||||
async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
|
||||
let query = args.get("query").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let source_filter = args.get("source").and_then(|v| v.as_str());
|
||||
let category_filter = args.get("category").and_then(|v| v.as_str());
|
||||
|
||||
tracing::debug!(
|
||||
query = %query,
|
||||
source = ?source_filter,
|
||||
category = ?category_filter,
|
||||
"[tool][skill_registry] search"
|
||||
);
|
||||
|
||||
match ops::search_catalog(query, source_filter, category_filter).await {
|
||||
Ok(entries) => Ok(ToolResult::success(serde_json::to_string(&json!({
|
||||
"count": entries.len(),
|
||||
"entries": entries,
|
||||
}))?)),
|
||||
Err(e) => Ok(ToolResult::error(format!(
|
||||
"Failed to search skill catalog: {e}"
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
fn is_concurrency_safe(&self, _args: &serde_json::Value) -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
pub struct SkillRegistryInstallTool {
|
||||
workspace_dir: PathBuf,
|
||||
}
|
||||
|
||||
impl SkillRegistryInstallTool {
|
||||
pub fn new(config: Arc<Config>) -> Self {
|
||||
Self {
|
||||
workspace_dir: config.workspace_dir.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for SkillRegistryInstallTool {
|
||||
fn name(&self) -> &str {
|
||||
"skill_registry_install"
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"Install a skill from the catalog by its entry_id. Downloads the \
|
||||
SKILL.md and installs it locally. Use `skill_registry_search` first \
|
||||
to find the entry to install."
|
||||
}
|
||||
|
||||
fn parameters_schema(&self) -> serde_json::Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"entry_id": {
|
||||
"type": "string",
|
||||
"description": "The skill entry id (slug) to install."
|
||||
}
|
||||
},
|
||||
"required": ["entry_id"]
|
||||
})
|
||||
}
|
||||
|
||||
fn permission_level(&self) -> PermissionLevel {
|
||||
PermissionLevel::Write
|
||||
}
|
||||
|
||||
async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
|
||||
let entry_id = args
|
||||
.get("entry_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("missing required argument `entry_id`"))?;
|
||||
|
||||
tracing::debug!(entry_id = %entry_id, "[tool][skill_registry] install");
|
||||
|
||||
let catalog = ops::browse_catalog(false)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("failed to load catalog: {e}"))?;
|
||||
|
||||
let entry = catalog.iter().find(|e| e.id == entry_id).ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"skill '{entry_id}' not found in catalog. \
|
||||
Run skill_registry_browse first to refresh."
|
||||
)
|
||||
})?;
|
||||
|
||||
match ops::install_from_catalog(&self.workspace_dir, entry).await {
|
||||
Ok(outcome) => Ok(ToolResult::success(serde_json::to_string(&json!({
|
||||
"url": outcome.url,
|
||||
"stdout": outcome.stdout,
|
||||
"stderr": outcome.stderr,
|
||||
"new_skills": outcome.new_skills,
|
||||
}))?)),
|
||||
Err(e) => Ok(ToolResult::error(format!(
|
||||
"Failed to install skill '{entry_id}': {e}"
|
||||
))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct SkillRegistrySourcesTool;
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for SkillRegistrySourcesTool {
|
||||
fn name(&self) -> &str {
|
||||
"skill_registry_sources"
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"List the distinct upstream sources available in the catalog \
|
||||
(e.g. 'built-in', 'ClawHub', 'skills.sh', 'LobeHub', 'browse.sh')."
|
||||
}
|
||||
|
||||
fn parameters_schema(&self) -> serde_json::Value {
|
||||
json!({ "type": "object", "properties": {} })
|
||||
}
|
||||
|
||||
async fn execute(&self, _args: serde_json::Value) -> anyhow::Result<ToolResult> {
|
||||
tracing::debug!("[tool][skill_registry] sources");
|
||||
match ops::list_sources().await {
|
||||
Ok(sources) => Ok(ToolResult::success(serde_json::to_string(&json!({
|
||||
"count": sources.len(),
|
||||
"sources": sources,
|
||||
}))?)),
|
||||
Err(e) => Ok(ToolResult::error(format!("Failed to list sources: {e}"))),
|
||||
}
|
||||
}
|
||||
|
||||
fn is_concurrency_safe(&self, _args: &serde_json::Value) -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
pub struct SkillRegistryUninstallTool;
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for SkillRegistryUninstallTool {
|
||||
fn name(&self) -> &str {
|
||||
"skill_registry_uninstall"
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"Uninstall an installed user-scope skill by slug. Use after listing \
|
||||
installed workflows or when the user asks to remove a skill."
|
||||
}
|
||||
|
||||
fn parameters_schema(&self) -> serde_json::Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "Installed skill slug to remove."
|
||||
}
|
||||
},
|
||||
"required": ["name"]
|
||||
})
|
||||
}
|
||||
|
||||
fn permission_level(&self) -> PermissionLevel {
|
||||
PermissionLevel::Write
|
||||
}
|
||||
|
||||
async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
|
||||
let name = args
|
||||
.get("name")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.ok_or_else(|| anyhow::anyhow!("missing required argument `name`"))?;
|
||||
tracing::debug!(name = %name, "[tool][skill_registry] uninstall");
|
||||
let params = crate::openhuman::workflows::ops_install::UninstallWorkflowParams {
|
||||
name: name.to_string(),
|
||||
};
|
||||
match crate::openhuman::workflows::ops_install::uninstall_workflow(params, None) {
|
||||
Ok(outcome) => Ok(ToolResult::success(serde_json::to_string(&json!({
|
||||
"name": outcome.name,
|
||||
"removed_path": outcome.removed_path,
|
||||
"scope": outcome.scope,
|
||||
}))?)),
|
||||
Err(error) => Ok(ToolResult::error(format!(
|
||||
"Failed to uninstall skill '{name}': {error}"
|
||||
))),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,55 +1,37 @@
|
||||
//! Domain types for the skill registry: sources, catalog entries, and search results.
|
||||
//! Domain types for the skill registry.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// A remote source of skills that the registry can fetch from.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RegistrySource {
|
||||
/// Unique identifier for this source (e.g. "openhuman-community", "clawhub", "hermes-hub").
|
||||
pub id: String,
|
||||
/// Human-readable label.
|
||||
pub name: String,
|
||||
/// Base URL for the registry API or GitHub repo index.
|
||||
pub url: String,
|
||||
/// What kind of registry this is.
|
||||
pub kind: RegistryKind,
|
||||
/// Whether this source is enabled.
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
/// The type of registry source — determines the fetch/parse strategy.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum RegistryKind {
|
||||
/// A GitHub repo containing an index of SKILL.md files (JSON manifest).
|
||||
GithubIndex,
|
||||
/// A raw HTTPS endpoint returning a JSON catalog.
|
||||
HttpCatalog,
|
||||
}
|
||||
|
||||
/// One entry in the remote catalog — metadata about an installable skill.
|
||||
/// One entry in the indexed skill catalog.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CatalogEntry {
|
||||
/// Unique slug within the source.
|
||||
/// Unique slug (e.g. "apple-notes", "docker-manager").
|
||||
pub id: String,
|
||||
/// Display name.
|
||||
pub name: String,
|
||||
/// Short description.
|
||||
pub description: String,
|
||||
/// Skill format: "openhuman", "hermes", "openclaw".
|
||||
pub format: String,
|
||||
/// Upstream source within the aggregated catalog (e.g. "built-in",
|
||||
/// "optional", "ClawHub", "skills.sh", "LobeHub", "browse.sh").
|
||||
pub source: String,
|
||||
/// Category label from the upstream catalog.
|
||||
pub category: String,
|
||||
/// Author name, if known.
|
||||
pub author: Option<String>,
|
||||
/// Version string, if declared.
|
||||
pub version: Option<String>,
|
||||
/// Tags for search/filter.
|
||||
pub tags: Vec<String>,
|
||||
/// Compatible platform hints.
|
||||
pub platforms: Vec<String>,
|
||||
/// Direct download URL for the SKILL.md file.
|
||||
pub download_url: String,
|
||||
/// Which registry source this came from.
|
||||
pub source_id: String,
|
||||
/// Star count or popularity metric, if available.
|
||||
pub stars: Option<u32>,
|
||||
/// Last updated timestamp (ISO 8601), if available.
|
||||
pub updated_at: Option<String>,
|
||||
/// Docs path from the Hermes catalog.
|
||||
pub docs_path: Option<String>,
|
||||
/// Required CLI commands.
|
||||
pub commands: Vec<String>,
|
||||
/// Required environment variables.
|
||||
pub env_vars: Vec<String>,
|
||||
/// Software license.
|
||||
pub license: Option<String>,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
# Skill Runtime
|
||||
|
||||
`skill_runtime` owns execution of installed `SKILL.md` workflows.
|
||||
|
||||
Responsibilities:
|
||||
|
||||
- Start and cancel skill runs.
|
||||
- Read recent run metadata and run logs.
|
||||
- Resolve reusable language runtimes before script-backed skills run.
|
||||
- Host the built-in `skill_executor` agent.
|
||||
|
||||
It deliberately reuses:
|
||||
|
||||
- `runtime_node` for Node.js, npm, npx, and PATH injection.
|
||||
- `runtime_python` for Python interpreter resolution and process launching.
|
||||
- `workflows` for installed skill discovery, metadata, resources, and run logs.
|
||||
|
||||
Production smoke examples:
|
||||
|
||||
```bash
|
||||
openhuman skill_runtime schemas
|
||||
openhuman skill_runtime resolve_runtimes --runtime all
|
||||
openhuman skill_runtime run --skill_id git-helper --inputs '{}'
|
||||
openhuman skill_runtime recent_runs --limit 10
|
||||
```
|
||||
|
||||
Compatibility:
|
||||
|
||||
- Existing `openhuman workflows run`, `workflows cancel`, and run-log RPCs remain available.
|
||||
- New scripts should prefer the `skill_runtime` namespace for execution.
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
pub mod skill_executor;
|
||||
@@ -0,0 +1,29 @@
|
||||
id = "skill_executor"
|
||||
display_name = "Skill Executor Agent"
|
||||
delegate_name = "run_skill"
|
||||
when_to_use = "Skill execution specialist — runs installed agent skills. Loads skill instructions from SKILL.md, follows the skill's procedure, and executes any bundled scripts. Use when the user invokes a skill by name or asks to run a specific installed skill."
|
||||
temperature = 0.4
|
||||
max_iterations = 15
|
||||
iteration_policy = "extended"
|
||||
sandbox_mode = "none"
|
||||
agent_tier = "worker"
|
||||
omit_identity = true
|
||||
omit_memory_context = true
|
||||
omit_safety_preamble = false
|
||||
omit_skills_catalog = false
|
||||
|
||||
[model]
|
||||
hint = "agentic"
|
||||
|
||||
[tools]
|
||||
named = [
|
||||
"list_workflows",
|
||||
"describe_workflow",
|
||||
"read_workflow_resource",
|
||||
"skill_runtime_resolve_runtimes",
|
||||
"run_workflow",
|
||||
"shell",
|
||||
"file_read",
|
||||
"file_write",
|
||||
"ask_user_clarification",
|
||||
]
|
||||
@@ -0,0 +1 @@
|
||||
pub mod prompt;
|
||||
@@ -0,0 +1,26 @@
|
||||
You are the **Skill Executor Agent**, a specialist in loading and executing installed agent skills.
|
||||
|
||||
## Your role
|
||||
|
||||
You execute agent skills that have been installed on this system. Skills are defined by SKILL.md files following the agentskills.io specification and may include scripts, references, and assets.
|
||||
|
||||
## Execution procedure
|
||||
|
||||
1. **Load** the skill's SKILL.md using `describe_workflow` to read its instructions.
|
||||
2. **Read** any referenced resources using `read_workflow_resource` (scripts, references, etc.).
|
||||
3. **Resolve runtimes** with `skill_runtime_resolve_runtimes` when the skill references Node.js, npm, npx, Python, or bundled `.js` / `.py` scripts.
|
||||
4. **Follow** the skill's instructions step by step.
|
||||
5. **Execute** any shell commands or scripts as directed by the skill.
|
||||
- Node.js scripts must use the OpenHuman Node runtime (`runtime_node`) rather than assuming the host PATH.
|
||||
- Python scripts must use the OpenHuman Python runtime (`runtime_python`) rather than assuming the host PATH.
|
||||
6. **Report** results back to the user.
|
||||
|
||||
## Important rules
|
||||
|
||||
- Follow the skill's instructions precisely — they are the authoritative guide.
|
||||
- When a skill references bundled scripts (e.g., `scripts/run.py`), read them with `read_workflow_resource` before executing.
|
||||
- Never modify the skill's SKILL.md or bundled files.
|
||||
- If a skill requires environment variables or credentials, ask the user before proceeding.
|
||||
- If a shell command fails, report the error and ask whether to retry or abort.
|
||||
- Respect the skill's `allowed-tools` declaration if present.
|
||||
- When the skill is read-only (no shell commands), do not use the shell tool.
|
||||
@@ -0,0 +1,38 @@
|
||||
//! System prompt builder for the `skill_executor` built-in agent.
|
||||
|
||||
use crate::openhuman::context::prompt::{
|
||||
render_safety, render_tools, render_user_files, render_workspace, PromptContext,
|
||||
};
|
||||
use anyhow::Result;
|
||||
|
||||
const ARCHETYPE: &str = include_str!("prompt.md");
|
||||
|
||||
pub fn build(ctx: &PromptContext<'_>) -> Result<String> {
|
||||
let mut out = String::with_capacity(4096);
|
||||
out.push_str(ARCHETYPE.trim_end());
|
||||
out.push_str("\n\n");
|
||||
|
||||
let user_files = render_user_files(ctx)?;
|
||||
if !user_files.trim().is_empty() {
|
||||
out.push_str(user_files.trim_end());
|
||||
out.push_str("\n\n");
|
||||
}
|
||||
|
||||
let tools = render_tools(ctx)?;
|
||||
if !tools.trim().is_empty() {
|
||||
out.push_str(tools.trim_end());
|
||||
out.push_str("\n\n");
|
||||
}
|
||||
|
||||
let safety = render_safety();
|
||||
out.push_str(safety.trim_end());
|
||||
out.push_str("\n\n");
|
||||
|
||||
let workspace = render_workspace(ctx)?;
|
||||
if !workspace.trim().is_empty() {
|
||||
out.push_str(workspace.trim_end());
|
||||
out.push('\n');
|
||||
}
|
||||
|
||||
Ok(out)
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
//! Skill runtime: execution, cancellation, and run-log polling for installed
|
||||
//! SKILL.md workflows.
|
||||
//!
|
||||
//! `workflows` owns discovery and installed skill metadata. `skill_registry`
|
||||
//! owns remote catalogs and install sources. This module owns actually running
|
||||
//! a skill, regardless of whether the skill's instructions call Python, Node,
|
||||
//! shell tools, or another OpenHuman agent tool.
|
||||
|
||||
pub mod agent;
|
||||
pub mod ops;
|
||||
mod run_machinery;
|
||||
pub mod schemas;
|
||||
pub mod tools;
|
||||
|
||||
pub use run_machinery::{await_run_outcome, spawn_workflow_run_background, WorkflowRunStarted};
|
||||
pub use schemas::{
|
||||
all_skill_runtime_controller_schemas, all_skill_runtime_registered_controllers,
|
||||
skill_runtime_schemas,
|
||||
};
|
||||
@@ -0,0 +1,189 @@
|
||||
//! Skill runtime operations that coordinate reusable language runtimes.
|
||||
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::openhuman::config::Config;
|
||||
use crate::openhuman::runtime_node::{NodeBootstrap, NodeSource};
|
||||
use crate::openhuman::runtime_python::{PythonBootstrap, PythonSource};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum RuntimeRequirement {
|
||||
All,
|
||||
Node,
|
||||
Python,
|
||||
}
|
||||
|
||||
impl RuntimeRequirement {
|
||||
pub fn from_optional(value: Option<&str>) -> Result<Self, String> {
|
||||
match value.unwrap_or("all").trim().to_ascii_lowercase().as_str() {
|
||||
"" | "all" => Ok(Self::All),
|
||||
"node" | "nodejs" | "javascript" => Ok(Self::Node),
|
||||
"python" | "python3" => Ok(Self::Python),
|
||||
other => Err(format!(
|
||||
"unknown runtime '{other}' (expected all, node, or python)"
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ResolvedRuntimeSummary {
|
||||
pub runtime: String,
|
||||
pub enabled: bool,
|
||||
pub available: bool,
|
||||
pub source: Option<String>,
|
||||
pub version: Option<String>,
|
||||
pub binary: Option<String>,
|
||||
pub bin_dir: Option<String>,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ResolveRuntimesOutcome {
|
||||
pub runtimes: Vec<ResolvedRuntimeSummary>,
|
||||
}
|
||||
|
||||
pub async fn resolve_runtimes(
|
||||
config: &Config,
|
||||
requirement: RuntimeRequirement,
|
||||
) -> ResolveRuntimesOutcome {
|
||||
tracing::debug!(
|
||||
requirement = ?requirement,
|
||||
"[skill_runtime] resolve_runtimes: start"
|
||||
);
|
||||
let mut runtimes = Vec::new();
|
||||
if matches!(
|
||||
requirement,
|
||||
RuntimeRequirement::All | RuntimeRequirement::Node
|
||||
) {
|
||||
runtimes.push(resolve_node(config).await);
|
||||
}
|
||||
if matches!(
|
||||
requirement,
|
||||
RuntimeRequirement::All | RuntimeRequirement::Python
|
||||
) {
|
||||
runtimes.push(resolve_python(config).await);
|
||||
}
|
||||
tracing::debug!(
|
||||
count = runtimes.len(),
|
||||
"[skill_runtime] resolve_runtimes: done"
|
||||
);
|
||||
ResolveRuntimesOutcome { runtimes }
|
||||
}
|
||||
|
||||
async fn resolve_node(config: &Config) -> ResolvedRuntimeSummary {
|
||||
if !config.node.enabled {
|
||||
return ResolvedRuntimeSummary {
|
||||
runtime: "node".to_string(),
|
||||
enabled: false,
|
||||
available: false,
|
||||
source: None,
|
||||
version: None,
|
||||
binary: None,
|
||||
bin_dir: None,
|
||||
error: Some("node runtime disabled".to_string()),
|
||||
};
|
||||
}
|
||||
let bootstrap = NodeBootstrap::new(
|
||||
config.node.clone(),
|
||||
config.workspace_dir.clone(),
|
||||
reqwest::Client::new(),
|
||||
);
|
||||
match bootstrap.resolve().await {
|
||||
Ok(resolved) => ResolvedRuntimeSummary {
|
||||
runtime: "node".to_string(),
|
||||
enabled: true,
|
||||
available: true,
|
||||
source: Some(
|
||||
match resolved.source {
|
||||
NodeSource::System => "system",
|
||||
NodeSource::Managed => "managed",
|
||||
}
|
||||
.to_string(),
|
||||
),
|
||||
version: Some(resolved.version),
|
||||
binary: Some(resolved.node_bin.display().to_string()),
|
||||
bin_dir: Some(resolved.bin_dir.display().to_string()),
|
||||
error: None,
|
||||
},
|
||||
Err(error) => ResolvedRuntimeSummary {
|
||||
runtime: "node".to_string(),
|
||||
enabled: true,
|
||||
available: false,
|
||||
source: None,
|
||||
version: None,
|
||||
binary: None,
|
||||
bin_dir: None,
|
||||
error: Some(error.to_string()),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async fn resolve_python(config: &Config) -> ResolvedRuntimeSummary {
|
||||
if !config.runtime_python.enabled {
|
||||
return ResolvedRuntimeSummary {
|
||||
runtime: "python".to_string(),
|
||||
enabled: false,
|
||||
available: false,
|
||||
source: None,
|
||||
version: None,
|
||||
binary: None,
|
||||
bin_dir: None,
|
||||
error: Some("python runtime disabled".to_string()),
|
||||
};
|
||||
}
|
||||
let bootstrap = PythonBootstrap::new(config.runtime_python.clone());
|
||||
match bootstrap.resolve().await {
|
||||
Ok(resolved) => ResolvedRuntimeSummary {
|
||||
runtime: "python".to_string(),
|
||||
enabled: true,
|
||||
available: true,
|
||||
source: Some(
|
||||
match resolved.source {
|
||||
PythonSource::System => "system",
|
||||
PythonSource::Managed => "managed",
|
||||
}
|
||||
.to_string(),
|
||||
),
|
||||
version: Some(resolved.version),
|
||||
binary: Some(resolved.python_bin.display().to_string()),
|
||||
bin_dir: resolved
|
||||
.python_bin
|
||||
.parent()
|
||||
.map(|path| path.display().to_string()),
|
||||
error: None,
|
||||
},
|
||||
Err(error) => ResolvedRuntimeSummary {
|
||||
runtime: "python".to_string(),
|
||||
enabled: true,
|
||||
available: false,
|
||||
source: None,
|
||||
version: None,
|
||||
binary: None,
|
||||
bin_dir: None,
|
||||
error: Some(error.to_string()),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn runtime_requirement_parses_aliases() {
|
||||
assert_eq!(
|
||||
RuntimeRequirement::from_optional(None).unwrap(),
|
||||
RuntimeRequirement::All
|
||||
);
|
||||
assert_eq!(
|
||||
RuntimeRequirement::from_optional(Some("nodejs")).unwrap(),
|
||||
RuntimeRequirement::Node
|
||||
);
|
||||
assert_eq!(
|
||||
RuntimeRequirement::from_optional(Some("python3")).unwrap(),
|
||||
RuntimeRequirement::Python
|
||||
);
|
||||
assert!(RuntimeRequirement::from_optional(Some("ruby")).is_err());
|
||||
}
|
||||
}
|
||||
+5
-5
@@ -1,4 +1,4 @@
|
||||
//! Background workflow run spawning and outcome polling.
|
||||
//! Background skill run spawning and outcome polling.
|
||||
//!
|
||||
//! `spawn_workflow_run_background` is re-used by both the `workflows_run`
|
||||
//! JSON-RPC controller and the `run_skill` agent tool (skill chaining).
|
||||
@@ -12,7 +12,7 @@ use crate::openhuman::agent::harness::subagent_runner::with_autonomous_iter_cap;
|
||||
use crate::openhuman::config::Config;
|
||||
use crate::openhuman::workflows::{preflight, registry, run_log};
|
||||
|
||||
use super::helpers::resolve_workspace_dir;
|
||||
use crate::openhuman::workflows::schemas::resolve_workspace_dir;
|
||||
|
||||
/// Iteration cap for an autonomous skill run (orchestrator + sub-agents). High
|
||||
/// enough to "run until done", while the repeated-failure circuit breaker still
|
||||
@@ -22,7 +22,7 @@ const WORKFLOW_RUN_MAX_ITERATIONS: usize = 200;
|
||||
/// Outcome of [`spawn_workflow_run_background`]: the new run's `run_id`, the
|
||||
/// canonical `workflow_id` the registry resolved it to, and the path of the
|
||||
/// streaming log file every step + the footer get written to.
|
||||
pub(crate) struct WorkflowRunStarted {
|
||||
pub struct WorkflowRunStarted {
|
||||
pub run_id: String,
|
||||
pub workflow_id: String,
|
||||
pub log_path: std::path::PathBuf,
|
||||
@@ -38,7 +38,7 @@ pub(crate) struct WorkflowRunStarted {
|
||||
/// background until DONE / DEGENERATE / FAILED. Errors (unknown skill,
|
||||
/// missing required inputs) surface as `Err(String)` *before* the spawn so
|
||||
/// callers can reject malformed invocations synchronously.
|
||||
pub(crate) async fn spawn_workflow_run_background(
|
||||
pub async fn spawn_workflow_run_background(
|
||||
skill_id_param: String,
|
||||
inputs_param: Option<Value>,
|
||||
) -> Result<WorkflowRunStarted, String> {
|
||||
@@ -289,7 +289,7 @@ pub(crate) async fn spawn_workflow_run_background(
|
||||
/// The poll happens in the runtime (a tokio sleep loop), NOT in the LLM —
|
||||
/// the model issues one `run_workflow` tool call and gets either the result
|
||||
/// or a "still running" handle back, never a busy-wait it has to drive.
|
||||
pub(crate) async fn await_run_outcome(
|
||||
pub async fn await_run_outcome(
|
||||
log_path: &std::path::Path,
|
||||
budget: std::time::Duration,
|
||||
) -> Option<run_log::RunOutcome> {
|
||||
@@ -0,0 +1,393 @@
|
||||
//! Controller schemas and handlers for `openhuman.skill_runtime_*`.
|
||||
//!
|
||||
//! This namespace is the CLI/RPC-friendly execution surface for installed
|
||||
//! skills. The older `workflows_*` run/log/cancel controllers are kept for
|
||||
//! compatibility, but new scripts should call `skill_runtime_*` directly.
|
||||
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
use crate::core::all::{ControllerFuture, RegisteredController};
|
||||
use crate::core::{ControllerSchema, FieldSchema, TypeSchema};
|
||||
use crate::openhuman::workflows::run_log;
|
||||
use crate::openhuman::workflows::schemas::resolve_workspace_dir;
|
||||
use crate::rpc::RpcOutcome;
|
||||
|
||||
use super::ops::{resolve_runtimes, RuntimeRequirement};
|
||||
use super::spawn_workflow_run_background;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct RunParams {
|
||||
skill_id: String,
|
||||
#[serde(default)]
|
||||
inputs: Option<Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct CancelParams {
|
||||
run_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct RecentRunsParams {
|
||||
#[serde(default)]
|
||||
skill_id: Option<String>,
|
||||
#[serde(default)]
|
||||
limit: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ReadRunLogParams {
|
||||
run_id: String,
|
||||
#[serde(default)]
|
||||
offset: Option<u64>,
|
||||
#[serde(default)]
|
||||
max_bytes: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Default)]
|
||||
struct ResolveRuntimesParams {
|
||||
#[serde(default)]
|
||||
runtime: Option<String>,
|
||||
}
|
||||
|
||||
fn deserialize_params<T: serde::de::DeserializeOwned>(
|
||||
params: Map<String, Value>,
|
||||
) -> Result<T, String> {
|
||||
serde_json::from_value(Value::Object(params)).map_err(|e| format!("invalid params: {e}"))
|
||||
}
|
||||
|
||||
fn to_json<T: serde::Serialize>(outcome: RpcOutcome<T>) -> Result<Value, String> {
|
||||
outcome.into_cli_compatible_json()
|
||||
}
|
||||
|
||||
pub fn all_skill_runtime_controller_schemas() -> Vec<ControllerSchema> {
|
||||
vec![
|
||||
skill_runtime_schemas("run"),
|
||||
skill_runtime_schemas("cancel"),
|
||||
skill_runtime_schemas("recent_runs"),
|
||||
skill_runtime_schemas("read_run_log"),
|
||||
skill_runtime_schemas("resolve_runtimes"),
|
||||
skill_runtime_schemas("schemas"),
|
||||
]
|
||||
}
|
||||
|
||||
pub fn all_skill_runtime_registered_controllers() -> Vec<RegisteredController> {
|
||||
vec![
|
||||
RegisteredController {
|
||||
schema: skill_runtime_schemas("run"),
|
||||
handler: handle_run,
|
||||
},
|
||||
RegisteredController {
|
||||
schema: skill_runtime_schemas("cancel"),
|
||||
handler: handle_cancel,
|
||||
},
|
||||
RegisteredController {
|
||||
schema: skill_runtime_schemas("recent_runs"),
|
||||
handler: handle_recent_runs,
|
||||
},
|
||||
RegisteredController {
|
||||
schema: skill_runtime_schemas("read_run_log"),
|
||||
handler: handle_read_run_log,
|
||||
},
|
||||
RegisteredController {
|
||||
schema: skill_runtime_schemas("resolve_runtimes"),
|
||||
handler: handle_resolve_runtimes,
|
||||
},
|
||||
RegisteredController {
|
||||
schema: skill_runtime_schemas("schemas"),
|
||||
handler: handle_schemas,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
pub fn skill_runtime_schemas(function: &str) -> ControllerSchema {
|
||||
match function {
|
||||
"run" => ControllerSchema {
|
||||
namespace: "skill_runtime",
|
||||
function: "run",
|
||||
description: "Start an installed skill in the background and return a run id plus log path.",
|
||||
inputs: vec![
|
||||
FieldSchema {
|
||||
name: "skill_id",
|
||||
ty: TypeSchema::String,
|
||||
comment: "Installed skill id (directory name / workflow id).",
|
||||
required: true,
|
||||
},
|
||||
FieldSchema {
|
||||
name: "inputs",
|
||||
ty: TypeSchema::Json,
|
||||
comment: "Optional JSON object of input values declared by the skill.",
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
outputs: vec![
|
||||
FieldSchema {
|
||||
name: "run_id",
|
||||
ty: TypeSchema::String,
|
||||
comment: "Background run id.",
|
||||
required: true,
|
||||
},
|
||||
FieldSchema {
|
||||
name: "status",
|
||||
ty: TypeSchema::String,
|
||||
comment: "Always `started`.",
|
||||
required: true,
|
||||
},
|
||||
FieldSchema {
|
||||
name: "skill_id",
|
||||
ty: TypeSchema::String,
|
||||
comment: "Resolved installed skill id.",
|
||||
required: true,
|
||||
},
|
||||
FieldSchema {
|
||||
name: "log",
|
||||
ty: TypeSchema::String,
|
||||
comment: "Run log path.",
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
"cancel" => ControllerSchema {
|
||||
namespace: "skill_runtime",
|
||||
function: "cancel",
|
||||
description: "Request cancellation of an in-flight skill run.",
|
||||
inputs: vec![FieldSchema {
|
||||
name: "run_id",
|
||||
ty: TypeSchema::String,
|
||||
comment: "Run id returned by skill_runtime_run.",
|
||||
required: true,
|
||||
}],
|
||||
outputs: vec![
|
||||
FieldSchema {
|
||||
name: "run_id",
|
||||
ty: TypeSchema::String,
|
||||
comment: "Echoed run id.",
|
||||
required: true,
|
||||
},
|
||||
FieldSchema {
|
||||
name: "cancelled",
|
||||
ty: TypeSchema::Bool,
|
||||
comment: "Whether a live run was found and signalled.",
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
"recent_runs" => ControllerSchema {
|
||||
namespace: "skill_runtime",
|
||||
function: "recent_runs",
|
||||
description: "List recent skill runs from the workspace run-log directory.",
|
||||
inputs: vec![
|
||||
FieldSchema {
|
||||
name: "skill_id",
|
||||
ty: TypeSchema::String,
|
||||
comment: "Optional skill id filter.",
|
||||
required: false,
|
||||
},
|
||||
FieldSchema {
|
||||
name: "limit",
|
||||
ty: TypeSchema::U64,
|
||||
comment: "Maximum runs to return, capped at 100.",
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
outputs: vec![FieldSchema {
|
||||
name: "runs",
|
||||
ty: TypeSchema::Json,
|
||||
comment: "Recent run summaries.",
|
||||
required: true,
|
||||
}],
|
||||
},
|
||||
"read_run_log" => ControllerSchema {
|
||||
namespace: "skill_runtime",
|
||||
function: "read_run_log",
|
||||
description: "Read a slice of a skill run log by run id.",
|
||||
inputs: vec![
|
||||
FieldSchema {
|
||||
name: "run_id",
|
||||
ty: TypeSchema::String,
|
||||
comment: "Run id returned by skill_runtime_run.",
|
||||
required: true,
|
||||
},
|
||||
FieldSchema {
|
||||
name: "offset",
|
||||
ty: TypeSchema::U64,
|
||||
comment: "Byte offset to start reading from.",
|
||||
required: false,
|
||||
},
|
||||
FieldSchema {
|
||||
name: "max_bytes",
|
||||
ty: TypeSchema::U64,
|
||||
comment: "Maximum bytes to return, capped at 256 KiB.",
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
outputs: vec![FieldSchema {
|
||||
name: "content",
|
||||
ty: TypeSchema::Json,
|
||||
comment: "Run-log slice.",
|
||||
required: true,
|
||||
}],
|
||||
},
|
||||
"resolve_runtimes" => ControllerSchema {
|
||||
namespace: "skill_runtime",
|
||||
function: "resolve_runtimes",
|
||||
description: "Resolve the reusable Node/Python runtimes used by skill execution and return binary paths for production smoke scripts.",
|
||||
inputs: vec![FieldSchema {
|
||||
name: "runtime",
|
||||
ty: TypeSchema::String,
|
||||
comment: "Runtime to resolve: all (default), node, or python.",
|
||||
required: false,
|
||||
}],
|
||||
outputs: vec![FieldSchema {
|
||||
name: "runtimes",
|
||||
ty: TypeSchema::Json,
|
||||
comment: "Resolved runtime status objects with enabled, available, source, version, binary, bin_dir, and error.",
|
||||
required: true,
|
||||
}],
|
||||
},
|
||||
"schemas" => ControllerSchema {
|
||||
namespace: "skill_runtime",
|
||||
function: "schemas",
|
||||
description: "Return the skill_runtime controller schemas for CLI/RPC smoke-test script generation.",
|
||||
inputs: vec![],
|
||||
outputs: vec![FieldSchema {
|
||||
name: "schemas",
|
||||
ty: TypeSchema::Json,
|
||||
comment: "Array of skill_runtime controller schemas.",
|
||||
required: true,
|
||||
}],
|
||||
},
|
||||
other => panic!("unknown skill_runtime schema function: {other}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_run(params: Map<String, Value>) -> ControllerFuture {
|
||||
Box::pin(async move {
|
||||
let payload = deserialize_params::<RunParams>(params)?;
|
||||
tracing::info!(skill_id = %payload.skill_id, "[skill_runtime][rpc] run");
|
||||
let started = spawn_workflow_run_background(payload.skill_id, payload.inputs).await?;
|
||||
to_json(RpcOutcome::new(
|
||||
serde_json::json!({
|
||||
"run_id": started.run_id,
|
||||
"status": "started",
|
||||
"skill_id": started.workflow_id,
|
||||
"log": started.log_path.display().to_string(),
|
||||
}),
|
||||
Vec::new(),
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
fn handle_cancel(params: Map<String, Value>) -> ControllerFuture {
|
||||
Box::pin(async move {
|
||||
let payload = deserialize_params::<CancelParams>(params)?;
|
||||
let cancelled = run_log::cancel_run(&payload.run_id);
|
||||
tracing::info!(run_id = %payload.run_id, cancelled, "[skill_runtime][rpc] cancel");
|
||||
to_json(RpcOutcome::new(
|
||||
serde_json::json!({ "run_id": payload.run_id, "cancelled": cancelled }),
|
||||
Vec::new(),
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
fn handle_recent_runs(params: Map<String, Value>) -> ControllerFuture {
|
||||
Box::pin(async move {
|
||||
let payload = deserialize_params::<RecentRunsParams>(params)?;
|
||||
let limit = payload.limit.unwrap_or(20).min(100) as usize;
|
||||
let workspace = resolve_workspace_dir().await;
|
||||
let runs = run_log::scan_runs(&workspace, payload.skill_id.as_deref(), limit);
|
||||
tracing::debug!(
|
||||
count = runs.len(),
|
||||
filter = ?payload.skill_id,
|
||||
limit,
|
||||
"[skill_runtime][rpc] recent_runs"
|
||||
);
|
||||
to_json(RpcOutcome::new(
|
||||
serde_json::json!({ "runs": runs }),
|
||||
Vec::new(),
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
fn handle_read_run_log(params: Map<String, Value>) -> ControllerFuture {
|
||||
Box::pin(async move {
|
||||
let payload = deserialize_params::<ReadRunLogParams>(params)?;
|
||||
let workspace = resolve_workspace_dir().await;
|
||||
let path = run_log::find_run_log_path(&workspace, &payload.run_id).ok_or_else(|| {
|
||||
format!(
|
||||
"skill_runtime_read_run_log: unknown run_id '{}'",
|
||||
payload.run_id
|
||||
)
|
||||
})?;
|
||||
let offset = payload.offset.unwrap_or(0);
|
||||
let max_bytes = payload.max_bytes.unwrap_or(64 * 1024).min(256 * 1024) as usize;
|
||||
let slice = run_log::read_run_log_slice(&path, offset, max_bytes)
|
||||
.map_err(|e| format!("skill_runtime_read_run_log: read failed: {e}"))?;
|
||||
to_json(RpcOutcome::new(slice, Vec::new()))
|
||||
})
|
||||
}
|
||||
|
||||
fn handle_resolve_runtimes(params: Map<String, Value>) -> ControllerFuture {
|
||||
Box::pin(async move {
|
||||
let payload = deserialize_params::<ResolveRuntimesParams>(params)?;
|
||||
let requirement = RuntimeRequirement::from_optional(payload.runtime.as_deref())?;
|
||||
let config = crate::openhuman::config::Config::load_or_init()
|
||||
.await
|
||||
.map_err(|error| format!("skill_runtime_resolve_runtimes: load config: {error:#}"))?;
|
||||
let outcome = resolve_runtimes(&config, requirement).await;
|
||||
to_json(RpcOutcome::new(outcome, Vec::new()))
|
||||
})
|
||||
}
|
||||
|
||||
fn handle_schemas(params: Map<String, Value>) -> ControllerFuture {
|
||||
Box::pin(async move {
|
||||
let _ = params;
|
||||
to_json(RpcOutcome::new(
|
||||
serde_json::json!({ "schemas": all_skill_runtime_controller_schemas() }),
|
||||
Vec::new(),
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn schemas_cover_runtime_cli_surface() {
|
||||
let functions: Vec<_> = all_skill_runtime_controller_schemas()
|
||||
.into_iter()
|
||||
.map(|schema| schema.function)
|
||||
.collect();
|
||||
assert_eq!(
|
||||
functions,
|
||||
vec![
|
||||
"run",
|
||||
"cancel",
|
||||
"recent_runs",
|
||||
"read_run_log",
|
||||
"resolve_runtimes",
|
||||
"schemas"
|
||||
]
|
||||
);
|
||||
assert!(all_skill_runtime_registered_controllers()
|
||||
.iter()
|
||||
.all(|controller| controller.schema.namespace == "skill_runtime"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_schema_uses_skill_id_not_workflow_id() {
|
||||
let schema = skill_runtime_schemas("run");
|
||||
assert_eq!(schema.namespace, "skill_runtime");
|
||||
assert!(schema.inputs.iter().any(|field| field.name == "skill_id"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_runtimes_schema_is_cli_friendly() {
|
||||
let schema = skill_runtime_schemas("resolve_runtimes");
|
||||
assert_eq!(schema.namespace, "skill_runtime");
|
||||
assert_eq!(schema.function, "resolve_runtimes");
|
||||
assert!(schema.inputs.iter().any(|field| field.name == "runtime"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
//! LLM-callable tools for the skill runtime domain.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::openhuman::config::Config;
|
||||
use crate::openhuman::tools::traits::{PermissionLevel, Tool, ToolResult};
|
||||
|
||||
use super::ops::{resolve_runtimes, RuntimeRequirement};
|
||||
|
||||
pub struct SkillRuntimeResolveRuntimesTool;
|
||||
|
||||
impl SkillRuntimeResolveRuntimesTool {
|
||||
pub fn new(_config: Arc<Config>) -> Self {
|
||||
Self
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for SkillRuntimeResolveRuntimesTool {
|
||||
fn name(&self) -> &str {
|
||||
"skill_runtime_resolve_runtimes"
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"Resolve OpenHuman's reusable Node/Python runtimes for skill execution. \
|
||||
Use before running skills that reference node, npm, npx, python, or \
|
||||
bundled .js/.py scripts."
|
||||
}
|
||||
|
||||
fn parameters_schema(&self) -> serde_json::Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"runtime": {
|
||||
"type": "string",
|
||||
"enum": ["all", "node", "python"],
|
||||
"description": "Runtime to resolve. Defaults to all."
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn permission_level(&self) -> PermissionLevel {
|
||||
PermissionLevel::ReadOnly
|
||||
}
|
||||
|
||||
async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
|
||||
let config = Config::load_or_init()
|
||||
.await
|
||||
.map_err(|error| anyhow::anyhow!("load config: {error:#}"))?;
|
||||
let requirement = RuntimeRequirement::from_optional(
|
||||
args.get("runtime").and_then(serde_json::Value::as_str),
|
||||
)
|
||||
.map_err(|error| anyhow::anyhow!(error))?;
|
||||
tracing::debug!(
|
||||
requirement = ?requirement,
|
||||
"[tool][skill_runtime] resolve_runtimes"
|
||||
);
|
||||
let outcome = resolve_runtimes(&config, requirement).await;
|
||||
Ok(ToolResult::success(serde_json::to_string(&outcome)?))
|
||||
}
|
||||
|
||||
fn is_concurrency_safe(&self, _args: &serde_json::Value) -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
@@ -39,6 +39,8 @@ pub use crate::openhuman::screen_intelligence::tools::*;
|
||||
pub use crate::openhuman::search::tools::*;
|
||||
pub use crate::openhuman::security::tools::*;
|
||||
pub use crate::openhuman::service::tools::*;
|
||||
pub use crate::openhuman::skill_registry::tools::*;
|
||||
pub use crate::openhuman::skill_runtime::tools::*;
|
||||
pub use crate::openhuman::task_sources::tools::*;
|
||||
pub use crate::openhuman::team::tools::*;
|
||||
pub use crate::openhuman::threads::tools::*;
|
||||
|
||||
@@ -177,7 +177,7 @@ pub fn all_tools_with_runtime(
|
||||
// Workflow composition: `run_workflow` runs another workflow as a
|
||||
// subagent and (by default) waits on its result like a function call;
|
||||
// `await_workflow` re-attaches to a run that outlived its inline wait.
|
||||
// Both wrap `workflows::schemas::spawn_workflow_run_background` +
|
||||
// Both wrap `skill_runtime::spawn_workflow_run_background` +
|
||||
// `await_run_outcome` — the same spawn path `openhuman.workflows_run`
|
||||
// JSON-RPC uses, so RPC and tool callers stay in sync.
|
||||
Box::new(RunWorkflowTool::new()),
|
||||
@@ -301,6 +301,17 @@ pub fn all_tools_with_runtime(
|
||||
// `tools::user_filter` (install also fetches remote content).
|
||||
Box::new(WorkflowListTool::new(config.clone())),
|
||||
Box::new(WorkflowDescribeTool::new(config.clone())),
|
||||
// Skill registry tools — browse/search/install from remote registries.
|
||||
// Browse and search are read-only (default-ON); install is a write
|
||||
// operation (fetches remote content and writes to disk).
|
||||
Box::new(SkillRegistryBrowseTool),
|
||||
Box::new(SkillRegistrySearchTool),
|
||||
Box::new(SkillRegistryInstallTool::new(config.clone())),
|
||||
Box::new(SkillRegistrySourcesTool),
|
||||
Box::new(SkillRegistryUninstallTool),
|
||||
// Skill runtime probes — resolve the reusable Node/Python runtimes
|
||||
// that skill execution relies on before a script-backed skill runs.
|
||||
Box::new(SkillRuntimeResolveRuntimesTool::new(config.clone())),
|
||||
Box::new(WorkflowReadResourceTool::new(config.clone())),
|
||||
Box::new(WorkflowRecentRunsTool::new(config.clone())),
|
||||
Box::new(WorkflowReadRunLogTool::new(config.clone())),
|
||||
|
||||
@@ -32,6 +32,7 @@ use crate::openhuman::agent::tools::RunWorkflowTool;
|
||||
use crate::openhuman::config::{Config, MultimodalConfig, MultimodalFileConfig};
|
||||
use crate::openhuman::inference::provider::traits::{ChatMessage, ProviderCapabilities};
|
||||
use crate::openhuman::inference::provider::{ChatRequest, ChatResponse, Provider, ToolCall};
|
||||
use crate::openhuman::skill_runtime::await_run_outcome;
|
||||
use crate::openhuman::tools::policy::DefaultToolPolicy;
|
||||
use crate::openhuman::tools::traits::Tool;
|
||||
use crate::openhuman::workflows::ops_create::{
|
||||
@@ -40,7 +41,6 @@ use crate::openhuman::workflows::ops_create::{
|
||||
use crate::openhuman::workflows::ops_types::WorkflowScope;
|
||||
use crate::openhuman::workflows::registry::get_workflow;
|
||||
use crate::openhuman::workflows::run_log;
|
||||
use crate::openhuman::workflows::schemas::await_run_outcome;
|
||||
|
||||
// ── Mock LLM ─────────────────────────────────────────────────────────────
|
||||
// Minimal scripted provider: pops queued ChatResponses in order. Mirrors the
|
||||
|
||||
@@ -38,13 +38,12 @@ use crate::openhuman::inference::provider::traits::{
|
||||
ChatMessage, ChatRequest, ChatResponse, ProviderCapabilities,
|
||||
};
|
||||
use crate::openhuman::inference::provider::{Provider, ToolCall};
|
||||
use crate::openhuman::skill_runtime::{await_run_outcome, spawn_workflow_run_background};
|
||||
use crate::openhuman::todos::ops as board_ops;
|
||||
use crate::openhuman::todos::ops::{BoardLocation, CardPatch};
|
||||
use crate::openhuman::tools::policy::DefaultToolPolicy;
|
||||
use crate::openhuman::tools::traits::Tool;
|
||||
use crate::openhuman::workflows::schemas::{
|
||||
await_run_outcome, resolve_workspace_dir, spawn_workflow_run_background,
|
||||
};
|
||||
use crate::openhuman::workflows::schemas::resolve_workspace_dir;
|
||||
|
||||
/// Serialize this module's tests (each touches process-global state).
|
||||
fn serial() -> &'static tokio::sync::Mutex<()> {
|
||||
|
||||
@@ -37,6 +37,7 @@ pub const MAX_INSTALL_URL_LEN: usize = 2048;
|
||||
/// a few KB; the 1 MiB cap here is a defensive limit against a hostile or
|
||||
/// misconfigured host streaming an unbounded response into memory.
|
||||
pub const MAX_WORKFLOW_MD_BYTES: usize = 1024 * 1024;
|
||||
const ALLOW_LOCAL_HTTP_ENV: &str = "OPENHUMAN_SKILL_INSTALL_ALLOW_LOCAL_HTTP";
|
||||
|
||||
/// Input for [`install_workflow_from_url`]. Mirrors the `skills.install_from_url`
|
||||
/// JSON-RPC payload.
|
||||
@@ -132,7 +133,9 @@ pub async fn install_workflow_from_url(
|
||||
// resolver may see different answers than ours. Closing that gap requires
|
||||
// pinning a `SocketAddr` and passing it to reqwest via a custom resolver,
|
||||
// tracked separately.
|
||||
validate_resolved_host(&fetch_url).await?;
|
||||
if !allow_local_http_install_url(&fetch_url) {
|
||||
validate_resolved_host(&fetch_url).await?;
|
||||
}
|
||||
|
||||
let redacted_raw_url = redact_url(&raw_url);
|
||||
let redacted_fetch_url = redact_url(&fetch_url);
|
||||
@@ -642,6 +645,9 @@ pub fn validate_install_url(raw: &str) -> Result<(), String> {
|
||||
}
|
||||
let parsed = url::Url::parse(trimmed).map_err(|e| format!("invalid url {trimmed:?}: {e}"))?;
|
||||
if parsed.scheme() != "https" {
|
||||
if allow_local_http_install_url(trimmed) {
|
||||
return Ok(());
|
||||
}
|
||||
return Err(format!(
|
||||
"url scheme {:?} not allowed; https only",
|
||||
parsed.scheme()
|
||||
@@ -661,6 +667,22 @@ pub fn validate_install_url(raw: &str) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn allow_local_http_install_url(raw: &str) -> bool {
|
||||
if std::env::var(ALLOW_LOCAL_HTTP_ENV).ok().as_deref() != Some("1") {
|
||||
return false;
|
||||
}
|
||||
let Ok(parsed) = url::Url::parse(raw) else {
|
||||
return false;
|
||||
};
|
||||
if parsed.scheme() != "http" {
|
||||
return false;
|
||||
}
|
||||
let Some(host) = parsed.host_str() else {
|
||||
return false;
|
||||
};
|
||||
matches!(host, "localhost" | "127.0.0.1" | "::1")
|
||||
}
|
||||
|
||||
/// Resolve the host in the given URL and reject if any returned IP falls in
|
||||
/// loopback / private / link-local / multicast / unspecified ranges.
|
||||
///
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Workflow preflight gates — run BEFORE the orchestrator boots for a
|
||||
//! `skills_run`, so failures surface as a plain `Err` from
|
||||
//! [`super::schemas::spawn_workflow_run_background`] (and from there into
|
||||
//! [`crate::openhuman::skill_runtime::spawn_workflow_run_background`] (and from there into
|
||||
//! the dashboard card / runner page UI) instead of leaking through as
|
||||
//! cryptic orchestrator output.
|
||||
//!
|
||||
|
||||
@@ -82,7 +82,7 @@ pub struct WorkflowDefinition {
|
||||
/// Optional GitHub preflight gate. When `Some(..)` with
|
||||
/// `required = true`, the preflight runs before the orchestrator
|
||||
/// boots — see
|
||||
/// [`crate::openhuman::workflows::schemas::spawn_workflow_run_background`].
|
||||
/// [`crate::openhuman::skill_runtime::spawn_workflow_run_background`].
|
||||
#[serde(default)]
|
||||
pub github: Option<WorkflowGithubConfig>,
|
||||
}
|
||||
|
||||
@@ -2,13 +2,15 @@
|
||||
//!
|
||||
//! Each `handle_*` function deserialises its params, calls into the domain
|
||||
//! ops layer, and serialises the result back as JSON. Business logic lives in
|
||||
//! `ops.rs` / `run_machinery.rs`; this layer is intentionally thin.
|
||||
//! `ops.rs` and skill execution lives in `skill_runtime`; this layer is
|
||||
//! intentionally thin.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
use crate::core::all::ControllerFuture;
|
||||
use crate::openhuman::skill_runtime::spawn_workflow_run_background;
|
||||
use crate::openhuman::workflows::ops::{
|
||||
create_workflow, discover_workflows, install_workflow_from_url, is_workspace_trusted,
|
||||
read_workflow_resource, uninstall_workflow, CreateWorkflowParams, UninstallWorkflowParams,
|
||||
@@ -17,7 +19,6 @@ use crate::openhuman::workflows::{registry, run_log};
|
||||
use crate::rpc::RpcOutcome;
|
||||
|
||||
use super::helpers::{deserialize_params, resolve_config, resolve_workspace_dir, to_json};
|
||||
use super::run_machinery::spawn_workflow_run_background;
|
||||
use super::wire_types::{
|
||||
WorkflowInputDescription, WorkflowSummary, WorkflowsCancelParams, WorkflowsCreateParams,
|
||||
WorkflowsCreateResult, WorkflowsDescribeParams, WorkflowsDescribeResult,
|
||||
|
||||
@@ -22,14 +22,12 @@
|
||||
//! |-----------------------|--------|-------------------------------------------------------------|
|
||||
//! | `wire_types` | ~200 | Param / result structs and `WorkflowSummary`. |
|
||||
//! | `helpers` | ~80 | Config/workspace resolution + `deserialize_params`/`to_json`.|
|
||||
//! | `run_machinery` | ~230 | Background run spawning and outcome polling. |
|
||||
//! | `handlers` | ~240 | Thin `handle_*` dispatcher functions. |
|
||||
//! | `controller_schemas` | ~300 | `workflows_schemas` match + `all_*` registry functions. |
|
||||
|
||||
mod controller_schemas;
|
||||
mod handlers;
|
||||
mod helpers;
|
||||
mod run_machinery;
|
||||
mod wire_types;
|
||||
|
||||
// ── External API — preserved exactly from the original schemas.rs ─────────────
|
||||
@@ -49,10 +47,6 @@ pub(crate) use crate::openhuman::workflows::ops::Workflow;
|
||||
// `resolve_workspace_dir` is used by the `run_workflow` agent tool.
|
||||
pub(crate) use helpers::resolve_workspace_dir;
|
||||
|
||||
// `spawn_workflow_run_background` and `await_run_outcome` are used by the
|
||||
// `run_workflow` agent tool for skill chaining.
|
||||
pub(crate) use run_machinery::{await_run_outcome, spawn_workflow_run_background};
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "../schemas_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -0,0 +1,549 @@
|
||||
//! Skill registry E2E: exercises browse, search, sources, and install
|
||||
//! JSON-RPC endpoints against a real core router.
|
||||
//!
|
||||
//! Run: `cargo test --test skill_registry_e2e`
|
||||
//!
|
||||
//! The test uses a local fixture catalog and local SKILL.md download URL so CI
|
||||
//! does not depend on the live Hermes API.
|
||||
|
||||
use std::net::SocketAddr;
|
||||
use std::path::Path;
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
use std::time::Duration;
|
||||
|
||||
use axum::http::header::AUTHORIZATION;
|
||||
use axum::routing::get;
|
||||
use axum::Router;
|
||||
use serde_json::{json, Value};
|
||||
use tempfile::tempdir;
|
||||
|
||||
use openhuman_core::core::auth::{init_rpc_token, CORE_TOKEN_ENV_VAR};
|
||||
use openhuman_core::core::jsonrpc::build_core_http_router;
|
||||
|
||||
// ── Constants ──────────────────────────────────────────────────────────────
|
||||
|
||||
const TEST_RPC_TOKEN: &str = "skill-registry-e2e-token";
|
||||
|
||||
// ── One-time auth init ─────────────────────────────────────────────────────
|
||||
|
||||
static SKILL_REGISTRY_AUTH_INIT: OnceLock<()> = OnceLock::new();
|
||||
|
||||
fn ensure_test_rpc_auth() {
|
||||
SKILL_REGISTRY_AUTH_INIT.get_or_init(|| {
|
||||
unsafe { std::env::set_var(CORE_TOKEN_ENV_VAR, TEST_RPC_TOKEN) };
|
||||
let token_dir = std::env::temp_dir().join("openhuman-skill-registry-e2e-auth");
|
||||
init_rpc_token(&token_dir).expect("init rpc auth token for skill_registry_e2e");
|
||||
});
|
||||
}
|
||||
|
||||
// ── Env lock (process-global env vars must not race) ──────────────────────
|
||||
|
||||
static ENV_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
|
||||
static KEYRING_INIT: OnceLock<()> = OnceLock::new();
|
||||
|
||||
fn env_lock() -> std::sync::MutexGuard<'static, ()> {
|
||||
KEYRING_INIT.get_or_init(|| unsafe {
|
||||
std::env::set_var("OPENHUMAN_KEYRING_BACKEND", "file");
|
||||
});
|
||||
let mutex = ENV_LOCK.get_or_init(|| Mutex::new(()));
|
||||
match mutex.lock() {
|
||||
Ok(guard) => guard,
|
||||
Err(poisoned) => poisoned.into_inner(),
|
||||
}
|
||||
}
|
||||
|
||||
// ── EnvVarGuard ───────────────────────────────────────────────────────────
|
||||
|
||||
struct EnvVarGuard {
|
||||
key: &'static str,
|
||||
old: Option<String>,
|
||||
}
|
||||
|
||||
impl EnvVarGuard {
|
||||
fn set_to_path(key: &'static str, path: &Path) -> Self {
|
||||
let old = std::env::var(key).ok();
|
||||
unsafe { std::env::set_var(key, path.as_os_str()) };
|
||||
Self { key, old }
|
||||
}
|
||||
|
||||
fn set(key: &'static str, value: &str) -> Self {
|
||||
let old = std::env::var(key).ok();
|
||||
unsafe { std::env::set_var(key, value) };
|
||||
Self { key, old }
|
||||
}
|
||||
|
||||
fn unset(key: &'static str) -> Self {
|
||||
let old = std::env::var(key).ok();
|
||||
unsafe { std::env::remove_var(key) };
|
||||
Self { key, old }
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for EnvVarGuard {
|
||||
fn drop(&mut self) {
|
||||
match &self.old {
|
||||
Some(v) => unsafe { std::env::set_var(self.key, v) },
|
||||
None => unsafe { std::env::remove_var(self.key) },
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Server helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
async fn serve_on_ephemeral(
|
||||
app: axum::Router,
|
||||
) -> (
|
||||
SocketAddr,
|
||||
tokio::task::JoinHandle<Result<(), std::io::Error>>,
|
||||
) {
|
||||
ensure_test_rpc_auth();
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
|
||||
.await
|
||||
.expect("bind ephemeral port");
|
||||
let addr = listener.local_addr().expect("local_addr");
|
||||
let handle = tokio::spawn(async move { axum::serve(listener, app).await });
|
||||
(addr, handle)
|
||||
}
|
||||
|
||||
async fn serve_fixture_catalog() -> (
|
||||
SocketAddr,
|
||||
tokio::task::JoinHandle<Result<(), std::io::Error>>,
|
||||
) {
|
||||
async fn catalog() -> axum::Json<Value> {
|
||||
axum::Json(json!([
|
||||
{
|
||||
"name": "git-helper",
|
||||
"description": "Automate git status and branch triage.",
|
||||
"overview": "Fixture skill for registry tests.",
|
||||
"category": "software-development",
|
||||
"categoryLabel": "Software Development",
|
||||
"source": "fixture",
|
||||
"tags": ["git", "workflow"],
|
||||
"platforms": ["linux", "macos"],
|
||||
"author": "OpenHuman Test",
|
||||
"version": "1.0.0",
|
||||
"license": "MIT",
|
||||
"envVars": [],
|
||||
"commands": ["git"],
|
||||
"docsPath": "fixture/software-development/software-development-git-helper"
|
||||
},
|
||||
{
|
||||
"name": "notes-helper",
|
||||
"description": "Summarize notes.",
|
||||
"category": "productivity",
|
||||
"source": "fixture",
|
||||
"tags": ["notes"],
|
||||
"platforms": ["linux", "macos"],
|
||||
"envVars": [],
|
||||
"commands": []
|
||||
}
|
||||
]))
|
||||
}
|
||||
|
||||
async fn skill_md() -> &'static str {
|
||||
r#"---
|
||||
name: git-helper
|
||||
description: Automate git status and branch triage.
|
||||
version: 1.0.0
|
||||
author: OpenHuman Test
|
||||
license: MIT
|
||||
metadata:
|
||||
id: git-helper
|
||||
hermes:
|
||||
tags: [git, workflow]
|
||||
---
|
||||
|
||||
# Git Helper
|
||||
|
||||
## When to Use
|
||||
Use when git state needs summarizing.
|
||||
|
||||
## Procedure
|
||||
Run `git status --short` and report the result.
|
||||
"#
|
||||
}
|
||||
|
||||
let app = Router::new()
|
||||
.route("/skills.json", get(catalog))
|
||||
.route("/skills/git-helper/SKILL.md", get(skill_md));
|
||||
serve_on_ephemeral(app).await
|
||||
}
|
||||
|
||||
// ── JSON-RPC helpers ───────────────────────────────────────────────────────
|
||||
|
||||
async fn post_json_rpc(rpc_base: &str, id: i64, method: &str, params: Value) -> Value {
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(120))
|
||||
.build()
|
||||
.expect("build reqwest client");
|
||||
let body = json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": id,
|
||||
"method": method,
|
||||
"params": params,
|
||||
});
|
||||
let url = format!("{}/rpc", rpc_base.trim_end_matches('/'));
|
||||
let resp = client
|
||||
.post(&url)
|
||||
.header(AUTHORIZATION, format!("Bearer {TEST_RPC_TOKEN}"))
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.unwrap_or_else(|e| panic!("POST {url}: {e}"));
|
||||
assert!(
|
||||
resp.status().is_success(),
|
||||
"HTTP error {} for {method}",
|
||||
resp.status()
|
||||
);
|
||||
resp.json::<Value>()
|
||||
.await
|
||||
.unwrap_or_else(|e| panic!("parse json for {method}: {e}"))
|
||||
}
|
||||
|
||||
fn assert_no_jsonrpc_error<'a>(v: &'a Value, context: &str) -> &'a Value {
|
||||
if let Some(err) = v.get("error") {
|
||||
panic!("{context}: JSON-RPC error: {err}");
|
||||
}
|
||||
v.get("result")
|
||||
.unwrap_or_else(|| panic!("{context}: missing `result` field: {v}"))
|
||||
}
|
||||
|
||||
fn assert_jsonrpc_error<'a>(v: &'a Value, context: &str) -> &'a Value {
|
||||
v.get("error")
|
||||
.unwrap_or_else(|| panic!("{context}: expected JSON-RPC error, got: {v}"))
|
||||
}
|
||||
|
||||
// ── Test ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/// End-to-end coverage for the `openhuman.skill_registry_*` endpoints.
|
||||
///
|
||||
/// Steps:
|
||||
/// 1. `sources` — lists the distinct upstream sources from the Hermes catalog.
|
||||
/// 2. `browse` — fetches the live catalog (force_refresh = true).
|
||||
/// 3. `search` — queries for "git" and expects at least one match.
|
||||
/// 4. `schemas` — exposes CLI/RPC schemas for prod smoke scripts.
|
||||
/// 5. `install` — happy-path install of a skill.
|
||||
/// 6. `install` — duplicate-rejection: same install must return an error.
|
||||
/// 7. `uninstall` — removes the installed skill.
|
||||
#[tokio::test]
|
||||
async fn skill_registry_e2e_sources_browse_search_install() {
|
||||
let _env_lock = env_lock();
|
||||
|
||||
let tmp = tempdir().expect("create tempdir");
|
||||
let home = tmp.path();
|
||||
let openhuman_home = home.join(".openhuman");
|
||||
|
||||
let _home_guard = EnvVarGuard::set_to_path("HOME", home);
|
||||
let _workspace_guard = EnvVarGuard::unset("OPENHUMAN_WORKSPACE");
|
||||
let _token_guard = EnvVarGuard::set(CORE_TOKEN_ENV_VAR, TEST_RPC_TOKEN);
|
||||
let _keyring_guard = EnvVarGuard::set("OPENHUMAN_KEYRING_BACKEND", "file");
|
||||
|
||||
let cfg_dir = openhuman_home.clone();
|
||||
std::fs::create_dir_all(&cfg_dir).expect("create .openhuman dir");
|
||||
std::fs::write(
|
||||
cfg_dir.join("config.toml"),
|
||||
r#"api_url = "http://127.0.0.1:9"
|
||||
default_model = "skill-e2e-model"
|
||||
|
||||
[secrets]
|
||||
encrypt = false
|
||||
"#,
|
||||
)
|
||||
.expect("write config.toml");
|
||||
|
||||
let user_cfg_dir = openhuman_home.join("users").join("local");
|
||||
std::fs::create_dir_all(&user_cfg_dir).expect("create users/local dir");
|
||||
std::fs::write(
|
||||
user_cfg_dir.join("config.toml"),
|
||||
r#"api_url = "http://127.0.0.1:9"
|
||||
default_model = "skill-e2e-model"
|
||||
|
||||
[secrets]
|
||||
encrypt = false
|
||||
"#,
|
||||
)
|
||||
.expect("write users/local/config.toml");
|
||||
|
||||
let (fixture_addr, fixture_join) = serve_fixture_catalog().await;
|
||||
let fixture_base = format!("http://{fixture_addr}");
|
||||
let _catalog_guard = EnvVarGuard::set(
|
||||
"OPENHUMAN_SKILL_REGISTRY_CATALOG_URL",
|
||||
&format!("{fixture_base}/skills.json"),
|
||||
);
|
||||
let _download_guard = EnvVarGuard::set(
|
||||
"OPENHUMAN_SKILL_REGISTRY_DOWNLOAD_BASE_URL",
|
||||
&format!("{fixture_base}/skills"),
|
||||
);
|
||||
let _local_http_guard = EnvVarGuard::set("OPENHUMAN_SKILL_INSTALL_ALLOW_LOCAL_HTTP", "1");
|
||||
|
||||
let (rpc_addr, rpc_join) = serve_on_ephemeral(build_core_http_router(false)).await;
|
||||
let rpc_base = format!("http://{rpc_addr}");
|
||||
|
||||
// ── Step 1: sources ────────────────────────────────────────────────────
|
||||
|
||||
let sources_resp = post_json_rpc(
|
||||
&rpc_base,
|
||||
9001,
|
||||
"openhuman.skill_registry_sources",
|
||||
json!({}),
|
||||
)
|
||||
.await;
|
||||
let sources_result = assert_no_jsonrpc_error(&sources_resp, "skill_registry_sources");
|
||||
|
||||
let sources = sources_result
|
||||
.get("sources")
|
||||
.and_then(Value::as_array)
|
||||
.expect("sources result must contain a `sources` array");
|
||||
|
||||
assert!(
|
||||
!sources.is_empty(),
|
||||
"expected at least one source from the Hermes catalog"
|
||||
);
|
||||
|
||||
// Sources should be string values (e.g. "built-in", "ClawHub", "skills.sh").
|
||||
for source in sources {
|
||||
assert!(
|
||||
source.is_string(),
|
||||
"each source must be a string, got: {source}"
|
||||
);
|
||||
}
|
||||
|
||||
// ── Step 2: browse (force_refresh = true) ─────────────────────────────
|
||||
|
||||
let browse_resp = post_json_rpc(
|
||||
&rpc_base,
|
||||
9002,
|
||||
"openhuman.skill_registry_browse",
|
||||
json!({ "force_refresh": true }),
|
||||
)
|
||||
.await;
|
||||
let browse_result = assert_no_jsonrpc_error(&browse_resp, "skill_registry_browse");
|
||||
|
||||
let entries = browse_result
|
||||
.get("entries")
|
||||
.and_then(Value::as_array)
|
||||
.expect("browse result must contain an `entries` array");
|
||||
|
||||
assert!(
|
||||
!entries.is_empty(),
|
||||
"browse catalog must return at least one entry after force_refresh"
|
||||
);
|
||||
|
||||
// Every entry must carry the required fields.
|
||||
let required_entry_fields = [
|
||||
"id",
|
||||
"name",
|
||||
"description",
|
||||
"download_url",
|
||||
"source",
|
||||
"category",
|
||||
];
|
||||
for entry in entries.iter().take(10) {
|
||||
for field in &required_entry_fields {
|
||||
assert!(
|
||||
entry.get(field).is_some(),
|
||||
"catalog entry missing field '{field}': {entry}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Step 3: search ────────────────────────────────────────────────────
|
||||
|
||||
let search_resp = post_json_rpc(
|
||||
&rpc_base,
|
||||
9003,
|
||||
"openhuman.skill_registry_search",
|
||||
json!({ "query": "git" }),
|
||||
)
|
||||
.await;
|
||||
let search_result = assert_no_jsonrpc_error(&search_resp, "skill_registry_search (git)");
|
||||
|
||||
let search_entries = search_result
|
||||
.get("entries")
|
||||
.and_then(Value::as_array)
|
||||
.expect("search result must contain an `entries` array");
|
||||
|
||||
assert!(
|
||||
!search_entries.is_empty(),
|
||||
"search for 'git' must return at least one match"
|
||||
);
|
||||
|
||||
for entry in search_entries.iter().take(5) {
|
||||
let name = entry
|
||||
.get("name")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("")
|
||||
.to_lowercase();
|
||||
let desc = entry
|
||||
.get("description")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("")
|
||||
.to_lowercase();
|
||||
let tags: Vec<String> = entry
|
||||
.get("tags")
|
||||
.and_then(Value::as_array)
|
||||
.map(|arr| {
|
||||
arr.iter()
|
||||
.filter_map(|t| t.as_str())
|
||||
.map(str::to_lowercase)
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let category = entry
|
||||
.get("category")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("")
|
||||
.to_lowercase();
|
||||
let author = entry
|
||||
.get("author")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("")
|
||||
.to_lowercase();
|
||||
|
||||
let matches = name.contains("git")
|
||||
|| desc.contains("git")
|
||||
|| tags.iter().any(|t| t.contains("git"))
|
||||
|| category.contains("git")
|
||||
|| author.contains("git");
|
||||
assert!(
|
||||
matches,
|
||||
"search result entry does not match query 'git': {entry}"
|
||||
);
|
||||
}
|
||||
|
||||
// ── Step 4: schemas ───────────────────────────────────────────────────
|
||||
|
||||
let schemas_resp = post_json_rpc(
|
||||
&rpc_base,
|
||||
9004,
|
||||
"openhuman.skill_registry_schemas",
|
||||
json!({}),
|
||||
)
|
||||
.await;
|
||||
let schemas_result = assert_no_jsonrpc_error(&schemas_resp, "skill_registry_schemas");
|
||||
let schemas = schemas_result
|
||||
.get("schemas")
|
||||
.and_then(Value::as_array)
|
||||
.expect("schemas result must contain a `schemas` array");
|
||||
assert!(
|
||||
schemas.iter().any(|schema| {
|
||||
schema.get("function").and_then(Value::as_str) == Some("install")
|
||||
&& schema.get("namespace").and_then(Value::as_str) == Some("skill_registry")
|
||||
}),
|
||||
"schemas must include skill_registry install schema: {schemas:?}"
|
||||
);
|
||||
|
||||
// ── Step 5: install (happy path) ──────────────────────────────────────
|
||||
|
||||
// Find the fixture entry with a local download_url.
|
||||
let install_target = entries
|
||||
.iter()
|
||||
.find(|e| {
|
||||
e.get("download_url")
|
||||
.and_then(Value::as_str)
|
||||
.map(|u| u == format!("{fixture_base}/skills/git-helper/SKILL.md"))
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.expect("expected the fixture git-helper download_url");
|
||||
|
||||
let entry_id = install_target
|
||||
.get("id")
|
||||
.and_then(Value::as_str)
|
||||
.expect("install_target id");
|
||||
|
||||
let install_resp = post_json_rpc(
|
||||
&rpc_base,
|
||||
9005,
|
||||
"openhuman.skill_registry_install",
|
||||
json!({ "entry_id": entry_id }),
|
||||
)
|
||||
.await;
|
||||
let install_result = assert_no_jsonrpc_error(&install_resp, "skill_registry_install (happy)");
|
||||
|
||||
let install_url = install_result
|
||||
.get("url")
|
||||
.and_then(Value::as_str)
|
||||
.expect("install result must contain `url`");
|
||||
assert!(
|
||||
!install_url.is_empty(),
|
||||
"install result `url` must not be empty"
|
||||
);
|
||||
|
||||
let install_stdout = install_result
|
||||
.get("stdout")
|
||||
.and_then(Value::as_str)
|
||||
.expect("install result must contain `stdout`");
|
||||
assert!(
|
||||
install_stdout.contains("Installed to"),
|
||||
"install stdout should mention 'Installed to', got: {install_stdout}"
|
||||
);
|
||||
|
||||
let _install_stderr = install_result
|
||||
.get("stderr")
|
||||
.expect("install result must contain `stderr`");
|
||||
|
||||
let new_skills = install_result
|
||||
.get("new_skills")
|
||||
.and_then(Value::as_array)
|
||||
.expect("install result must contain `new_skills` array");
|
||||
assert!(
|
||||
new_skills.iter().any(|s| s.as_str() == Some(entry_id)),
|
||||
"new_skills must contain '{entry_id}', got: {new_skills:?}"
|
||||
);
|
||||
|
||||
// Verify the SKILL.md file actually landed on disk.
|
||||
let skill_file = home
|
||||
.join(".openhuman")
|
||||
.join("skills")
|
||||
.join(entry_id)
|
||||
.join("SKILL.md");
|
||||
assert!(
|
||||
skill_file.exists(),
|
||||
"SKILL.md should exist on disk at {}, but was not found",
|
||||
skill_file.display()
|
||||
);
|
||||
|
||||
// ── Step 6: install (duplicate rejection) ─────────────────────────────
|
||||
|
||||
let dup_resp = post_json_rpc(
|
||||
&rpc_base,
|
||||
9006,
|
||||
"openhuman.skill_registry_install",
|
||||
json!({ "entry_id": entry_id }),
|
||||
)
|
||||
.await;
|
||||
let dup_error = assert_jsonrpc_error(&dup_resp, "skill_registry_install (duplicate)");
|
||||
let dup_message = dup_error
|
||||
.get("message")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default();
|
||||
assert!(
|
||||
dup_message.contains("already installed"),
|
||||
"duplicate install error should mention 'already installed', got: {dup_message}"
|
||||
);
|
||||
|
||||
// ── Step 7: uninstall ─────────────────────────────────────────────────
|
||||
|
||||
let uninstall_resp = post_json_rpc(
|
||||
&rpc_base,
|
||||
9007,
|
||||
"openhuman.skill_registry_uninstall",
|
||||
json!({ "name": entry_id }),
|
||||
)
|
||||
.await;
|
||||
let uninstall_result = assert_no_jsonrpc_error(&uninstall_resp, "skill_registry_uninstall");
|
||||
assert_eq!(
|
||||
uninstall_result.get("name").and_then(Value::as_str),
|
||||
Some(entry_id)
|
||||
);
|
||||
assert!(
|
||||
!skill_file.exists(),
|
||||
"SKILL.md should be removed after uninstall at {}",
|
||||
skill_file.display()
|
||||
);
|
||||
|
||||
// ── Cleanup ───────────────────────────────────────────────────────────
|
||||
|
||||
rpc_join.abort();
|
||||
fixture_join.abort();
|
||||
}
|
||||
Reference in New Issue
Block a user