diff --git a/desktop/src/api/composerReferences.test.ts b/desktop/src/api/composerReferences.test.ts index 41473717..ff09048f 100644 --- a/desktop/src/api/composerReferences.test.ts +++ b/desktop/src/api/composerReferences.test.ts @@ -1,8 +1,20 @@ import { expect, it, vi } from 'vitest' import { api } from '@/api/client' -import { composerReferencesApi } from './composerReferences' +import { composerReferencesApi, mentionProviderId } from './composerReferences' vi.mock('@/api/client', () => ({ api: { get: vi.fn() } })) it('requests available capabilities for the exact workspace without credentials or catalog installation', () => { composerReferencesApi.list('/repo/中文 folder') expect(api.get).toHaveBeenCalledWith('/api/skills/mentions?cwd=%2Frepo%2F%E4%B8%AD%E6%96%87%20folder') }) + +it('names the provider the session runs with so provider-backed skills match it', () => { + composerReferencesApi.list('/repo', 'grok-official') + expect(api.get).toHaveBeenLastCalledWith('/api/skills/mentions?cwd=%2Frepo&providerId=grok-official') + expect(mentionProviderId({ providerId: 'grok-official', modelId: 'grok-4.7' })).toBe('grok-official') + // A null provider is the Claude subscription, which offers no image provider. + expect(mentionProviderId({ providerId: null, modelId: 'opus' })).toBe('claude-official') + // No selection inherits the active provider; the server resolves it. + expect(mentionProviderId(undefined)).toBeUndefined() + composerReferencesApi.list(undefined, undefined) + expect(api.get).toHaveBeenLastCalledWith('/api/skills/mentions') +}) diff --git a/desktop/src/api/composerReferences.ts b/desktop/src/api/composerReferences.ts index a3d375ba..8a9cdd5a 100644 --- a/desktop/src/api/composerReferences.ts +++ b/desktop/src/api/composerReferences.ts @@ -1,9 +1,26 @@ import { api } from './client' +import { CLAUDE_OFFICIAL_PROVIDER_ID } from '@/constants/openaiOfficialProvider' import type { ComposerReferenceCandidate } from '@/types/composerReference' +import type { RuntimeSelection } from '@/types/runtime' + +/** + * Provider-backed skills (imagegen) exist only when the session's provider + * offers them, so the server needs the provider the session will run with. + * A `null` selection is the Claude subscription; no selection inherits the + * active provider, which the server resolves itself. + */ +export function mentionProviderId(selection: RuntimeSelection | undefined): string | undefined { + if (!selection) return undefined + return selection.providerId ?? CLAUDE_OFFICIAL_PROVIDER_ID +} export const composerReferencesApi = { - list(cwd?: string) { - const query = cwd ? `?cwd=${encodeURIComponent(cwd)}` : '' + list(cwd?: string, providerId?: string) { + const params = [ + cwd ? `cwd=${encodeURIComponent(cwd)}` : '', + providerId ? `providerId=${encodeURIComponent(providerId)}` : '', + ].filter(Boolean) + const query = params.length ? `?${params.join('&')}` : '' return api.get<{ skills: ComposerReferenceCandidate[], plugins: ComposerReferenceCandidate[] }>(`/api/skills/mentions${query}`) }, } diff --git a/desktop/src/components/chat/ChatInput.test.tsx b/desktop/src/components/chat/ChatInput.test.tsx index bbfa9ae9..24fed648 100644 --- a/desktop/src/components/chat/ChatInput.test.tsx +++ b/desktop/src/components/chat/ChatInput.test.tsx @@ -64,7 +64,8 @@ vi.mock('../../api/sessions', () => ({ vi.mock('../../api/sessionCollaboration', () => ({ sessionCollaborationApi: { list: mocks.listSessionReferences } })) -vi.mock('../../api/composerReferences', () => ({ +vi.mock('../../api/composerReferences', async (importOriginal) => ({ + ...await importOriginal(), composerReferencesApi: { list: mocks.listReferences }, })) @@ -149,6 +150,7 @@ import { getComposerElement, getComposerText, getComposerView, setComposerSelect import { useVoiceInputStore } from '../../stores/voiceInputStore' import { useChatStore } from '../../stores/chatStore' import { useSessionStore } from '../../stores/sessionStore' +import { useSessionRuntimeStore } from '../../stores/sessionRuntimeStore' import { useSettingsStore } from '../../stores/settingsStore' import { useTabStore } from '../../stores/tabStore' import { useWorkspaceChatContextStore } from '../../stores/workspaceChatContextStore' @@ -751,7 +753,7 @@ describe('ChatInput file mentions', () => { expect(parentEditor.textContent).toBe('parent draft') expect(mocks.wsSend).not.toHaveBeenCalledWith(sessionId, expect.objectContaining({ type: 'user_message' })) expect(container.querySelector('[data-session-id="side-scoped"]')).toBeInTheDocument() - expect(mocks.listReferences).toHaveBeenCalledWith('/child') + expect(mocks.listReferences).toHaveBeenCalledWith('/child', undefined) }) it('accepts asynchronous image paste in an explicit child while the global tab stays on its parent', async () => { @@ -2012,6 +2014,28 @@ describe('ChatInput file mentions', () => { expect(useChatStore.getState().sessions[sessionId]?.queuedUserMessages?.[0]).toMatchObject({ sessionReferences: [{ sessionId: 'prior' }] }) }) + it('asks for @ candidates with the session provider and refetches when the provider changes', async () => { + const imagegen = { + kind: 'skill' as const, id: 'imagegen', name: 'imagegen', displayName: 'imagegen', + description: 'Generate images', source: 'bundled', modelText: 'Use the Skill tool with skill: "imagegen" for this request.', + } + mocks.listReferences.mockImplementation(async (_cwd?: string, providerId?: string) => ({ + plugins: [], skills: providerId === 'grok-official' ? [imagegen] : [], + })) + act(() => useSessionRuntimeStore.getState().setSelection(sessionId, { providerId: null, modelId: 'opus' })) + try { + render() + await waitFor(() => expect(mocks.listReferences).toHaveBeenLastCalledWith(expect.anything(), 'claude-official')) + act(() => useSessionRuntimeStore.getState().setSelection(sessionId, { providerId: 'grok-official', modelId: 'grok-4.7' })) + await waitFor(() => expect(mocks.listReferences).toHaveBeenLastCalledWith(expect.anything(), 'grok-official')) + setComposerText('@imagegen', 9) + fireEvent.click(await screen.findByRole('option', { name: /imagegen/i })) + await waitFor(() => expect(document.querySelector('[data-mention-kind="skill"]')).toBeInTheDocument()) + } finally { + act(() => useSessionRuntimeStore.getState().clearSelection(sessionId)) + } + }) + it('selects an exact slash skill on Enter without executing and preserves its canonical identity', async () => { mocks.listReferences.mockResolvedValue({ plugins: [], skills: [{ kind: 'skill', id: 'skill:team:review', name: 'team:review', displayName: 'Review', diff --git a/desktop/src/components/chat/ChatInput.tsx b/desktop/src/components/chat/ChatInput.tsx index 34810d9b..e63572d9 100644 --- a/desktop/src/components/chat/ChatInput.tsx +++ b/desktop/src/components/chat/ChatInput.tsx @@ -38,7 +38,7 @@ import { ComposerReferenceMenu, type ComposerReferenceMenuHandle } from './Compo import { ComposerReferenceDetail } from './ComposerReferenceDetail' import { ComposerCapabilityMenu } from './ComposerCapabilityMenu' import { useCapabilityMenu } from './useCapabilityMenu' -import { composerReferencesApi } from '@/api/composerReferences' +import { composerReferencesApi, mentionProviderId } from '@/api/composerReferences' import type { ComposerReferenceCandidate } from '@/types/composerReference' import { LocalSlashCommandPanel, type LocalSlashCommandName } from './LocalSlashCommandPanel' import { getSlashCommandOptionId, SlashCommandMenu } from './SlashCommandMenu' @@ -336,20 +336,21 @@ export function ChatInput({ variant = 'default', compact = false, sessionId, vis const useCompactControls = isMobileComposer || !fitsAtLeast(TOOLBAR_LOCATION_MIN_WIDTH) const activeLaunchWorkDir = showLaunchControls ? (launchWorkDir || resolvedWorkDir || '') : (resolvedWorkDir || '') const referenceCwd = activeLaunchWorkDir || resolvedWorkDir || '' - const referenceContext = `${activeTabId ?? ''}\0${referenceCwd}` + const referenceProviderId = mentionProviderId(runtimeSelection) + const referenceContext = `${activeTabId ?? ''}\0${referenceCwd}\0${referenceProviderId ?? ''}` const referenceCurrent = referenceState?.context === referenceContext ? referenceState : null const composerReferences = useMemo(() => (referenceCurrent?.items ?? EMPTY_COMPOSER_REFERENCES).filter(isComposerReferenceVisible), [referenceCurrent?.items]) useEffect(() => { let active = true if (isMemberSession) return setReferenceState({ context: referenceContext, items: [], loading: true, error: false }) - void composerReferencesApi.list(referenceCwd || undefined).then(data => { + void composerReferencesApi.list(referenceCwd || undefined, referenceProviderId).then(data => { if (active) setReferenceState({ context: referenceContext, items: [...data.plugins, ...data.skills], loading: false, error: false }) }).catch(() => { if (active) setReferenceState({ context: referenceContext, items: [], loading: false, error: true }) }) return () => { active = false } - }, [referenceContext, referenceCwd, isMemberSession, slashMenuOpen, fileSearchOpen, plusMenuOpen]) + }, [referenceContext, referenceCwd, referenceProviderId, isMemberSession, slashMenuOpen, fileSearchOpen, plusMenuOpen]) useEffect(() => { setReferenceDetail(null) setReferenceOptionId(undefined) diff --git a/desktop/src/pages/EmptySession.test.tsx b/desktop/src/pages/EmptySession.test.tsx index 51ac1fc6..a87cc8c0 100644 --- a/desktop/src/pages/EmptySession.test.tsx +++ b/desktop/src/pages/EmptySession.test.tsx @@ -74,7 +74,8 @@ vi.mock('../api/skills', () => ({ }, })) -vi.mock('../api/composerReferences', () => ({ +vi.mock('../api/composerReferences', async (importOriginal) => ({ + ...await importOriginal(), composerReferencesApi: { list: mocks.listReferences }, })) diff --git a/desktop/src/pages/EmptySession.tsx b/desktop/src/pages/EmptySession.tsx index f15595bf..1e519e43 100644 --- a/desktop/src/pages/EmptySession.tsx +++ b/desktop/src/pages/EmptySession.tsx @@ -26,7 +26,7 @@ import { ComposerDropOverlay } from '../components/chat/ComposerDropOverlay' import { ContextUsageIndicator } from '../components/chat/ContextUsageIndicator' import { ComposerReferenceMenu, type ComposerReferenceMenuHandle } from '@/components/chat/ComposerReferenceMenu' import { ComposerReferenceDetail } from '@/components/chat/ComposerReferenceDetail' -import { composerReferencesApi } from '@/api/composerReferences' +import { composerReferencesApi, mentionProviderId } from '@/api/composerReferences' import type { ComposerReferenceCandidate } from '@/types/composerReference' import { LocalSlashCommandPanel, type LocalSlashCommandName } from '../components/chat/LocalSlashCommandPanel' import { @@ -123,7 +123,7 @@ export function EmptySession() { const [mentions, setMentions] = useState([]) const [referenceDetail, setReferenceDetail] = useState(null) const [referenceOptionId, setReferenceOptionId] = useState() - const [referenceState, setReferenceState] = useState<{ cwd: string, items: ComposerReferenceCandidate[], loading: boolean, error: boolean } | null>(null) + const [referenceState, setReferenceState] = useState<{ context: string, items: ComposerReferenceCandidate[], loading: boolean, error: boolean } | null>(null) const [isSubmitting, setIsSubmitting] = useState(false) const [workDir, setWorkDir] = useState('') const [selectedBranch, setSelectedBranch] = useState(null) @@ -167,18 +167,21 @@ export function EmptySession() { const activeProviderId = useProviderStore((state) => state.activeId) const [draftPermissionMode, setDraftPermissionMode] = useState(defaultPermissionMode) const lastPluginReloadSummary = usePluginStore((state) => state.lastReloadSummary) - const referenceCurrent = referenceState?.cwd === workDir ? referenceState : null + const draftRuntimeSelection = useSessionRuntimeStore((state) => state.selections[DRAFT_RUNTIME_SELECTION_KEY]) + const referenceProviderId = mentionProviderId(draftRuntimeSelection) + const referenceContext = `${workDir}\0${referenceProviderId ?? ''}` + const referenceCurrent = referenceState?.context === referenceContext ? referenceState : null const composerReferences = useMemo(() => (referenceCurrent?.items ?? EMPTY_COMPOSER_REFERENCES).filter(isComposerReferenceVisible), [referenceCurrent?.items]) useEffect(() => { let active = true - setReferenceState({ cwd: workDir, items: [], loading: true, error: false }) - void composerReferencesApi.list(workDir || undefined).then(data => { - if (active) setReferenceState({ cwd: workDir, items: [...data.plugins, ...data.skills], loading: false, error: false }) + setReferenceState({ context: referenceContext, items: [], loading: true, error: false }) + void composerReferencesApi.list(workDir || undefined, referenceProviderId).then(data => { + if (active) setReferenceState({ context: referenceContext, items: [...data.plugins, ...data.skills], loading: false, error: false }) }).catch(() => { - if (active) setReferenceState({ cwd: workDir, items: [], loading: false, error: true }) + if (active) setReferenceState({ context: referenceContext, items: [], loading: false, error: true }) }) return () => { active = false } - }, [workDir, lastPluginReloadSummary, slashMenuOpen, fileSearchOpen, plusMenuOpen]) + }, [referenceContext, workDir, referenceProviderId, lastPluginReloadSummary, slashMenuOpen, fileSearchOpen, plusMenuOpen]) useEffect(() => { setReferenceDetail(null) setReferenceOptionId(undefined) @@ -186,7 +189,6 @@ export function EmptySession() { setSlashMenuOpen(false) }, [workDir]) - const draftRuntimeSelection = useSessionRuntimeStore((state) => state.selections[DRAFT_RUNTIME_SELECTION_KEY]) const draftRuntimeSelectionKey = draftRuntimeSelection ? `${draftRuntimeSelection.providerId ?? 'official'}:${draftRuntimeSelection.modelId}:${draftRuntimeSelection.effortLevel ?? 'auto'}` : undefined diff --git a/src/commands.ts b/src/commands.ts index 62614abe..fb03668d 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -158,7 +158,7 @@ import { getDynamicSkills, } from './skills/loadSkillsDir.js' import { getBundledSkills } from './skills/bundledSkills.js' -import { initBundledSkills } from './skills/bundled/index.js' +import { getInitializedBundledSkills } from './skills/bundled/index.js' import { getBuiltinPluginSkillCommands } from './plugins/builtinPlugins.js' import { getPluginCommands, @@ -367,11 +367,8 @@ export const builtInCommandNames = memoize( */ export function getCompiledInCommands(): Command[] { // The CLI registers these during startup; the server process never runs that - // path. Registration appends, so only initialize an empty registry. - if (getBundledSkills().length === 0) { - initBundledSkills() - } - const commands: Command[] = [...getBundledSkills()] + // path. + const commands: Command[] = [...getInitializedBundledSkills()] try { commands.push(...COMMANDS()) diff --git a/src/server/__tests__/skills.test.ts b/src/server/__tests__/skills.test.ts index 9a2f8501..ae9db97f 100644 --- a/src/server/__tests__/skills.test.ts +++ b/src/server/__tests__/skills.test.ts @@ -148,6 +148,60 @@ describe('Skills API', () => { expect(body.plugins.some((item: { source: string }) => item.source.endsWith('@haha-connectors'))).toBe(false) }) + it('offers bundled imagegen to @ mentions only when the session provider generates images', async () => { + // The image provider is injected into each session's CLI, never into the + // server. Nothing here may leak in from the developer's environment. + const imageEnvKeys = Object.keys(process.env).filter(key => key.startsWith('CC_HAHA_IMAGE_')) + const savedImageEnv = Object.fromEntries(imageEnvKeys.map(key => [key, process.env[key]])) + for (const key of imageEnvKeys) delete process.env[key] + const models = { main: 'fixture-main', haiku: 'fixture-haiku', sonnet: 'fixture-sonnet', opus: 'fixture-opus' } + const provider = (id: string, extra: Record = {}) => ({ + id, presetId: 'custom', name: id, apiKey: 'fake-key', baseUrl: 'http://127.0.0.1:9/v1', models, ...extra, + }) + await fs.mkdir(path.join(tmpHome, '.claude', 'cc-haha'), { recursive: true }) + const writeProviders = (activeId: string | null) => fs.writeFile( + path.join(tmpHome, '.claude', 'cc-haha', 'providers.json'), + JSON.stringify({ + schemaVersion: 2, + activeId, + providers: [ + provider('text-only'), + provider('with-images', { imageGeneration: { model: 'fixture-image-model' } }), + ], + }), + ) + const mentionNames = async (query = '') => { + const { req, url, segments } = makeRequest(`/api/skills/mentions?cwd=${encodeURIComponent(tmpHome)}${query}`) + const response = await handleSkillsApi(req, url, segments) + expect(response.status).toBe(200) + const body = await response.json() as { skills: Array<{ name: string, source: string, modelText: string }> } + return body.skills + } + const hasImagegen = async (query = '') => (await mentionNames(query)).some(skill => skill.name === 'imagegen') + + try { + await writeProviders('text-only') + expect(await hasImagegen('&providerId=grok-official')).toBe(true) + expect(await hasImagegen('&providerId=openai-official')).toBe(true) + expect(await hasImagegen('&providerId=with-images')).toBe(true) + expect(await hasImagegen('&providerId=text-only')).toBe(false) + expect(await hasImagegen('&providerId=claude-official')).toBe(false) + expect(await hasImagegen('&providerId=deleted-provider')).toBe(false) + // No selection inherits the active provider, as the session launch does. + expect(await hasImagegen()).toBe(false) + await writeProviders('grok-official') + expect(await hasImagegen()).toBe(true) + + const imagegen = (await mentionNames('&providerId=grok-official')).find(skill => skill.name === 'imagegen')! + expect(imagegen.source).toBe('bundled') + expect(imagegen.modelText).toBe('Use the Skill tool with skill: "imagegen" for this request.') + // The provider decides availability without touching the server's env. + expect(Object.keys(process.env).some(key => key.startsWith('CC_HAHA_IMAGE_'))).toBe(false) + } finally { + Object.assign(process.env, savedImageEnv) + } + }) + it('lists user and project skills for the requested cwd', async () => { const userSkillsRoot = path.join(tmpHome, '.claude', 'skills') const projectRoot = path.join(tmpHome, 'workspace') diff --git a/src/server/api/skills.ts b/src/server/api/skills.ts index 8384ee9b..63c38e84 100644 --- a/src/server/api/skills.ts +++ b/src/server/api/skills.ts @@ -4,6 +4,7 @@ * GET /api/skills — List all installed skills (metadata only) * GET /api/skills/detail — Full skill data (tree + files) * ?source=user&name=xxx + * GET /api/skills/mentions — Composer @ candidates (?cwd=&providerId=) */ import * as path from 'path' @@ -555,7 +556,9 @@ export async function handleSkillsApi( return await listSkills(url) case 'mentions': { const { listCapabilityMentions } = await import('../services/capabilityMentionService.js') - return Response.json(await listCapabilityMentions(url.searchParams.get('cwd') || getCwd())) + return Response.json(await listCapabilityMentions(url.searchParams.get('cwd') || getCwd(), { + providerId: url.searchParams.get('providerId') || undefined, + })) } case 'detail': return await getSkillDetail(url) diff --git a/src/server/services/capabilityMentionService.test.ts b/src/server/services/capabilityMentionService.test.ts index 1db87f43..e0444b1d 100644 --- a/src/server/services/capabilityMentionService.test.ts +++ b/src/server/services/capabilityMentionService.test.ts @@ -74,3 +74,15 @@ describe('composer capability mentions', () => { expect(explicitLabel.skills[0]?.displayName).toBe('Animation workflow') expect(explicitLabel.plugins[0]?.description).toBe(owner.manifest.description!) }) + +test('bundled skills are offered, gated by the caller-supplied availability, and shadow same-named disk skills', () => { + const bundled = skill('imagegen', { source: 'bundled', loadedFrom: 'bundled', description: 'Built-in image skill', isEnabled: () => false }) + const disk = skill('imagegen', { description: 'Personal skill with the same name' }) + const offered = buildCapabilityMentions({ enabledPlugins: [], commands: [bundled, disk], isEnabled: command => command.source === 'bundled' || command.isEnabled?.() !== false }) + expect(offered.skills).toHaveLength(1) + expect(offered.skills[0]).toMatchObject({ name: 'imagegen', source: 'bundled', description: 'Built-in image skill' }) + // Unavailable for this session: the disk skill the CLI would fall back to is offered instead. + const unavailable = buildCapabilityMentions({ enabledPlugins: [], commands: [bundled, disk] }) + expect(unavailable.skills).toHaveLength(1) + expect(unavailable.skills[0]).toMatchObject({ name: 'imagegen', source: 'userSettings' }) +}) diff --git a/src/server/services/capabilityMentionService.ts b/src/server/services/capabilityMentionService.ts index 24f73deb..e2bd091b 100644 --- a/src/server/services/capabilityMentionService.ts +++ b/src/server/services/capabilityMentionService.ts @@ -8,6 +8,8 @@ export type CapabilityMentionSnapshot = { enabledPlugins: LoadedPlugin[] failedPluginIds?: string[] brands?: Brand[] + /** Defaults to the command's own `isEnabled`, which reads this process's state. */ + isEnabled?: (command: Command) => boolean } /** Serialize only exact invocation identities, never third-party description text. */ @@ -19,10 +21,11 @@ export function buildCapabilityMentions(snapshot: CapabilityMentionSnapshot): Ca const brand = brands.get(source) return brand && /^[a-z][a-z0-9-]*$/.test(brand.id) ? `/connectors/${brand.id}.svg` : undefined } + const isEnabled = snapshot.isEnabled ?? ((command: Command) => command.isEnabled?.() !== false) const skills: CapabilityMentionCandidate[] = [] const names = new Set() for (const command of snapshot.commands) { - if (command.type !== 'prompt' || command.disableModelInvocation || command.disableNonInteractive || command.userInvocable === false || command.isHidden || command.isEnabled?.() === false || command.source === 'builtin') continue + if (command.type !== 'prompt' || command.disableModelInvocation || command.disableNonInteractive || command.userInvocable === false || command.isHidden || !isEnabled(command) || command.source === 'builtin') continue const pluginId = command.source === 'plugin' ? command.pluginInfo?.repository : undefined if (command.source === 'plugin' && (!pluginId || !enabled.has(pluginId))) continue // Match SkillTool discovery, including described plugin prompt commands. @@ -55,12 +58,35 @@ export function buildCapabilityMentions(snapshot: CapabilityMentionSnapshot): Ca return { skills: skills.sort(sort), plugins: plugins.sort(sort) } } +/** + * Env of the CLI a session with this runtime selection would run. + * + * `providerId` follows the session runtime contract: an id selects that + * provider, `claude-official` is the Claude subscription, and omitting it + * inherits the active provider. Unknown or unreadable providers yield no env, + * which hides provider-backed skills rather than offering one that would fail. + */ +export async function resolveMentionRuntimeEnv(providerId?: string): Promise> { + const [{ ProviderService }, { CLAUDE_OFFICIAL_PROVIDER_ID }] = await Promise.all([ + import('./providerService.js'), import('../types/provider.js'), + ]) + const providerService = new ProviderService() + try { + const id = providerId ?? (await providerService.listProviders()).activeId + if (!id || id === CLAUDE_OFFICIAL_PROVIDER_ID) return {} + return await providerService.getProviderRuntimeEnv(id) + } catch { + return {} + } +} + /** Cache-only local discovery: never installs packages, connects MCP, or logs in. */ -export async function listCapabilityMentions(cwd: string): Promise { - const [local, plugins, { loadInstalledPluginsForProject }, { loadPluginMcpServers }, { ALL_CONNECTORS }, { resetSettingsCache }, { clearInstalledPluginsCache }] = await Promise.all([ +export async function listCapabilityMentions(cwd: string, options: { providerId?: string } = {}): Promise { + const [local, plugins, { loadInstalledPluginsForProject }, { loadPluginMcpServers }, { ALL_CONNECTORS }, { resetSettingsCache }, { clearInstalledPluginsCache }, { getInitializedBundledSkills }, { IMAGEGEN_SKILL_NAME, isImagegenAvailable }, runtimeEnv] = await Promise.all([ import('../../skills/loadSkillsDir.js'), import('../../utils/plugins/loadPluginCommands.js'), import('../../utils/plugins/pluginLoader.js'), import('../../utils/plugins/mcpPluginIntegration.js'), import('../../services/connectors/catalog.js'), import('../../utils/settings/settingsCache.js'), import('../../utils/plugins/installedPluginsManager.js'), + import('../../skills/bundled/index.js'), import('../../skills/bundled/imagegen.js'), resolveMentionRuntimeEnv(options.providerId), ]) // Reuse the runtime's installed-skill loaders, without importing built-in // login commands (which require provider credentials even for discovery). @@ -73,7 +99,8 @@ export async function listCapabilityMentions(cwd: string): Promise { const specs = Array.isArray(plugin.manifest.mcpServers) ? plugin.manifest.mcpServers : [plugin.manifest.mcpServers] // Unexpanded MCPB bundles may require preparation; mentioning must not unpack/install them. @@ -81,5 +108,10 @@ export async function listCapabilityMentions(cwd: string): Promise error.source), brands: ALL_CONNECTORS }) + // imagegen's provider is injected into each session's CLI, never into this + // server, so its own isEnabled() would hide it for every provider. + const isEnabled = (command: Command) => command.source === 'bundled' && command.name === IMAGEGEN_SKILL_NAME + ? isImagegenAvailable(runtimeEnv) + : command.isEnabled?.() !== false + return buildCapabilityMentions({ commands, enabledPlugins, failedPluginIds: state.errors.map(error => error.source), brands: ALL_CONNECTORS, isEnabled }) } diff --git a/src/skills/bundled/imagegen.ts b/src/skills/bundled/imagegen.ts index e7639886..c14d0def 100644 --- a/src/skills/bundled/imagegen.ts +++ b/src/skills/bundled/imagegen.ts @@ -10,13 +10,26 @@ const DESCRIPTION = ? frontmatter.description : 'Generate images with the desktop image provider.' +export const IMAGEGEN_SKILL_NAME = 'imagegen' + +/** + * The image provider reaches the CLI as env the desktop injects per session, so + * whether this skill exists depends on whose env is asked. Callers outside the + * session's CLI (the desktop server) must pass that session's env explicitly. + */ +export function isImagegenAvailable( + env: NodeJS.ProcessEnv = process.env, +): boolean { + return getImageGenerationRuntimeConfig(env) !== null +} + export function registerImagegenSkill(): void { registerBundledSkill({ - name: 'imagegen', + name: IMAGEGEN_SKILL_NAME, description: DESCRIPTION, allowedTools: ['ImageGen', 'ImageEdit'], userInvocable: true, - isEnabled: () => getImageGenerationRuntimeConfig() !== null, + isEnabled: () => isImagegenAvailable(), async getPromptForCommand(args) { const parts = [SKILL_BODY.trimStart()] if (args) parts.push(`## User request\n\n${args}`) diff --git a/src/skills/bundled/index.ts b/src/skills/bundled/index.ts index 68c2e709..b4945717 100644 --- a/src/skills/bundled/index.ts +++ b/src/skills/bundled/index.ts @@ -1,5 +1,7 @@ import { feature } from 'bun:bundle' +import type { Command } from '../../types/command.js' import { shouldAutoEnableClaudeInChrome } from 'src/utils/claudeInChrome/setup.js' +import { getBundledSkills } from '../bundledSkills.js' /** * Initialize all bundled skills. @@ -58,3 +60,14 @@ export function initBundledSkills(): void { } /* eslint-enable @typescript-eslint/no-require-imports */ } + +/** + * Bundled skills for a process that never ran CLI startup (the desktop server). + * Registration appends, so only an empty registry is initialized. + */ +export function getInitializedBundledSkills(): Command[] { + if (getBundledSkills().length === 0) { + initBundledSkills() + } + return getBundledSkills() +}