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
+17 -1
View File
@@ -1,6 +1,6 @@
import { describe, it, expect, vi } from 'vitest';
import { createApproveCommand, createApprovalsCommand, createContextCommand, createDenyCommand, createElevateCommand, createModelCommand, createQueueCommand, createResearchCommand, createTransferCommand } from './index.js';
import { createApproveCommand, createApprovalsCommand, createContextCommand, createDenyCommand, createElevateCommand, createModelCommand, createQueueCommand, createResearchCommand, createSkillCommand, createTransferCommand } from './index.js';
describe('builtin /model command', () => {
it('passes through the full argument string', async () => {
@@ -241,3 +241,19 @@ describe('builtin approval commands', () => {
expect(result).toEqual({ handled: true, text: 'denied' });
});
});
describe('builtin /skill command', () => {
it('passes subcommand text to skillCommand service', async () => {
const cmd = createSkillCommand();
const skillCommand = vi.fn(() => 'ok');
const result = await cmd.execute(['search', 'calendar'], {
channel: 'test',
senderId: 'user',
sessionId: 's1',
rawInput: '/skill search calendar',
services: { skillCommand },
});
expect(skillCommand).toHaveBeenCalledWith('search calendar');
expect(result).toEqual({ handled: true, text: 'ok' });
});
});
+17
View File
@@ -284,6 +284,22 @@ export function createDenyCommand(): CommandDefinition {
};
}
export function createSkillCommand(): CommandDefinition {
return {
name: 'skill',
description: 'In-chat skill discovery and install (list/search/install)',
execute: async (args, ctx) => {
if (!ctx.services?.skillCommand) {
return notAvailable('Skill command');
}
return {
handled: true,
text: await ctx.services.skillCommand(args.join(' ').trim()),
};
},
};
}
export function registerBuiltinCommands(registry: CommandRegistry): void {
registry.register(createHelpCommand(registry));
registry.register(createStatusCommand());
@@ -299,4 +315,5 @@ export function registerBuiltinCommands(registry: CommandRegistry): void {
registry.register(createApprovalsCommand());
registry.register(createApproveCommand());
registry.register(createDenyCommand());
registry.register(createSkillCommand());
}
+1
View File
@@ -13,5 +13,6 @@ export {
createApprovalsCommand,
createApproveCommand,
createDenyCommand,
createSkillCommand,
registerBuiltinCommands,
} from './builtin/index.js';
+1
View File
@@ -38,4 +38,5 @@ export interface CommandServices {
getApprovals?: () => Promise<string> | string;
approvePending?: (input: string) => Promise<string> | string;
denyPending?: (input: string) => Promise<string> | string;
skillCommand?: (input: string) => Promise<string> | string;
}
+1 -1
View File
@@ -213,7 +213,7 @@ export async function startDaemon(config: Config, options?: StartDaemonOptions):
const messageRouter = createMessageRouter({
sessionManager, modelRouter, systemPrompt, toolRegistry, toolExecutor,
config, memoryStore, agentConfigRegistry, agentRouter, sandboxManager, commandRegistry, hookEngine, intentRegistry, routingPolicy, skillRegistry,
config, memoryStore, agentConfigRegistry, agentRouter, sandboxManager, commandRegistry, hookEngine, intentRegistry, routingPolicy, skillRegistry, skillInstaller,
...createConfiguredExternalBackends(config),
});
channelRegistry.setMessageHandler(messageRouter.handler);
+73
View File
@@ -911,6 +911,79 @@ describe('daemon command fast-path integration', () => {
await expect(pendingPromise).resolves.toEqual({ approved: true });
expect(processSpy).not.toHaveBeenCalled();
});
it('handles /skill list via command fast-path', async () => {
const processSpy = vi.spyOn(AgentOrchestrator.prototype, 'process');
const session = {
id: 'telegram:skill-user',
addMessage: vi.fn(),
getHistory: vi.fn(() => []),
clear: vi.fn(),
replaceHistory: vi.fn(),
getConfig: vi.fn(() => undefined),
setConfig: vi.fn(),
deleteConfig: vi.fn(),
};
const commandRegistry = new CommandRegistry();
registerBuiltinCommands(commandRegistry);
const router = createMessageRouter({
sessionManager: {
getSession: vi.fn(() => session),
} as unknown as MessageRouterDeps['sessionManager'],
modelRouter: {
getAvailableTiers: () => ['fast', 'default', 'complex', 'local'],
getAllLabels: () => ({ fast: 'fast', default: 'default', complex: 'complex', local: 'local' }),
getLabel: (tier: string) => tier,
} as unknown as MessageRouterDeps['modelRouter'],
systemPrompt: 'test prompt',
toolRegistry: {
clone() { return this; },
register: vi.fn(),
} as unknown as MessageRouterDeps['toolRegistry'],
toolExecutor: {} as unknown as MessageRouterDeps['toolExecutor'],
config: {
agents: {
primary_tier: 'default',
delegation: {
compaction: 'fast',
memory_extraction: 'fast',
classification: 'fast',
tool_summarisation: 'fast',
complex_reasoning: 'complex',
},
max_delegation_depth: 3,
max_iterations: 10,
},
compaction: { enabled: false },
models: { default: { provider: 'anthropic', model: 'claude' } },
} as unknown as MessageRouterDeps['config'],
commandRegistry,
skillRegistry: {
listAvailable: () => ([
{ manifest: { name: 'calendar', tier: 'managed', version: '1.2.0' } },
{ manifest: { name: 'todoist', tier: 'workspace', version: '0.4.1' } },
]),
} as unknown as MessageRouterDeps['skillRegistry'],
});
const reply = vi.fn(async (_message: OutboundMessage) => {});
await router.handler({
id: 'skill-1',
channel: 'telegram',
senderId: 'skill-user',
text: '/skill list',
timestamp: Date.now(),
metadata: { isCommand: true, command: 'skill', commandArgs: 'list' },
} as MessageRouterInput, reply);
expect(processSpy).not.toHaveBeenCalled();
const outbound = reply.mock.calls[0]?.[0] as OutboundMessage | undefined;
expect(String(outbound?.text)).toContain('Available skills (2):');
expect(String(outbound?.text)).toContain('calendar');
expect(String(outbound?.text)).toContain('todoist');
});
});
describe('daemon external backend integration', () => {
+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';
},
},
});