feat: add in-chat skill discovery and local registry install command

This commit is contained in:
William Valentin
2026-02-18 10:41:12 -08:00
parent f34a974210
commit 02fa604c7c
9 changed files with 267 additions and 4 deletions
+136 -1
View File
@@ -23,9 +23,11 @@ import type { RoutingPolicy } from '../routing/index.js';
import type { HookEngine } from '../hooks/index.js';
import { createClientFromConfig } from './models.js';
import { matchReactionPrompt } from '../automation/reactions.js';
import type { SkillRegistry } from '../skills/index.js';
import { loadSkillRegistryCatalog } from '../skills/index.js';
import type { SkillInstaller, SkillRegistry, SkillRegistryEntry, SkillRegistrySource } from '../skills/index.js';
import { auditLogger } from '../audit/index.js';
import { randomUUID } from 'crypto';
import { dirname, resolve } from 'path';
function buildProviderConfigMap(config: Config): Partial<Record<ModelProvider, ModelConfig>> {
const providerConfigs: Partial<Record<ModelProvider, ModelConfig>> = {};
@@ -98,6 +100,47 @@ function isTtsEnabledForChannel(config: Config, channel: string): boolean {
}
return enabledChannels.includes(channel);
}
function resolveRegistrySource(config: Config): { source?: SkillRegistrySource; error?: string } {
const raw = config.skills.registry_source?.trim() || process.env.FLYNN_SKILLS_REGISTRY_SOURCE?.trim();
if (!raw) {
return {
error: 'Skills registry is not configured. Set `skills.registry_source` (or FLYNN_SKILLS_REGISTRY_SOURCE).',
};
}
if (raw.startsWith('http://')) {
return { error: `Registry URL must use https:// (${raw})` };
}
if (raw.startsWith('https://')) {
return { source: { type: 'url', url: raw } };
}
return { source: { type: 'file', path: raw } };
}
function resolveRegistryEntryLocalPath(entry: SkillRegistryEntry, registrySource: SkillRegistrySource): string | null {
const source = entry.source.trim();
if (!source) {
return null;
}
if (source.startsWith('http://') || source.startsWith('https://') || source.startsWith('git+https://')) {
return null;
}
if (source.startsWith('file://')) {
try {
return decodeURIComponent(new URL(source).pathname);
} catch {
return null;
}
}
if (registrySource.type === 'file' && (source.startsWith('./') || source.startsWith('../'))) {
return resolve(dirname(resolve(registrySource.path)), source);
}
return resolve(source);
}
/**
* Create the unified message handler for the channel registry.
* Each channel+sender pair gets its own AgentOrchestrator backed by a persistent session.
@@ -121,6 +164,7 @@ export function createMessageRouter(deps: {
intentRegistry?: ComponentRegistry;
routingPolicy?: RoutingPolicy;
skillRegistry?: SkillRegistry;
skillInstaller?: SkillInstaller;
externalBackends?: Partial<Record<ExternalBackendName, ExternalBackend>>;
defaultName?: ExternalBackendName;
}): {
@@ -1018,6 +1062,97 @@ export function createMessageRouter(deps: {
? `Denied: ${selected.tool} (${selected.id}) — ${reason}`
: `Approval request is no longer pending: ${selected.id}`;
},
skillCommand: async (inputRaw: string) => {
const input = inputRaw.trim();
const [actionRaw, ...rest] = input.split(/\s+/).filter(Boolean);
const action = actionRaw?.toLowerCase();
if (!action || action === 'help') {
return [
'Usage: /skill <list|search|install>',
'/skill list',
'/skill search <term>',
'/skill install <registry-id>',
].join('\n');
}
if (action === 'list') {
const skills = deps.skillRegistry?.listAvailable() ?? [];
if (skills.length === 0) {
return 'No available skills are currently loaded.';
}
return [
`Available skills (${skills.length}):`,
...skills.map((skill) => `- ${skill.manifest.name} (${skill.manifest.tier}, v${skill.manifest.version})`),
].join('\n');
}
if (action === 'search') {
const term = rest.join(' ').trim().toLowerCase();
if (!term) {
return 'Usage: /skill search <term>';
}
const sourceResolved = resolveRegistrySource(deps.config);
if (!sourceResolved.source) {
return sourceResolved.error ?? 'Failed to resolve registry source.';
}
try {
const catalog = await loadSkillRegistryCatalog(sourceResolved.source);
const matches = catalog.skills.filter((entry) => {
const haystack = `${entry.id} ${entry.name} ${entry.summary} ${entry.publisher ?? ''}`.toLowerCase();
return haystack.includes(term);
}).slice(0, 12);
if (matches.length === 0) {
return `No registry skills found for "${term}".`;
}
return [
`Registry matches (${matches.length}):`,
...matches.map((entry) => `- ${entry.id} (${entry.version}) — ${entry.summary}`),
].join('\n');
} catch (error) {
return `Registry lookup failed: ${error instanceof Error ? error.message : String(error)}`;
}
}
if (action === 'install') {
const registryId = rest.join(' ').trim().toLowerCase();
if (!registryId) {
return 'Usage: /skill install <registry-id>';
}
if (!deps.skillInstaller || !deps.skillRegistry) {
return 'Skill installation is unavailable in this runtime.';
}
const sourceResolved = resolveRegistrySource(deps.config);
if (!sourceResolved.source) {
return sourceResolved.error ?? 'Failed to resolve registry source.';
}
try {
const catalog = await loadSkillRegistryCatalog(sourceResolved.source);
const entry = catalog.skills.find((item) => item.id.toLowerCase() === registryId);
if (!entry) {
return `Registry skill not found: ${registryId}`;
}
const localPath = resolveRegistryEntryLocalPath(entry, sourceResolved.source);
if (!localPath) {
return `Registry entry '${entry.id}' points to a remote source. Use CLI install for remote sources.`;
}
const installed = deps.skillInstaller.install(localPath);
if (!installed) {
return `Failed to install skill from ${entry.source}`;
}
deps.skillRegistry.register(installed);
return `Installed skill '${installed.manifest.name}' (${installed.manifest.version}) from registry id '${entry.id}'.`;
} catch (error) {
return `Skill install failed: ${error instanceof Error ? error.message : String(error)}`;
}
}
return 'Unknown skill action. Use: list, search, install';
},
},
});