mirror of
https://github.com/NanmiCoder/claude-code-haha.git
synced 2026-10-10 20:03:13 +08:00
fix(skills): offer bundled skills in composer @ mentions
The @ mention list only loaded disk and plugin skills, so bundled skills such as imagegen never appeared. imagegen is also gated on image provider env that is injected into each session's CLI rather than the server, so the mentions endpoint now accepts the session's providerId and evaluates imagegen against that provider's runtime env.
This commit is contained in:
@@ -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')
|
||||
})
|
||||
|
||||
@@ -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}`)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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<typeof import('../../api/composerReferences')>(),
|
||||
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(<ChatInput compact />)
|
||||
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',
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -74,7 +74,8 @@ vi.mock('../api/skills', () => ({
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../api/composerReferences', () => ({
|
||||
vi.mock('../api/composerReferences', async (importOriginal) => ({
|
||||
...await importOriginal<typeof import('../api/composerReferences')>(),
|
||||
composerReferencesApi: { list: mocks.listReferences },
|
||||
}))
|
||||
|
||||
|
||||
@@ -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<ComposerMention[]>([])
|
||||
const [referenceDetail, setReferenceDetail] = useState<ComposerMention | null>(null)
|
||||
const [referenceOptionId, setReferenceOptionId] = useState<string | undefined>()
|
||||
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<string | null>(null)
|
||||
@@ -167,18 +167,21 @@ export function EmptySession() {
|
||||
const activeProviderId = useProviderStore((state) => state.activeId)
|
||||
const [draftPermissionMode, setDraftPermissionMode] = useState<PermissionMode>(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
|
||||
|
||||
+3
-6
@@ -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())
|
||||
|
||||
@@ -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<string, unknown> = {}) => ({
|
||||
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')
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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' })
|
||||
})
|
||||
|
||||
@@ -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<string>()
|
||||
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<Record<string, string>> {
|
||||
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<CapabilityMentionResponse> {
|
||||
const [local, plugins, { loadInstalledPluginsForProject }, { loadPluginMcpServers }, { ALL_CONNECTORS }, { resetSettingsCache }, { clearInstalledPluginsCache }] = await Promise.all([
|
||||
export async function listCapabilityMentions(cwd: string, options: { providerId?: string } = {}): Promise<CapabilityMentionResponse> {
|
||||
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<CapabilityMen
|
||||
const [pluginCommands, pluginSkills] = await Promise.all([
|
||||
plugins.loadPluginCommandsFromEnabledPlugins(state.enabled), plugins.loadPluginSkillsFromEnabledPlugins(state.enabled),
|
||||
])
|
||||
const commands = [...localCommands, ...pluginCommands, ...pluginSkills]
|
||||
// getCommands() order: an enabled bundled skill shadows a same-named disk skill.
|
||||
const commands = [...getInitializedBundledSkills(), ...localCommands, ...pluginCommands, ...pluginSkills]
|
||||
const enabledPlugins = await Promise.all(state.enabled.map(async plugin => {
|
||||
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<CapabilityMen
|
||||
try { return { ...plugin, mcpServers: plugin.mcpServers ?? await loadPluginMcpServers(plugin, state.errors) } }
|
||||
catch { return { ...plugin, mcpServers: undefined } }
|
||||
}))
|
||||
return buildCapabilityMentions({ commands, enabledPlugins, failedPluginIds: state.errors.map(error => 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 })
|
||||
}
|
||||
|
||||
@@ -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}`)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user