import { readFile, readdir } from 'node:fs/promises'; import path from 'node:path'; import { type CodingSkillId, } from '../../shared/coding-skills'; import type { ProductCodingCommand, ProductCodingSkill, ProductPiCommandInput, } from '../../shared/coding-product-tools'; export type { ProductCodingCommand, ProductCodingSkill, ProductPiCommandInput, } from '../../shared/coding-product-tools'; const MAKELORE_COMMANDS: readonly ProductCodingCommand[] = [ { name: 'models', title: '切换模型', description: '切换当前会话后续轮次使用的模型', source: 'makelore' }, { name: 'thinking', title: '思考强度', description: '设置当前会话的思考强度', source: 'makelore' }, { name: 'compact', title: '压缩会话', description: '使用当前模型总结会话上下文', source: 'makelore' }, { name: 'fork', title: '分叉会话', description: '从当前用户消息创建新的产品会话', source: 'makelore' }, { name: 'recover', title: '恢复会话', description: '重新创建当前会话 worker 并恢复状态', source: 'makelore' }, ] as const; const COMMAND_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,63}$/; export interface ProductCodingPluginSkillSource { id: string; pluginId?: string; directory: string; available?: boolean; /** Package-relative Skill entry; defaults to `SKILL.md`. */ entryPath?: string; } function frontmatterScalar(content: string, key: string): string | undefined { const block = content.match(/^---\s*\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/)?.[1]; if (!block) return undefined; const line = block.split(/\r?\n/).find((candidate) => candidate.startsWith(`${key}:`)); if (!line) return undefined; const value = line.slice(key.length + 1).trim(); if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) { return value.slice(1, -1).trim() || undefined; } return value || undefined; } function selectedSkillIds( value: readonly string[], allIds: readonly string[], ): Set { const allowed = new Set(allIds); const selected = new Set(); for (const raw of value) { const id = raw.trim(); // Old project assignments are retained in project.json, but a removed or // not-yet-installed provider must not break the current capability list. if (allowed.has(id)) selected.add(id); } return selected; } function productSkillId(id: string): CodingSkillId { return id; } async function listSkillEntries( directory: string, relative = '', ): Promise> { const entries = await readdir(path.join(directory, relative), { withFileTypes: true }); const result: Array<{ path: string; type: 'file' | 'directory' }> = []; for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) { const entryPath = relative ? path.posix.join(relative, entry.name) : entry.name; if (entry.isDirectory()) { result.push({ path: entryPath, type: 'directory' }); result.push(...await listSkillEntries(directory, entryPath)); } else if (entry.isFile()) { result.push({ path: entryPath, type: 'file' }); } } return result; } export async function listProductCodingSkills( _bundledSkillsDir: string, selectedIds: readonly string[] = [], pluginSkillSources: readonly ProductCodingPluginSkillSource[] = [], ): Promise { const pluginIds = pluginSkillSources.map(({ id }) => id); const allIds = [...pluginIds]; if (new Set(allIds).size !== allIds.length) { throw new Error('Duplicate coding skill identifier'); } const selected = selectedSkillIds(selectedIds, allIds); const sources: ProductCodingPluginSkillSource[] = [...pluginSkillSources]; const visibleSources = sources.filter(({ id, available }) => available !== false || selected.has(id)); return await Promise.all(visibleSources.map(async ({ id, directory, entryPath, available = true }) => { const sourceLocation = path.resolve(directory); const location = sourceLocation; const content = await readFile(path.join(sourceLocation, entryPath ?? 'SKILL.md'), 'utf8'); return { id: productSkillId(id), name: frontmatterScalar(content, 'name') ?? id, description: frontmatterScalar(content, 'description') ?? '', selected: selected.has(id), available, effective: available && selected.has(id), location, content, entries: await listSkillEntries(sourceLocation), }; })); } export function buildProductCodingCommandCatalog( skills: readonly ProductCodingSkill[], piCommands: readonly ProductPiCommandInput[] = [], ): ProductCodingCommand[] { const commands = [...MAKELORE_COMMANDS]; const used = new Set(commands.map((command) => command.name.toLocaleLowerCase())); for (const command of piCommands) { const name = command.name.trim(); const key = name.toLocaleLowerCase(); if (!COMMAND_NAME_PATTERN.test(name) || used.has(key)) continue; used.add(key); commands.push({ name, title: name, description: command.description?.trim() || 'Pi 命令', source: 'pi', }); } for (const skill of skills) { const key = skill.id.toLocaleLowerCase(); if (!skill.effective || used.has(key)) continue; used.add(key); commands.push({ name: skill.id, title: skill.name, description: skill.description || '项目技能', source: 'skill', skillId: skill.id, }); } return commands; }