Detect installed agent skills in settings (#2613)

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Brennan Benson
2026-05-22 13:37:05 -07:00
committed by GitHub
co-authored by Orca
parent 0d78e807dd
commit 845622b1ef
17 changed files with 954 additions and 310 deletions
+40 -1
View File
@@ -1,4 +1,4 @@
import { mkdtemp, mkdir, writeFile } from 'node:fs/promises'
import { mkdtemp, mkdir, symlink, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
@@ -55,4 +55,43 @@ describe('skill discovery', () => {
expect(roots.map((root) => root.path)).not.toContain('/remote/repo/.claude/skills')
expect(roots.map((root) => root.path)).toContain('/workspace/current/.claude/skills')
})
it('discovers skill packages through symlinked skill directories', async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-skills-'))
const home = join(root, 'home')
const realSkill = join(root, 'central-skills', 'orca-cli')
const linkedSkill = join(home, '.agents', 'skills', 'orca-cli')
await mkdir(realSkill, { recursive: true })
await mkdir(join(home, '.agents', 'skills'), { recursive: true })
await writeFile(join(realSkill, 'SKILL.md'), '# Orca CLI\n\nUse the Orca CLI.')
await symlink(realSkill, linkedSkill, process.platform === 'win32' ? 'junction' : 'dir')
const result = await discoverSkills({
homeDir: home,
cwd: join(root, 'missing-cwd')
})
const skill = result.skills.find((entry) => entry.name === 'Orca CLI')
expect(skill?.sourceKind).toBe('home')
expect(skill?.directoryPath).toBe(linkedSkill)
})
it('does not loop through recursive symlinked skill directories', async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-skills-'))
const home = join(root, 'home')
const skillRoot = join(home, '.agents', 'skills')
await mkdir(skillRoot, { recursive: true })
await symlink(
skillRoot,
join(skillRoot, 'loop'),
process.platform === 'win32' ? 'junction' : 'dir'
)
const result = await discoverSkills({
homeDir: home,
cwd: join(root, 'missing-cwd')
})
expect(result.skills).toEqual([])
})
})
+59 -3
View File
@@ -1,6 +1,6 @@
import { createHash } from 'node:crypto'
import type { Dirent } from 'node:fs'
import { open, readdir, stat } from 'node:fs/promises'
import { open, readdir, realpath, stat } from 'node:fs/promises'
import { homedir } from 'node:os'
import { basename, dirname, join, relative, sep } from 'node:path'
import type { Repo } from '../../shared/types'
@@ -67,10 +67,22 @@ function sourceLabelForSkill(root: SkillScanRoot, sourceKind: SkillSourceKind):
async function findSkillFiles(rootPath: string, maxDepth: number): Promise<string[]> {
const out: string[] = []
const visitedDirectoryPaths = new Set<string>()
async function visit(dirPath: string): Promise<void> {
if (!isWithinDepth(rootPath, dirPath, maxDepth)) {
return
}
let resolvedDirPath: string
try {
resolvedDirPath = await realpath(dirPath)
} catch {
return
}
if (visitedDirectoryPaths.has(resolvedDirPath)) {
return
}
visitedDirectoryPaths.add(resolvedDirPath)
let entries: Dirent[]
try {
entries = await readdir(dirPath, { withFileTypes: true })
@@ -79,12 +91,36 @@ async function findSkillFiles(rootPath: string, maxDepth: number): Promise<strin
}
for (const entry of entries) {
const entryPath = join(dirPath, entry.name)
if (entry.isFile() && entry.name === SKILL_FILE_NAME) {
out.push(entryPath)
if (entry.name === SKILL_FILE_NAME) {
if (entry.isFile()) {
out.push(entryPath)
continue
}
if (entry.isSymbolicLink()) {
try {
if ((await stat(entryPath)).isFile()) {
out.push(entryPath)
}
} catch {
// Broken links are not valid skill files.
}
}
continue
}
if (entry.isDirectory()) {
await visit(entryPath)
continue
}
if (entry.isSymbolicLink()) {
// Why: users commonly symlink agent skill dirs across providers; follow
// directory links but guard by realpath so recursive links cannot loop.
try {
if ((await stat(entryPath)).isDirectory()) {
await visit(entryPath)
}
} catch {
// Broken links are not valid skill directories.
}
}
}
}
@@ -94,10 +130,22 @@ async function findSkillFiles(rootPath: string, maxDepth: number): Promise<strin
async function countFiles(dirPath: string): Promise<number> {
let count = 0
const visitedDirectoryPaths = new Set<string>()
async function visit(currentPath: string): Promise<void> {
if (count >= MAX_SKILL_FILES) {
return
}
let resolvedPath: string
try {
resolvedPath = await realpath(currentPath)
} catch {
return
}
if (visitedDirectoryPaths.has(resolvedPath)) {
return
}
visitedDirectoryPaths.add(resolvedPath)
let entries: Dirent[]
try {
entries = await readdir(currentPath, { withFileTypes: true })
@@ -113,6 +161,14 @@ async function countFiles(dirPath: string): Promise<number> {
count += 1
} else if (entry.isDirectory()) {
await visit(entryPath)
} else if (entry.isSymbolicLink()) {
try {
if ((await stat(entryPath)).isFile()) {
count += 1
}
} catch {
// Broken links do not contribute to the skill package file count.
}
}
}
}
@@ -0,0 +1,25 @@
import { Check } from 'lucide-react'
import { cn } from '@/lib/utils'
type AgentSkillInstalledIndicatorProps = {
className?: string
showLabel?: boolean
}
export function AgentSkillInstalledIndicator({
className,
showLabel = true
}: AgentSkillInstalledIndicatorProps): React.JSX.Element {
return (
<span
aria-label="Skill installed"
className={cn(
'inline-flex shrink-0 items-center gap-1.5 text-[11px] font-medium text-emerald-700 dark:text-emerald-300',
className
)}
>
<Check className="size-3.5" aria-hidden />
{showLabel ? <span>Installed</span> : <span className="sr-only">Installed</span>}
</span>
)
}
@@ -0,0 +1,38 @@
import { cn } from '@/lib/utils'
export type IntegrationStatusTone = 'connected' | 'attention' | 'neutral'
const TONE_CLASSES: Record<IntegrationStatusTone, { pill: string; dot: string }> = {
connected: {
pill: 'border-emerald-500/40 bg-emerald-500/10 text-emerald-600 dark:text-emerald-300',
dot: 'bg-emerald-500'
},
attention: {
pill: 'border-amber-500/40 bg-amber-500/10 text-amber-700 dark:text-amber-300',
dot: 'bg-amber-500'
},
neutral: {
pill: 'border-border bg-background text-muted-foreground',
dot: 'bg-muted-foreground'
}
}
export function IntegrationStatusPill({
tone,
children
}: {
tone: IntegrationStatusTone
children: React.ReactNode
}): React.JSX.Element {
return (
<span
className={cn(
'inline-flex items-center gap-1.5 rounded-full border px-2 py-0.5 text-[11px] font-medium',
TONE_CLASSES[tone].pill
)}
>
<span className={cn('size-1.5 rounded-full', TONE_CLASSES[tone].dot)} />
{children}
</span>
)
}
@@ -12,7 +12,7 @@ import {
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { useAppStore } from '@/store'
import { cn } from '@/lib/utils'
import { IntegrationStatusPill } from '@/components/integration-status-pill'
import { OnboardingInlineCommandTerminal } from './OnboardingInlineCommandTerminal'
type GitHubSetupState = 'checking' | 'connected' | 'not-installed' | 'not-authenticated'
@@ -29,43 +29,6 @@ function getGitHubSetupState(
return status.gh.authenticated ? 'connected' : 'not-authenticated'
}
type StatusTone = 'connected' | 'attention' | 'neutral'
const statusToneClassNames: Record<StatusTone, { pill: string; dot: string }> = {
connected: {
pill: 'border-emerald-500/40 bg-emerald-500/10 text-emerald-600 dark:text-emerald-300',
dot: 'bg-emerald-500'
},
attention: {
pill: 'border-amber-500/40 bg-amber-500/10 text-amber-700 dark:text-amber-300',
dot: 'bg-amber-500'
},
neutral: {
pill: 'border-border bg-background text-muted-foreground',
dot: 'bg-muted-foreground'
}
}
function StatusPill({
tone,
children
}: {
tone: StatusTone
children: React.ReactNode
}): React.JSX.Element {
return (
<span
className={cn(
'inline-flex items-center gap-1.5 rounded-full border px-2 py-0.5 text-[11px] font-medium',
statusToneClassNames[tone].pill
)}
>
<span className={cn('size-1.5 rounded-full', statusToneClassNames[tone].dot)} />
{children}
</span>
)
}
function GitHubRow(): React.JSX.Element {
const preflightStatus = useAppStore((s) => s.preflightStatus)
const preflightStatusLoading = useAppStore((s) => s.preflightStatusLoading)
@@ -86,13 +49,13 @@ function GitHubRow(): React.JSX.Element {
<div className="flex items-center gap-2">
<h3 className="text-[15px] font-semibold leading-tight text-foreground">GitHub</h3>
{state === 'connected' ? (
<StatusPill tone="connected">Connected</StatusPill>
<IntegrationStatusPill tone="connected">Connected</IntegrationStatusPill>
) : state === 'not-installed' ? (
<StatusPill tone="attention">CLI not installed</StatusPill>
<IntegrationStatusPill tone="attention">CLI not installed</IntegrationStatusPill>
) : state === 'not-authenticated' ? (
<StatusPill tone="attention">Sign in needed</StatusPill>
<IntegrationStatusPill tone="attention">Sign in needed</IntegrationStatusPill>
) : (
<StatusPill tone="neutral">Checking</StatusPill>
<IntegrationStatusPill tone="neutral">Checking</IntegrationStatusPill>
)}
</div>
<p className="mt-1 text-[13px] leading-relaxed text-muted-foreground">
@@ -189,7 +152,9 @@ function LinearRow(): React.JSX.Element {
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<h3 className="text-[15px] font-semibold leading-tight text-foreground">Linear</h3>
{linearStatus.connected ? <StatusPill tone="connected">Connected</StatusPill> : null}
{linearStatus.connected ? (
<IntegrationStatusPill tone="connected">Connected</IntegrationStatusPill>
) : null}
</div>
<p className="mt-1 text-[13px] leading-relaxed text-muted-foreground">
{linearStatus.connected
@@ -334,7 +299,7 @@ export function IntegrationsStep(): React.JSX.Element {
Issues, sprints, and assignees.
</span>
</div>
<StatusPill tone="neutral">Coming soon</StatusPill>
<IntegrationStatusPill tone="neutral">Coming soon</IntegrationStatusPill>
</div>
</div>
</div>
@@ -6,15 +6,16 @@ import { focusTerminalTabSurface } from '@/lib/focus-terminal-tab-surface'
import { useAppStore } from '@/store'
const ONBOARDING_INLINE_TERMINAL_WORKTREE_ID = 'onboarding-inline-terminal'
const AUTO_INSERT_DELAY_MS = 700
const AUTO_INSERT_DELAY_MS = 250
const READY_RETRY_MS = 100
const READY_MAX_ATTEMPTS = 50
const PTY_TEXT_FALLBACK_MS = 750
type OnboardingInlineCommandTerminalProps = {
command: string
title: string
description: string
ariaLabel: string
worktreeId?: string
onOpened?: () => void
onInteracted?: (method: 'keyboard' | 'pointer', event?: KeyboardEvent<HTMLElement>) => void
}
@@ -24,6 +25,7 @@ export function OnboardingInlineCommandTerminal({
title,
description,
ariaLabel,
worktreeId = ONBOARDING_INLINE_TERMINAL_WORKTREE_ID,
onOpened,
onInteracted
}: OnboardingInlineCommandTerminalProps): React.JSX.Element {
@@ -56,13 +58,18 @@ export function OnboardingInlineCommandTerminal({
}, [])
useEffect(() => {
const tab = createTab(ONBOARDING_INLINE_TERMINAL_WORKTREE_ID, undefined, undefined, {
const tab = createTab(worktreeId, undefined, undefined, {
activate: false
})
setActiveTabForWorktree(ONBOARDING_INLINE_TERMINAL_WORKTREE_ID, tab.id)
setActiveTabForWorktree(worktreeId, tab.id)
setTabCustomTitle(tab.id, title)
setTabId(tab.id)
}, [createTab, setActiveTabForWorktree, setTabCustomTitle, title])
return () => {
// Why: inline setup panels can disappear after detection succeeds; close
// the backing tab so installer shells do not keep running invisibly.
closeTab(tab.id)
}
}, [closeTab, createTab, setActiveTabForWorktree, setTabCustomTitle, title, worktreeId])
useEffect(() => {
if (prefersReducedMotion) {
@@ -119,38 +126,62 @@ export function OnboardingInlineCommandTerminal({
}, [command, tabId])
useEffect(() => {
if (!tabId || autoInsertedRef.current === command) {
if (!tabId || !cwd || autoInsertedRef.current === command) {
return
}
let canceled = false
let insertionTimer: number | null = null
let retryTimer: number | null = null
let ptyFirstSeenAt: number | null = null
const waitForTerminal = (attempt: number): void => {
const scheduleInsert = (): void => {
if (insertionTimer !== null) {
return
}
insertionTimer = window.setTimeout(() => {
if (!canceled) {
autoInsertedRef.current = command
insertCommand()
}
}, AUTO_INSERT_DELAY_MS)
}
const waitForTerminal = (): void => {
if (canceled) {
return
}
if (findTerminalTabElement(tabId)?.querySelector('[data-pty-id]')) {
insertionTimer = window.setTimeout(() => {
if (!canceled) {
autoInsertedRef.current = command
insertCommand()
}
}, AUTO_INSERT_DELAY_MS)
const terminalElement = findTerminalTabElement(tabId)
const hasPty = Boolean(terminalElement?.querySelector('[data-pty-id]'))
if (terminalReadyForCommand(terminalElement)) {
scheduleInsert()
return
}
if (attempt < READY_MAX_ATTEMPTS) {
window.setTimeout(() => waitForTerminal(attempt + 1), READY_RETRY_MS)
if (hasPty) {
ptyFirstSeenAt ??= Date.now()
// Why: GPU/canvas terminal renderers may not expose visible prompt text
// in .xterm-rows. Once the PTY has settled briefly, paste the draft
// instead of waiting on a DOM signal that may never arrive.
if (Date.now() - ptyFirstSeenAt >= PTY_TEXT_FALLBACK_MS) {
scheduleInsert()
return
}
} else {
ptyFirstSeenAt = null
}
retryTimer = window.setTimeout(waitForTerminal, READY_RETRY_MS)
}
waitForTerminal(0)
waitForTerminal()
return () => {
canceled = true
if (retryTimer !== null) {
window.clearTimeout(retryTimer)
}
if (insertionTimer !== null) {
window.clearTimeout(insertionTimer)
}
}
}, [command, insertCommand, tabId])
}, [command, cwd, insertCommand, tabId])
// Why: grid 0fr → 1fr animates to the child's natural height without a
// hardcoded max-height, so we don't leave dead space if the terminal
@@ -182,7 +213,7 @@ export function OnboardingInlineCommandTerminal({
{cwd && tabId ? (
<TerminalPane
tabId={tabId}
worktreeId={ONBOARDING_INLINE_TERMINAL_WORKTREE_ID}
worktreeId={worktreeId}
cwd={cwd}
isActive
isVisible
@@ -209,3 +240,13 @@ function findTerminalTabElement(tabId: string): HTMLElement | null {
}
return null
}
function terminalReadyForCommand(element: HTMLElement | null): boolean {
if (!element?.querySelector('[data-pty-id]')) {
return false
}
// Why: pasting before the login shell renders a prompt can double-echo the
// draft command. Visible terminal text is the least intrusive readiness signal.
const renderedText = element.querySelector('.xterm-rows')?.textContent?.trim() ?? ''
return renderedText.length > 0
}
@@ -0,0 +1,124 @@
import { useEffect, useState, type ReactNode } from 'react'
import { Terminal } from 'lucide-react'
import { IntegrationStatusPill } from '../integration-status-pill'
import { OnboardingInlineCommandTerminal } from '../onboarding/OnboardingInlineCommandTerminal'
import { Button } from '../ui/button'
import { cn } from '@/lib/utils'
type AgentSkillSetupPanelVariant = 'card' | 'inline'
type AgentSkillSetupPanelProps = {
title: string
detectedDescription: string
missingDescription: string
command: string
terminalTitle: string
terminalAriaLabel: string
terminalWorktreeId: string
installed: boolean
detected: boolean
loading: boolean
error: string | null
installDisabled?: boolean
leading?: ReactNode
icon?: ReactNode
variant?: AgentSkillSetupPanelVariant
className?: string
onRecheck: () => void | Promise<void>
}
export function AgentSkillSetupPanel({
title,
detectedDescription,
missingDescription,
command,
terminalTitle,
terminalAriaLabel,
terminalWorktreeId,
installed,
detected,
loading,
error,
installDisabled = false,
leading,
icon,
variant = 'card',
className,
onRecheck
}: AgentSkillSetupPanelProps): React.JSX.Element {
const [terminalOpen, setTerminalOpen] = useState(false)
useEffect(() => {
if (installed) {
setTerminalOpen(false)
}
}, [installed])
const body = detected ? detectedDescription : missingDescription
return (
<div
className={cn(
variant === 'card' ? 'rounded-xl border border-border bg-muted/20' : null,
className
)}
>
<div className={cn('flex items-start gap-4', variant === 'card' ? 'p-5' : null)}>
{leading}
{icon ? (
<div className="flex size-10 shrink-0 items-center justify-center rounded-lg border border-border bg-background text-foreground">
{icon}
</div>
) : null}
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<h3 className="text-[15px] font-semibold leading-tight text-foreground">{title}</h3>
{loading && !installed ? (
<IntegrationStatusPill tone="neutral">Checking...</IntegrationStatusPill>
) : installed ? (
<IntegrationStatusPill tone="connected">Installed</IntegrationStatusPill>
) : (
<IntegrationStatusPill tone="attention">Not installed</IntegrationStatusPill>
)}
</div>
<p className="mt-1 text-[13px] leading-relaxed text-muted-foreground">{body}</p>
{error ? <p className="mt-1 text-[12px] text-destructive">{error}</p> : null}
</div>
<div className="flex shrink-0 items-center gap-2">
{!installed ? (
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setTerminalOpen(true)}
disabled={terminalOpen || installDisabled}
>
<Terminal className="size-3.5" />
Install
</Button>
) : null}
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => void onRecheck()}
disabled={loading}
>
Re-check
</Button>
</div>
</div>
{!installed && terminalOpen ? (
<div className={cn(variant === 'card' ? 'px-5 pb-5' : 'mt-3')}>
<OnboardingInlineCommandTerminal
worktreeId={terminalWorktreeId}
command={command}
title={terminalTitle}
ariaLabel={terminalAriaLabel}
description="Press Enter to run the installer. If you already installed this skill, skip this terminal and click Re-check instead."
/>
</div>
) : null}
</div>
)
}
@@ -2,11 +2,15 @@ import { useEffect, useState } from 'react'
import { Import, Loader2, MousePointerClick } from 'lucide-react'
import { toast } from 'sonner'
import type { CliInstallStatus } from '../../../../shared/cli-install-types'
import { ORCA_CLI_SKILL_INSTALL_COMMAND } from '@/lib/agent-feature-install-commands'
import {
BROWSER_USE_ENABLED_STORAGE_KEY,
BROWSER_USE_SKILL_INSTALLED_STORAGE_KEY
} from '@/lib/browser-use-setup-state'
ORCA_CLI_SKILL_INSTALL_COMMAND,
ORCA_CLI_SKILL_NAME
} from '@/lib/agent-feature-install-commands'
import { BROWSER_USE_ENABLED_STORAGE_KEY } from '@/lib/browser-use-setup-state'
import {
GLOBAL_AGENT_SKILL_SOURCE_KINDS,
useInstalledAgentSkill
} from '@/hooks/useInstalledAgentSkills'
import { Button } from '../ui/button'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../ui/tooltip'
import {
@@ -93,19 +97,16 @@ export function BrowserUseSetup({
const cliEnabled = cliStatus?.state === 'installed'
const cliSupported = cliStatus?.supported ?? false
// Why: the skill install step is a copy-and-run command that happens in the
// user's terminal. We cannot detect completion from the app, so we let the
// user mark it done explicitly after copying — this avoids falsely implying
// progress and keeps the guided flow honest.
const [skillInstalled, setSkillInstalled] = useState<boolean>(() => {
return localStorage.getItem(BROWSER_USE_SKILL_INSTALLED_STORAGE_KEY) === '1'
const {
installed: skillDetected,
loading: skillLoading,
error: skillError,
refresh: refreshSkill
} = useInstalledAgentSkill(ORCA_CLI_SKILL_NAME, {
enabled: browserUseEnabled,
sourceKinds: GLOBAL_AGENT_SKILL_SOURCE_KINDS
})
const markSkillInstalled = (value: boolean): void => {
setSkillInstalled(value)
localStorage.setItem(BROWSER_USE_SKILL_INSTALLED_STORAGE_KEY, value ? '1' : '0')
}
const handleEnableCli = async (): Promise<void> => {
setCliBusy(true)
try {
@@ -119,15 +120,6 @@ export function BrowserUseSetup({
}
}
const handleCopySkillCommand = async (): Promise<void> => {
try {
await window.api.ui.writeClipboardText(ORCA_CLI_SKILL_INSTALL_COMMAND)
toast.success('Copied install command. Run it on this computer.')
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Failed to copy command.')
}
}
const handleImportFromBrowser = async (
browserFamily: string,
browserProfile?: string
@@ -162,8 +154,7 @@ export function BrowserUseSetup({
const showStep1 = matchesSettingsSearch(searchQuery, [BROWSER_USE_PANE_SEARCH_ENTRIES[0]])
const showStep2 = matchesSettingsSearch(searchQuery, [BROWSER_USE_PANE_SEARCH_ENTRIES[1]])
const showStep3 = matchesSettingsSearch(searchQuery, [BROWSER_USE_PANE_SEARCH_ENTRIES[2]])
const completedCount = [cliEnabled, skillInstalled, cookiesImported].filter(Boolean).length
const completedCount = [cliEnabled, skillDetected, cookiesImported].filter(Boolean).length
const sourceLabel = defaultProfile?.source
? `${BROWSER_FAMILY_LABELS[defaultProfile.source.browserFamily] ?? defaultProfile.source.browserFamily}${defaultProfile.source.profileName ? ` (${defaultProfile.source.profileName})` : ''}`
@@ -310,10 +301,11 @@ export function BrowserUseSetup({
>
<BrowserUseSkillStep
command={ORCA_CLI_SKILL_INSTALL_COMMAND}
skillInstalled={skillInstalled}
skillDetected={skillDetected}
skillLoading={skillLoading}
skillError={skillError}
disabled={!cliEnabled}
onCopy={() => void handleCopySkillCommand()}
onToggleInstalled={() => markSkillInstalled(!skillInstalled)}
onRecheck={refreshSkill}
/>
</SearchableSetting>
) : null}
@@ -324,7 +316,7 @@ export function BrowserUseSetup({
description="Import cookies from Chrome, Edge, or other browsers so agents can reuse your logins."
keywords={BROWSER_USE_PANE_SEARCH_ENTRIES[2].keywords}
className={`rounded-xl border border-border/60 bg-card/50 p-4 ${
cliEnabled && skillInstalled ? '' : 'opacity-60'
cliEnabled && skillDetected ? '' : 'opacity-60'
}`}
>
<div className="flex items-start gap-3">
@@ -1,72 +1,40 @@
import { Copy } from 'lucide-react'
import { Button } from '../ui/button'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../ui/tooltip'
import { AgentSkillSetupPanel } from './AgentSkillSetupPanel'
import { StepBadge } from './BrowserUseStepBadge'
type Props = {
command: string
skillInstalled: boolean
skillDetected: boolean
skillLoading: boolean
skillError: string | null
disabled?: boolean
onCopy: () => void
onToggleInstalled: () => void
onRecheck: () => void | Promise<void>
}
export function BrowserUseSkillStep({
command,
skillInstalled,
skillDetected,
skillLoading,
skillError,
disabled = false,
onCopy,
onToggleInstalled
onRecheck
}: Props): React.JSX.Element {
return (
<div className="flex items-start gap-3">
<StepBadge index={2} state={skillInstalled ? 'done' : 'pending'} />
<div className="min-w-0 flex-1 space-y-2">
<div className="space-y-1">
<p className="text-sm font-medium">Install Browser Use Skill</p>
<p className="text-xs text-muted-foreground">
Run this once on your computer so Claude Code, Codex, and other agents learn to drive
Orca&apos;s browser.
</p>
</div>
<div className="flex max-w-full items-center gap-2 rounded-lg border border-border/60 bg-background/60 px-3 py-2">
<code className="flex-1 overflow-x-auto whitespace-nowrap text-[11px] text-muted-foreground">
{command}
</code>
<TooltipProvider delayDuration={250}>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon-xs"
onClick={onCopy}
aria-label="Copy skill install command"
>
<Copy className="size-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
Copy
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<div className="flex items-center gap-2 text-[11px] text-muted-foreground">
<span>
{skillInstalled
? 'Marked as installed on this machine.'
: "Check off once you've run it on this computer."}
</span>
<button
type="button"
className="underline-offset-2 hover:text-foreground hover:underline disabled:cursor-not-allowed disabled:no-underline disabled:hover:text-muted-foreground"
onClick={onToggleInstalled}
disabled={disabled}
>
{skillInstalled ? 'Undo' : 'I ran it'}
</button>
</div>
</div>
</div>
<AgentSkillSetupPanel
variant="inline"
title="Browser Use skill"
detectedDescription="Detected on this machine. Agents can drive Orca's browser."
missingDescription="Agents need this skill before they can drive Orca's browser. If you already installed it, click Re-check instead of running the installer again."
command={command}
terminalTitle="Browser Use setup"
terminalAriaLabel="Browser Use skill install terminal"
terminalWorktreeId="settings-browser-use-skill-terminal"
installed={skillDetected}
detected={skillDetected}
loading={skillLoading}
error={skillError}
installDisabled={disabled}
leading={<StepBadge index={2} state={skillDetected ? 'done' : 'pending'} />}
onRecheck={onRecheck}
/>
)
}
@@ -1,8 +1,15 @@
import { useEffect, useState } from 'react'
import { Copy, FolderOpen, RefreshCw } from 'lucide-react'
import { FolderOpen, RefreshCw } from 'lucide-react'
import { toast } from 'sonner'
import type { CliInstallStatus } from '../../../../shared/cli-install-types'
import { ORCA_CLI_SKILL_INSTALL_COMMAND } from '@/lib/agent-feature-install-commands'
import {
ORCA_CLI_SKILL_INSTALL_COMMAND,
ORCA_CLI_SKILL_NAME
} from '@/lib/agent-feature-install-commands'
import {
GLOBAL_AGENT_SKILL_SOURCE_KINDS,
useInstalledAgentSkill
} from '@/hooks/useInstalledAgentSkills'
import { Button } from '../ui/button'
import {
Dialog,
@@ -14,6 +21,7 @@ import {
} from '../ui/dialog'
import { Label } from '../ui/label'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../ui/tooltip'
import { AgentSkillSetupPanel } from './AgentSkillSetupPanel'
import { WslCliRegistration } from './WslCliRegistration'
type CliSectionProps = {
@@ -48,6 +56,14 @@ export function CliSection({ currentPlatform }: CliSectionProps): React.JSX.Elem
const [loading, setLoading] = useState(true)
const [dialogOpen, setDialogOpen] = useState(false)
const [busyAction, setBusyAction] = useState<'install' | 'remove' | null>(null)
const {
installed: cliSkillDetected,
loading: cliSkillLoading,
error: cliSkillError,
refresh: refreshCliSkill
} = useInstalledAgentSkill(ORCA_CLI_SKILL_NAME, {
sourceKinds: GLOBAL_AGENT_SKILL_SOURCE_KINDS
})
const refreshStatus = async (): Promise<void> => {
setLoading(true)
@@ -99,15 +115,6 @@ export function CliSection({ currentPlatform }: CliSectionProps): React.JSX.Elem
}
}
const handleCopySkillInstallCommand = async (command: string): Promise<void> => {
try {
await window.api.ui.writeClipboardText(command)
toast.success('Copied skill install command.')
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Failed to copy install command.')
}
}
return (
<section className="space-y-4">
<div className="space-y-1">
@@ -214,35 +221,22 @@ export function CliSection({ currentPlatform }: CliSectionProps): React.JSX.Elem
</p>
</div>
<div className="mt-3 space-y-3">
<div className="space-y-1">
<p className="text-xs text-muted-foreground">CLI skill</p>
<div className="inline-flex max-w-full items-center gap-2 rounded-lg border border-border/60 bg-background/60 px-3 py-2">
<code className="overflow-x-auto whitespace-nowrap text-[11px] text-muted-foreground">
{ORCA_CLI_SKILL_INSTALL_COMMAND}
</code>
<TooltipProvider delayDuration={250}>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon-xs"
onClick={() =>
void handleCopySkillInstallCommand(ORCA_CLI_SKILL_INSTALL_COMMAND)
}
aria-label="Copy CLI skill install command"
>
<Copy className="size-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
Copy
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</div>
</div>
<AgentSkillSetupPanel
className="mt-3"
variant="inline"
title="CLI skill"
detectedDescription="Detected on this machine. Agents know how to use Orca and report status."
missingDescription="Agents need this skill before they can use Orca and report status. If you already installed it, click Re-check instead of running the installer again."
command={ORCA_CLI_SKILL_INSTALL_COMMAND}
terminalTitle="CLI skill setup"
terminalAriaLabel="CLI skill install terminal"
terminalWorktreeId="settings-cli-skill-terminal"
installed={cliSkillDetected}
detected={cliSkillDetected}
loading={cliSkillLoading}
error={cliSkillError}
onRecheck={refreshCliSkill}
/>
</div>
) : null}
</div>
@@ -1,14 +1,28 @@
import { useCallback, useEffect, useMemo, useState, type ReactNode } from 'react'
import { Accessibility, Camera, Copy, ExternalLink, RefreshCw, ShieldCheck } from 'lucide-react'
import {
Accessibility,
Camera,
ExternalLink,
MonitorCog,
RefreshCw,
ShieldCheck
} from 'lucide-react'
import { toast } from 'sonner'
import type {
ComputerUsePermissionId,
ComputerUsePermissionState,
ComputerUsePermissionStatus
} from '../../../../shared/computer-use-permissions-types'
import { COMPUTER_USE_SKILL_INSTALL_COMMAND } from '@/lib/agent-feature-install-commands'
import {
COMPUTER_USE_SKILL_INSTALL_COMMAND,
COMPUTER_USE_SKILL_NAME
} from '@/lib/agent-feature-install-commands'
import {
GLOBAL_AGENT_SKILL_SOURCE_KINDS,
useInstalledAgentSkill
} from '@/hooks/useInstalledAgentSkills'
import { Button } from '../ui/button'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../ui/tooltip'
import { AgentSkillSetupPanel } from './AgentSkillSetupPanel'
import type { SettingsSearchEntry } from './settings-search'
export const COMPUTER_USE_PANE_SEARCH_ENTRIES: SettingsSearchEntry[] = [
@@ -73,6 +87,14 @@ export function ComputerUsePane(): React.JSX.Element {
const [loading, setLoading] = useState(true)
const [pendingId, setPendingId] = useState<ComputerUsePermissionId | null>(null)
const [helperUnavailableReason, setHelperUnavailableReason] = useState<string | null>(null)
const {
installed: computerUseSkillDetected,
loading: computerUseSkillLoading,
error: computerUseSkillError,
refresh: refreshComputerUseSkill
} = useInstalledAgentSkill(COMPUTER_USE_SKILL_NAME, {
sourceKinds: GLOBAL_AGENT_SKILL_SOURCE_KINDS
})
const stateById = useMemo(
() => new Map(states.map((state) => [state.id, state.status] as const)),
@@ -127,15 +149,6 @@ export function ComputerUsePane(): React.JSX.Element {
}
}
const copySkillInstallCommand = async (): Promise<void> => {
try {
await window.api.ui.writeClipboardText(COMPUTER_USE_SKILL_INSTALL_COMMAND)
toast.success('Copied skill install command.')
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Failed to copy install command.')
}
}
const isMac = platform === null || platform === 'darwin'
return (
@@ -209,36 +222,21 @@ export function ComputerUsePane(): React.JSX.Element {
</>
) : null}
<div className="space-y-2 rounded-lg border border-border/60 px-4 py-3">
<div className="space-y-1">
<p className="text-sm font-medium">Install Computer Use Skill</p>
<p className="text-xs text-muted-foreground">
Run this once on your computer so agents know how to use Orca&apos;s computer controls.
</p>
</div>
<div className="flex max-w-full items-center gap-2 rounded-lg border border-border/60 bg-background/60 px-3 py-2">
<code className="flex-1 overflow-x-auto whitespace-nowrap text-[11px] text-muted-foreground">
{COMPUTER_USE_SKILL_INSTALL_COMMAND}
</code>
<TooltipProvider delayDuration={250}>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon-xs"
onClick={() => void copySkillInstallCommand()}
aria-label="Copy Computer Use skill install command"
>
<Copy className="size-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
Copy
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</div>
<AgentSkillSetupPanel
title="Computer Use skill"
detectedDescription="Detected on this machine. Agents can use Orca's computer controls."
missingDescription="Agents need this skill before they can use Orca's computer controls. If you already installed it, click Re-check instead of running the installer again."
command={COMPUTER_USE_SKILL_INSTALL_COMMAND}
terminalTitle="Computer Use setup"
terminalAriaLabel="Computer Use skill install terminal"
terminalWorktreeId="settings-computer-use-skill-terminal"
installed={computerUseSkillDetected}
detected={computerUseSkillDetected}
loading={computerUseSkillLoading}
error={computerUseSkillError}
icon={<MonitorCog className="size-5" />}
onRecheck={refreshComputerUseSkill}
/>
</div>
)
}
@@ -1,22 +1,23 @@
import { useEffect, useState } from 'react'
import { Copy } from 'lucide-react'
import { toast } from 'sonner'
import { Button } from '../ui/button'
import { Workflow } from 'lucide-react'
import { Label } from '../ui/label'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../ui/tooltip'
import { ORCHESTRATION_SKILL_NAME } from '@/lib/agent-feature-install-commands'
import { ORCHESTRATION_SKILL_INSTALL_COMMAND } from '@/lib/orchestration-install-command'
import {
GLOBAL_AGENT_SKILL_SOURCE_KINDS,
useInstalledAgentSkill
} from '@/hooks/useInstalledAgentSkills'
import {
ORCHESTRATION_ENABLED_STORAGE_KEY,
ORCHESTRATION_SKILL_INSTALLED_STORAGE_KEY,
ORCHESTRATION_SETUP_STATE_EVENT,
isOrchestrationSetupEnabled,
isOrchestrationSkillMarkedInstalled,
notifyOrchestrationSetupStateChanged
} from '@/lib/orchestration-setup-state'
import { SearchableSetting } from './SearchableSetting'
import { matchesSettingsSearch } from './settings-search'
import { useAppStore } from '../../store'
import { ORCHESTRATION_PANE_SEARCH_ENTRIES } from './orchestration-search'
import { AgentSkillSetupPanel } from './AgentSkillSetupPanel'
export function OrchestrationPane(): React.JSX.Element {
const searchQuery = useAppStore((s) => s.settingsSearchQuery)
@@ -26,14 +27,19 @@ export function OrchestrationPane(): React.JSX.Element {
return isOrchestrationSetupEnabled()
})
const [orchestrationSkillInstalled, setOrchestrationSkillInstalled] = useState<boolean>(() => {
return isOrchestrationSkillMarkedInstalled()
const {
installed: orchestrationSkillDetected,
loading: orchestrationSkillLoading,
error: orchestrationSkillError,
refresh: refreshOrchestrationSkill
} = useInstalledAgentSkill(ORCHESTRATION_SKILL_NAME, {
enabled: orchestrationEnabled,
sourceKinds: GLOBAL_AGENT_SKILL_SOURCE_KINDS
})
useEffect(() => {
const syncSetupState = (): void => {
setOrchestrationEnabled(isOrchestrationSetupEnabled())
setOrchestrationSkillInstalled(isOrchestrationSkillMarkedInstalled())
}
window.addEventListener(ORCHESTRATION_SETUP_STATE_EVENT, syncSetupState)
return () => {
@@ -47,19 +53,8 @@ export function OrchestrationPane(): React.JSX.Element {
notifyOrchestrationSetupStateChanged()
}
const markOrchestrationSkillInstalled = (value: boolean): void => {
setOrchestrationSkillInstalled(value)
localStorage.setItem(ORCHESTRATION_SKILL_INSTALLED_STORAGE_KEY, value ? '1' : '0')
notifyOrchestrationSetupStateChanged()
}
const handleCopyOrchestrationCommand = async (): Promise<void> => {
try {
await window.api.ui.writeClipboardText(ORCHESTRATION_SKILL_INSTALL_COMMAND)
toast.success('Copied install command. Run it on this computer.')
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Failed to copy command.')
}
const handleRecheckOrchestrationSkill = async (): Promise<void> => {
await refreshOrchestrationSkill()
}
if (!showOrchestration) {
@@ -98,50 +93,21 @@ export function OrchestrationPane(): React.JSX.Element {
</div>
{orchestrationEnabled ? (
<div className="space-y-3 rounded-xl border border-border/60 bg-card/50 p-4">
<div className="space-y-1">
<p className="text-sm font-medium">Install Orchestration Skill</p>
<p className="text-xs text-muted-foreground">
Run this once on your computer so agents learn to use inter-agent orchestration.
</p>
</div>
<div className="flex max-w-full items-center gap-2 rounded-lg border border-border/60 bg-background/60 px-3 py-2">
<code className="flex-1 overflow-x-auto whitespace-nowrap text-[11px] text-muted-foreground">
{ORCHESTRATION_SKILL_INSTALL_COMMAND}
</code>
<TooltipProvider delayDuration={250}>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon-xs"
onClick={() => void handleCopyOrchestrationCommand()}
aria-label="Copy orchestration skill install command"
>
<Copy className="size-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
Copy
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<div className="flex items-center gap-2 text-[11px] text-muted-foreground">
<span>
{orchestrationSkillInstalled
? 'Marked as installed on this machine.'
: "Check off once you've run it on this computer."}
</span>
<button
type="button"
className="underline-offset-2 hover:text-foreground hover:underline"
onClick={() => markOrchestrationSkillInstalled(!orchestrationSkillInstalled)}
>
{orchestrationSkillInstalled ? 'Undo' : 'I ran it'}
</button>
</div>
</div>
<AgentSkillSetupPanel
title="Orchestration skill"
detectedDescription="Detected on this machine. Agents can use inter-agent orchestration."
missingDescription="Agents need this skill before they can use inter-agent orchestration. If you already installed it, click Re-check instead of running the installer again."
command={ORCHESTRATION_SKILL_INSTALL_COMMAND}
terminalTitle="Orchestration setup"
terminalAriaLabel="Orchestration skill install terminal"
terminalWorktreeId="settings-orchestration-skill-terminal"
installed={orchestrationSkillDetected}
detected={orchestrationSkillDetected}
loading={orchestrationSkillLoading}
error={orchestrationSkillError}
icon={<Workflow className="size-5" />}
onRecheck={handleRecheckOrchestrationSkill}
/>
) : null}
</SearchableSetting>
)
@@ -0,0 +1,145 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { DiscoveredSkill, SkillDiscoveryResult } from '../../../shared/skills'
import {
GLOBAL_AGENT_SKILL_SOURCE_KINDS,
_installedAgentSkillDiscoveryInternalsForTests,
hasInstalledAgentSkill
} from './useInstalledAgentSkills'
afterEach(() => {
_installedAgentSkillDiscoveryInternalsForTests.reset()
vi.restoreAllMocks()
vi.unstubAllGlobals()
})
function skill(overrides: Partial<DiscoveredSkill>): DiscoveredSkill {
return {
id: 'skill-1',
name: 'Example Skill',
description: null,
providers: ['agent-skills'],
sourceKind: 'home',
sourceLabel: 'Agent skills home',
rootPath: '/Users/test/.agents/skills',
directoryPath: '/Users/test/.agents/skills/example-skill',
skillFilePath: '/Users/test/.agents/skills/example-skill/SKILL.md',
installed: true,
fileCount: 1,
updatedAt: null,
...overrides
}
}
function discoveryResult(skills: DiscoveredSkill[] = []): SkillDiscoveryResult {
return {
skills,
sources: [],
scannedAt: Date.now()
}
}
function deferred<T>(): {
promise: Promise<T>
resolve: (value: T) => void
reject: (reason?: unknown) => void
} {
let resolve!: (value: T) => void
let reject!: (reason?: unknown) => void
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
resolve = resolvePromise
reject = rejectPromise
})
return { promise, resolve, reject }
}
describe('hasInstalledAgentSkill', () => {
it('matches installed skills by summarized name', () => {
expect(hasInstalledAgentSkill([skill({ name: 'orca-cli' })], 'orca-cli')).toBe(true)
})
it('matches installed skills by directory name when frontmatter has a display name', () => {
expect(
hasInstalledAgentSkill(
[
skill({
name: 'Orca CLI',
directoryPath: 'C:\\Users\\test\\.agents\\skills\\orca-cli'
})
],
'orca-cli'
)
).toBe(true)
})
it('ignores non-installed discovery entries', () => {
expect(
hasInstalledAgentSkill([skill({ name: 'orca-cli', installed: false })], 'orca-cli')
).toBe(false)
})
it('does not count repo or plugin skills when matching global installs', () => {
expect(
hasInstalledAgentSkill(
[
skill({
name: 'orca-cli',
sourceKind: 'repo',
sourceLabel: 'Repo test .agents',
rootPath: '/repo/.agents/skills',
directoryPath: '/repo/.agents/skills/orca-cli',
skillFilePath: '/repo/.agents/skills/orca-cli/SKILL.md'
}),
skill({
id: 'skill-2',
name: 'orca-cli',
sourceKind: 'plugin',
sourceLabel: 'Codex plugin cache',
rootPath: '/Users/test/.codex/plugins/cache',
directoryPath: '/Users/test/.codex/plugins/cache/vendor/orca-cli',
skillFilePath: '/Users/test/.codex/plugins/cache/vendor/orca-cli/SKILL.md'
})
],
'orca-cli',
{ sourceKinds: GLOBAL_AGENT_SKILL_SOURCE_KINDS }
)
).toBe(false)
})
it('counts home skills when matching global installs', () => {
expect(
hasInstalledAgentSkill([skill({ name: 'orca-cli' })], 'orca-cli', {
sourceKinds: GLOBAL_AGENT_SKILL_SOURCE_KINDS
})
).toBe(true)
})
})
describe('discoverInstalledAgentSkills', () => {
it('starts a fresh scan when a forced refresh arrives during a background scan', async () => {
const firstScan = deferred<SkillDiscoveryResult>()
const secondScan = deferred<SkillDiscoveryResult>()
const discover = vi.fn<() => Promise<SkillDiscoveryResult>>()
discover.mockReturnValueOnce(firstScan.promise)
discover.mockReturnValueOnce(secondScan.promise)
vi.stubGlobal('window', {
api: { skills: { discover } }
})
const backgroundRefresh =
_installedAgentSkillDiscoveryInternalsForTests.discoverInstalledAgentSkills(false)
const forcedRefresh =
_installedAgentSkillDiscoveryInternalsForTests.discoverInstalledAgentSkills(true)
expect(discover).toHaveBeenCalledTimes(1)
const staleResult = discoveryResult([])
firstScan.resolve(staleResult)
await expect(backgroundRefresh).resolves.toBe(staleResult)
expect(discover).toHaveBeenCalledTimes(2)
const freshResult = discoveryResult([skill({ name: 'orca-cli' })])
secondScan.resolve(freshResult)
await expect(forcedRefresh).resolves.toBe(freshResult)
})
})
@@ -0,0 +1,179 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import type { DiscoveredSkill, SkillDiscoveryResult, SkillSourceKind } from '../../../shared/skills'
const INSTALLED_AGENT_SKILLS_CHANGED_EVENT = 'orca:installed-agent-skills-changed'
export const GLOBAL_AGENT_SKILL_SOURCE_KINDS = [
'home'
] as const satisfies readonly SkillSourceKind[]
type InstalledAgentSkillOptions = {
enabled?: boolean
sourceKinds?: readonly SkillSourceKind[]
}
type InstalledAgentSkillMatchOptions = {
sourceKinds?: readonly SkillSourceKind[]
}
let cachedDiscovery: SkillDiscoveryResult | null = null
let pendingDiscovery: Promise<SkillDiscoveryResult> | null = null
let pendingDiscoverySatisfiesForcedRefresh = false
function normalizeSkillName(value: string): string {
return value.trim().toLowerCase()
}
function basenameFromPath(pathValue: string): string {
return pathValue.split(/[\\/]/).filter(Boolean).at(-1) ?? pathValue
}
export function hasInstalledAgentSkill(
skills: readonly DiscoveredSkill[],
skillName: string,
options: InstalledAgentSkillMatchOptions = {}
): boolean {
const expected = normalizeSkillName(skillName)
return skills.some((skill) => {
if (!skill.installed) {
return false
}
if (options.sourceKinds && !options.sourceKinds.includes(skill.sourceKind)) {
return false
}
return (
normalizeSkillName(skill.name) === expected ||
normalizeSkillName(basenameFromPath(skill.directoryPath)) === expected
)
})
}
export function notifyInstalledAgentSkillsChanged(): void {
cachedDiscovery = null
if (typeof window !== 'undefined') {
window.dispatchEvent(new CustomEvent(INSTALLED_AGENT_SKILLS_CHANGED_EVENT))
}
}
function startInstalledAgentSkillDiscovery(force: boolean): Promise<SkillDiscoveryResult> {
const discovery = window.api.skills
.discover()
.then((result) => {
cachedDiscovery = result
return result
})
.finally(() => {
if (pendingDiscovery === discovery) {
pendingDiscovery = null
pendingDiscoverySatisfiesForcedRefresh = false
}
})
pendingDiscovery = discovery
pendingDiscoverySatisfiesForcedRefresh = force
return discovery
}
async function discoverInstalledAgentSkills(force: boolean): Promise<SkillDiscoveryResult> {
if (!force && cachedDiscovery) {
return cachedDiscovery
}
const inFlightDiscovery = pendingDiscovery
if (inFlightDiscovery) {
if (!force || pendingDiscoverySatisfiesForcedRefresh) {
return inFlightDiscovery
}
try {
await inFlightDiscovery
} catch {
// Why: an explicit re-check should still read current disk state even if
// the older background scan failed.
}
if (pendingDiscovery && pendingDiscovery !== inFlightDiscovery) {
return pendingDiscovery
}
}
return startInstalledAgentSkillDiscovery(force)
}
export const _installedAgentSkillDiscoveryInternalsForTests = {
discoverInstalledAgentSkills,
reset(): void {
cachedDiscovery = null
pendingDiscovery = null
pendingDiscoverySatisfiesForcedRefresh = false
}
}
export function useInstalledAgentSkill(
skillName: string,
options: InstalledAgentSkillOptions = {}
): {
installed: boolean
loading: boolean
error: string | null
refresh: () => Promise<void>
} {
const { enabled = true, sourceKinds } = options
const [result, setResult] = useState<SkillDiscoveryResult | null>(cachedDiscovery)
const [loading, setLoading] = useState(enabled && !cachedDiscovery)
const [error, setError] = useState<string | null>(null)
const refresh = useCallback(
async (force = true): Promise<void> => {
if (!enabled) {
setLoading(false)
return
}
setLoading(true)
try {
const next = await discoverInstalledAgentSkills(force)
setResult(next)
setError(null)
} catch (refreshError) {
setError(
refreshError instanceof Error ? refreshError.message : 'Could not scan installed skills.'
)
} finally {
setLoading(false)
}
},
[enabled]
)
useEffect(() => {
void refresh(false)
}, [refresh])
useEffect(() => {
if (!enabled) {
return
}
const refreshFromExternalChange = (): void => {
void refresh(true)
}
// Why: skill install commands run outside React state, often in a terminal.
// Refresh on focus and explicit install events so completion is detected.
window.addEventListener('focus', refreshFromExternalChange)
window.addEventListener(INSTALLED_AGENT_SKILLS_CHANGED_EVENT, refreshFromExternalChange)
return () => {
window.removeEventListener('focus', refreshFromExternalChange)
window.removeEventListener(INSTALLED_AGENT_SKILLS_CHANGED_EVENT, refreshFromExternalChange)
}
}, [enabled, refresh])
const installed = useMemo(
() =>
enabled && result ? hasInstalledAgentSkill(result.skills, skillName, { sourceKinds }) : false,
[enabled, result, skillName, sourceKinds]
)
const forceRefresh = useCallback(() => refresh(true), [refresh])
return {
installed,
loading,
error,
refresh: forceRefresh
}
}
@@ -1,2 +1 @@
export const BROWSER_USE_ENABLED_STORAGE_KEY = 'orca.browserUse.enabled'
export const BROWSER_USE_SKILL_INSTALLED_STORAGE_KEY = 'orca.browserUse.skillInstalled'
@@ -1,18 +1,13 @@
export const ORCHESTRATION_SETUP_STATE_EVENT = 'orca:orchestration-setup-state'
export const ORCHESTRATION_ENABLED_STORAGE_KEY = 'orca.orchestration.enabled'
export const ORCHESTRATION_SKILL_INSTALLED_STORAGE_KEY = 'orca.orchestration.skillInstalled'
export const ORCHESTRATION_SETUP_DISMISSED_STORAGE_KEY = 'orca.orchestration.setupDismissed'
export function isOrchestrationSetupEnabled(): boolean {
return localStorage.getItem(ORCHESTRATION_ENABLED_STORAGE_KEY) === '1'
}
export function isOrchestrationSkillMarkedInstalled(): boolean {
return localStorage.getItem(ORCHESTRATION_SKILL_INSTALLED_STORAGE_KEY) === '1'
}
export function hasOrchestrationSetupMarker(): boolean {
return isOrchestrationSetupEnabled() || isOrchestrationSkillMarkedInstalled()
return isOrchestrationSetupEnabled()
}
export function isOrchestrationSetupDismissed(): boolean {
+120
View File
@@ -0,0 +1,120 @@
import type { ElectronApplication, Page } from '@stablyai/playwright-test'
import { test, expect } from './helpers/orca-app'
import { waitForSessionReady } from './helpers/store'
import type {
DiscoveredSkill,
SkillDiscoveryResult,
SkillSourceKind
} from '../../src/shared/skills'
import { ORCHESTRATION_ENABLED_STORAGE_KEY } from '../../src/renderer/src/lib/orchestration-setup-state'
type MockSkillDiscoveryGlobal = typeof globalThis & {
__orcaSettingsSkillDiscoveryResult?: SkillDiscoveryResult
}
function makeSkill(sourceKind: SkillSourceKind, directoryPath: string): DiscoveredSkill {
return {
id: `${sourceKind}-orca-cli`,
name: 'orchestration',
description: null,
providers: ['agent-skills'],
sourceKind,
sourceLabel: sourceKind,
rootPath: directoryPath.replace(/[\\/]orchestration$/, ''),
directoryPath,
skillFilePath: `${directoryPath}/SKILL.md`,
installed: true,
fileCount: 1,
updatedAt: null
}
}
function discoveryResult(skills: DiscoveredSkill[]): SkillDiscoveryResult {
return {
skills,
sources: [],
scannedAt: Date.now()
}
}
async function installMockSkillDiscovery(
app: ElectronApplication,
result: SkillDiscoveryResult
): Promise<void> {
await app.evaluate((electron, initialResult) => {
const global = globalThis as MockSkillDiscoveryGlobal
global.__orcaSettingsSkillDiscoveryResult = initialResult
electron.ipcMain.removeHandler('skills:discover')
electron.ipcMain.handle('skills:discover', () => {
const latest = (globalThis as MockSkillDiscoveryGlobal).__orcaSettingsSkillDiscoveryResult
if (!latest) {
throw new Error('Missing mocked skill discovery result')
}
return latest
})
}, result)
}
async function setMockSkillDiscovery(
app: ElectronApplication,
result: SkillDiscoveryResult
): Promise<void> {
await app.evaluate((_, nextResult) => {
;(globalThis as MockSkillDiscoveryGlobal).__orcaSettingsSkillDiscoveryResult = nextResult
}, result)
}
async function openOrchestrationSettings(page: Page): Promise<void> {
await page.evaluate(
({ enabledKey }) => {
localStorage.removeItem(enabledKey)
const state = window.__store!.getState()
state.setSettingsSearchQuery('orchestration')
state.openSettingsPage()
},
{
enabledKey: ORCHESTRATION_ENABLED_STORAGE_KEY
}
)
await expect(page.getByPlaceholder('Search settings')).toBeVisible({ timeout: 10_000 })
await expect(
page
.locator('[data-settings-section="orchestration"]')
.getByRole('heading', { name: 'Orchestration', exact: true })
).toBeInViewport({ timeout: 10_000 })
}
test.describe('Settings skill detection', () => {
test.beforeEach(async ({ orcaPage }) => {
await waitForSessionReady(orcaPage)
})
test('shows installed only for global orchestration skill installs', async ({
electronApp,
orcaPage
}) => {
await installMockSkillDiscovery(
electronApp,
discoveryResult([
makeSkill('repo', '/workspace/.agents/skills/orchestration'),
makeSkill('plugin', '/Users/test/.codex/plugins/cache/vendor/orchestration')
])
)
await openOrchestrationSettings(orcaPage)
const section = orcaPage.locator('[data-settings-section="orchestration"]')
await section.getByRole('switch').click()
await expect(section.getByText('Not installed', { exact: true })).toBeVisible()
await expect(section.getByText('Agents need this skill', { exact: false })).toBeVisible()
await setMockSkillDiscovery(
electronApp,
discoveryResult([makeSkill('home', '/Users/test/.agents/skills/orchestration')])
)
await section.getByRole('button', { name: 'Re-check' }).click()
await expect(section.getByText('Installed', { exact: true })).toBeVisible()
await expect(section.getByText('Detected on this machine', { exact: false })).toBeVisible()
})
})