feat(skills): add registry doctor diagnostics and docs

This commit is contained in:
William Valentin
2026-02-16 00:53:25 -08:00
parent 23609a03a4
commit ae36248da8
11 changed files with 298 additions and 23 deletions
+66
View File
@@ -315,6 +315,72 @@ skills:
expect(skillsCheck?.detail).toContain('1 skill(s), 1 available, 0 unavailable');
});
it('reports WARN for skills registry when unconfigured', async () => {
mkdirSync(testDir, { recursive: true });
const configPath = join(testDir, 'registry-unconfigured.yaml');
writeFileSync(configPath, `
telegram:
bot_token: "test-token"
allowed_chat_ids: [123]
models:
default:
provider: anthropic
model: claude-sonnet
`);
const ctx: DoctorContext = { configPath, dataDir: testDir };
const results = await runChecks(ctx);
const registryCheck = results.find(r => r.label === 'Skills registry');
expect(registryCheck?.status).toBe('warn');
expect(registryCheck?.detail).toContain('unconfigured');
});
it('reports PASS for skills registry when source is parsable', async () => {
mkdirSync(testDir, { recursive: true });
const registryPath = join(testDir, 'registry.json');
writeFileSync(registryPath, JSON.stringify({ skills: [] }), 'utf-8');
const configPath = join(testDir, 'registry-pass.yaml');
writeFileSync(configPath, `
telegram:
bot_token: "test-token"
allowed_chat_ids: [123]
models:
default:
provider: anthropic
model: claude-sonnet
skills:
registry_source: "${registryPath}"
`);
const ctx: DoctorContext = { configPath, dataDir: testDir };
const results = await runChecks(ctx);
const registryCheck = results.find(r => r.label === 'Skills registry');
expect(registryCheck?.status).toBe('pass');
expect(registryCheck?.detail).toContain('loaded 0 entries');
});
it('reports FAIL for skills registry when source cannot be loaded', async () => {
mkdirSync(testDir, { recursive: true });
const configPath = join(testDir, 'registry-fail.yaml');
writeFileSync(configPath, `
telegram:
bot_token: "test-token"
allowed_chat_ids: [123]
models:
default:
provider: anthropic
model: claude-sonnet
skills:
registry_source: "${join(testDir, 'missing-registry.json')}"
`);
const ctx: DoctorContext = { configPath, dataDir: testDir };
const results = await runChecks(ctx);
const registryCheck = results.find(r => r.label === 'Skills registry');
expect(registryCheck?.status).toBe('fail');
});
it('reports FAIL when OpenAI auth_mode=api_key has no available key sources', async () => {
const originalHome = process.env.HOME;
const originalKey = process.env.OPENAI_API_KEY;
+56
View File
@@ -422,6 +422,61 @@ const checkSkills: Check = async (ctx) => {
}
};
function toRegistrySource(value: string): { type: 'file'; path: string } | { type: 'url'; url: string } | null {
const trimmed = value.trim();
if (!trimmed) {
return null;
}
if (trimmed.startsWith('https://')) {
return { type: 'url', url: trimmed };
}
if (trimmed.startsWith('http://')) {
return null;
}
return { type: 'file', path: trimmed };
}
const checkSkillsRegistry: Check = async (ctx) => {
if (!ctx.config) {
return { status: 'skip', label: 'Skills registry', detail: '(config invalid)' };
}
const configured = ctx.config.skills.registry_source?.trim() || process.env.FLYNN_SKILLS_REGISTRY_SOURCE?.trim() || '';
if (!configured) {
return {
status: 'warn',
label: 'Skills registry',
detail: 'registry discovery unconfigured (set skills.registry_source or FLYNN_SKILLS_REGISTRY_SOURCE)',
};
}
const source = toRegistrySource(configured);
if (!source) {
return {
status: 'fail',
label: 'Skills registry',
detail: `invalid registry source '${configured}' (use local path or https:// URL)`,
};
}
try {
const { loadSkillRegistryCatalog } = await import('../skills/index.js');
const catalog = await loadSkillRegistryCatalog(source);
const sourceLabel = source.type === 'file' ? source.path : source.url;
return {
status: 'pass',
label: 'Skills registry',
detail: `loaded ${catalog.skills.length} entr${catalog.skills.length === 1 ? 'y' : 'ies'} from ${sourceLabel}`,
};
} catch (err) {
return {
status: 'fail',
label: 'Skills registry',
detail: err instanceof Error ? err.message : String(err),
};
}
};
const checkTailscale: Check = async (ctx) => {
if (!ctx.config?.server?.tailscale?.serve) {
return { status: 'skip', label: 'Tailscale Serve', detail: '(not enabled)' };
@@ -533,6 +588,7 @@ const allChecks: Check[] = [
checkGmail,
checkMcpServers,
checkSkills,
checkSkillsRegistry,
checkTailscale,
];
+68
View File
@@ -80,6 +80,7 @@ function writeSkillsCliConfig(
allowShellRunner?: boolean;
shellRunnerAllowlist?: string[];
shellRunnerGovernanceOwner?: string;
registrySource?: string;
auditEnabled?: boolean;
auditPath?: string;
},
@@ -105,6 +106,7 @@ function writeSkillsCliConfig(
` installation_execution: ${opts.installationExecution ?? 'disabled'}`,
` allow_shell_runner: ${opts.allowShellRunner ?? false}`,
` shell_runner_allowlist: [${allowlist.map((item) => `'${item}'`).join(', ')}]`,
...(opts.registrySource ? [` registry_source: '${opts.registrySource}'`] : []),
...governanceOwnerLines,
...auditLines,
].join('\n'),
@@ -293,6 +295,10 @@ describe('skills CLI helpers', () => {
expect(noneTrust.status).toBe('none_declared');
expect(resolveSkillRegistrySource('./registry.json').source).toEqual({ type: 'file', path: './registry.json' });
expect(resolveSkillRegistrySource(undefined, './configured-registry.json').source).toEqual({
type: 'file',
path: './configured-registry.json',
});
expect(resolveSkillRegistrySource('https://registry.example/catalog.json').source).toEqual({
type: 'url',
url: 'https://registry.example/catalog.json',
@@ -1968,6 +1974,68 @@ describe('skills CLI helpers', () => {
rmSync(root, { recursive: true, force: true });
});
it('skills install supports --registry-id using config skills.registry_source fallback', async () => {
const root = mkdtempSync(join(tmpdir(), 'flynn-skills-cli-'));
const configPath = join(root, 'config.yaml');
const registryPath = join(root, 'registry.json');
const sourceSkillDir = join(root, 'registry-skills', 'todoist-config');
const managedDir = join(root, 'managed');
const bundledDir = join(root, 'bundled');
const workspaceDir = join(root, 'workspace');
mkdirSync(sourceSkillDir, { recursive: true });
mkdirSync(managedDir, { recursive: true });
mkdirSync(bundledDir, { recursive: true });
mkdirSync(workspaceDir, { recursive: true });
writeFileSync(join(sourceSkillDir, 'SKILL.md'), '# Todoist Config Skill\nInstructions');
writeFileSync(
join(sourceSkillDir, 'manifest.json'),
JSON.stringify({
name: 'todoist-config',
description: 'Todoist integration config fallback',
version: '1.0.0',
}),
'utf-8',
);
writeFileSync(
registryPath,
JSON.stringify({
skills: [
{
id: 'todoist-config',
name: 'Todoist Config',
version: '1.0.0',
source: './registry-skills/todoist-config',
summary: 'Task manager integration via config fallback',
},
],
}),
'utf-8',
);
writeSkillsCliConfig(configPath, { managedDir, bundledDir, workspaceDir, registrySource: registryPath });
const program = new Command();
registerSkillsCommand(program);
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined);
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
process.exitCode = undefined;
await program.parseAsync(
['skills', 'install', '--registry-id', 'todoist-config', '-c', configPath],
{ from: 'user' },
);
expect(errorSpy).not.toHaveBeenCalled();
expect(logSpy).toHaveBeenCalledWith("Installed skill 'todoist-config' (1.0.0).");
expect(existsSync(join(managedDir, 'todoist-config', 'SKILL.md'))).toBe(true);
expect(process.exitCode).toBeUndefined();
logSpy.mockRestore();
errorSpy.mockRestore();
process.exitCode = undefined;
rmSync(root, { recursive: true, force: true });
});
it('skills install requires --confirm for remote registry sources', async () => {
const root = mkdtempSync(join(tmpdir(), 'flynn-skills-cli-'));
const configPath = join(root, 'config.yaml');
+20 -8
View File
@@ -239,11 +239,12 @@ export function renderSkillRegistryEntry(entry: SkillRegistryEntry): string {
export function resolveSkillRegistrySource(
sourceArg?: string,
configSource?: string,
): { source?: SkillRegistrySource; error?: string } {
const raw = sourceArg?.trim() || process.env[SKILL_REGISTRY_SOURCE_ENV]?.trim();
const raw = sourceArg?.trim() || configSource?.trim() || process.env[SKILL_REGISTRY_SOURCE_ENV]?.trim();
if (!raw) {
return {
error: `Registry source is required. Pass --source <path-or-https-url> or set ${SKILL_REGISTRY_SOURCE_ENV}.`,
error: `Registry source is required. Pass --source <path-or-https-url>, set skills.registry_source, or set ${SKILL_REGISTRY_SOURCE_ENV}.`,
};
}
@@ -261,8 +262,9 @@ export function resolveSkillRegistrySource(
export async function loadRegistrySkillLookup(
registryId: string,
sourceArg?: string,
configSource?: string,
): Promise<{ lookup?: RegistrySkillLookup; error?: string }> {
const sourceResult = resolveSkillRegistrySource(sourceArg);
const sourceResult = resolveSkillRegistrySource(sourceArg, configSource);
if (sourceResult.error || !sourceResult.source) {
return { error: sourceResult.error ?? 'Failed to resolve registry source.' };
}
@@ -1746,8 +1748,11 @@ export function registerSkillsCommand(program: Command): void {
.option('--search <term>', 'Filter by id/name/summary/publisher')
.option('--publisher <name>', 'Filter by exact publisher name (case-insensitive)')
.option('--json', 'Output as JSON')
.action(async (opts: { source?: string; search?: string; publisher?: string; json?: boolean }) => {
const sourceResult = resolveSkillRegistrySource(opts.source);
.option('-c, --config <path>', 'Config file path')
.action(async (opts: { source?: string; search?: string; publisher?: string; json?: boolean; config?: string }) => {
const loaded = loadConfigSafe(opts.config);
const configSource = loaded.config?.skills.registry_source;
const sourceResult = resolveSkillRegistrySource(opts.source, configSource);
if (sourceResult.error || !sourceResult.source) {
console.error(sourceResult.error ?? 'Failed to resolve registry source.');
process.exitCode = 1;
@@ -1781,8 +1786,11 @@ export function registerSkillsCommand(program: Command): void {
.description('Show details for a registry entry')
.option('--source <path-or-url>', 'Registry catalog source (local file path or HTTPS URL)')
.option('--json', 'Output as JSON')
.action(async (id: string, opts: { source?: string; json?: boolean }) => {
const sourceResult = resolveSkillRegistrySource(opts.source);
.option('-c, --config <path>', 'Config file path')
.action(async (id: string, opts: { source?: string; json?: boolean; config?: string }) => {
const loaded = loadConfigSafe(opts.config);
const configSource = loaded.config?.skills.registry_source;
const sourceResult = resolveSkillRegistrySource(opts.source, configSource);
if (sourceResult.error || !sourceResult.source) {
console.error(sourceResult.error ?? 'Failed to resolve registry source.');
process.exitCode = 1;
@@ -1903,7 +1911,11 @@ export function registerSkillsCommand(program: Command): void {
| undefined;
if (hasRegistryId) {
const lookupResult = await loadRegistrySkillLookup(opts.registryId ?? '', opts.registrySource);
const lookupResult = await loadRegistrySkillLookup(
opts.registryId ?? '',
opts.registrySource,
loaded.config.skills.registry_source,
);
if (lookupResult.error || !lookupResult.lookup) {
console.error(lookupResult.error ?? 'Failed to resolve registry skill.');
process.exitCode = 1;