import { existsSync } from 'node:fs'; import { mkdir, readFile, writeFile } from 'node:fs/promises'; import { randomBytes } from 'node:crypto'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import type { BuiltInRoleSubagentId } from '../../src/lib/role-subagents'; const COURSE_ROLE_IDS = [ 'pm', 'product', 'designer', 'dev', 'marketing', 'deploy', 'gameplay', 'assets', 'game-dev', 'game-release', 'game-showcase', ] as const satisfies readonly BuiltInRoleSubagentId[]; const BASE_REQUIRED_SKILL_IDS = ['youth-plain-language'] as const; export type CourseRuntimeRoleId = typeof COURSE_ROLE_IDS[number]; export type RuntimeSubagentTemplateRoleId = CourseRuntimeRoleId | 'custom'; export interface RuntimeSubagentCreateInput { projectPath: string; displayName: string; templateRoleId: RuntimeSubagentTemplateRoleId; prompt?: string; requiredSkillIds?: string[]; } export interface RuntimeSubagentCreateResult { id: string; displayName: string; templateRoleId: RuntimeSubagentTemplateRoleId; filePath: string; skillIds: string[]; createdAt: string; updatedAt: string; } export interface RuntimeSubagentUpdateInput { projectPath: string; agentId: string; skillIds: string[]; requiredSkillIds?: string[]; displayName?: string; templateRoleId?: RuntimeSubagentTemplateRoleId; prompt?: string; } export interface RuntimeSubagentUpdateResult { id: string; filePath: string; skillIds: string[]; updatedAt: string; } const currentDir = path.dirname(fileURLToPath(import.meta.url)); function isCourseRoleId(value: unknown): value is CourseRuntimeRoleId { return typeof value === 'string' && COURSE_ROLE_IDS.includes(value as CourseRuntimeRoleId); } function isTemplateRoleId(value: unknown): value is RuntimeSubagentTemplateRoleId { return value === 'custom' || isCourseRoleId(value); } function getResourcesPath(): string | null { const processWithResources = process as NodeJS.Process & { resourcesPath?: string }; return typeof processWithResources.resourcesPath === 'string' && processWithResources.resourcesPath ? processWithResources.resourcesPath : null; } function getTemplateCandidates(templateRoleId: CourseRuntimeRoleId): string[] { const resourcesPath = getResourcesPath(); return [ ...(resourcesPath ? [path.join(resourcesPath, 'course-agents', `${templateRoleId}.md`)] : []), path.resolve(currentDir, '..', '..', '.opencode', 'agent', `${templateRoleId}.md`), path.resolve(process.cwd(), '.opencode', 'agent', `${templateRoleId}.md`), ]; } async function readBuiltInTemplate(templateRoleId: CourseRuntimeRoleId): Promise { const templatePath = getTemplateCandidates(templateRoleId).find((candidate) => existsSync(candidate)); if (!templatePath) { throw new Error(`Missing built-in subagent template: ${templateRoleId}`); } return await readFile(templatePath, 'utf8'); } function yamlString(value: string): string { return JSON.stringify(value); } function buildCustomAgentMarkdown(displayName: string, prompt: string): string { return `--- description: ${yamlString(displayName)} mode: all color: "#64D2C8" permission: "*": deny read: allow glob: allow grep: allow list: allow question: allow todowrite: allow skill: "*": deny --- ${prompt.trim()} `; } function normalizeDisplayName(value: unknown): string { const normalized = typeof value === 'string' ? value.trim() : ''; if (!normalized) throw new Error('Missing subagent display name'); if (normalized.length > 80) throw new Error('Subagent display name is too long'); return normalized; } function normalizePrompt(value: unknown): string { const normalized = typeof value === 'string' ? value.trim() : ''; if (!normalized) throw new Error('Missing custom subagent prompt'); if (normalized.length > 20000) throw new Error('Custom subagent prompt is too long'); return normalized; } function normalizeAgentId(value: unknown): string { const normalized = typeof value === 'string' ? value.trim() : ''; if (!normalized) throw new Error('Missing subagent id'); if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(normalized)) throw new Error('Invalid subagent id'); return normalized; } function normalizeSkillIds(value: unknown): string[] { if (!Array.isArray(value)) throw new Error('Missing skill ids'); const result: string[] = []; const seen = new Set(); for (const item of value) { const skillId = typeof item === 'string' ? item.trim() : ''; if (!skillId) continue; if (!/^[a-z0-9][a-z0-9-]{0,63}$/.test(skillId)) throw new Error(`Invalid skill id: ${skillId}`); if (!seen.has(skillId)) { seen.add(skillId); result.push(skillId); } } return result; } function normalizeOptionalSkillIds(value: unknown): string[] { return value === undefined ? [] : normalizeSkillIds(value); } function mergeSkillIds(...skillLists: Array): string[] { const result: string[] = []; const seen = new Set(); for (const skillList of skillLists) { for (const skillId of skillList) { if (!seen.has(skillId)) { seen.add(skillId); result.push(skillId); } } } return result; } function validateInput(input: RuntimeSubagentCreateInput): Omit & { prompt?: string } { const displayName = normalizeDisplayName(input.displayName); if (!isTemplateRoleId(input.templateRoleId)) throw new Error('Invalid template role id'); return { projectPath: input.projectPath, displayName, templateRoleId: input.templateRoleId, ...(input.templateRoleId === 'custom' ? { prompt: normalizePrompt(input.prompt) } : {}), }; } function validateRestoreInput(input: RuntimeSubagentUpdateInput): RuntimeSubagentCreateInput { const displayName = normalizeDisplayName(input.displayName); if (!isTemplateRoleId(input.templateRoleId)) throw new Error('Invalid template role id'); return { projectPath: input.projectPath, displayName, templateRoleId: input.templateRoleId, ...(input.templateRoleId === 'custom' ? { prompt: normalizePrompt(input.prompt) } : {}), }; } function createAgentId(templateRoleId: RuntimeSubagentTemplateRoleId): string { const timestamp = new Date().toISOString() .replace(/\D/g, '') .slice(0, 14); return `nitu-${templateRoleId}-${timestamp}-${randomBytes(3).toString('hex')}`; } async function writeUniqueAgentFile(agentDir: string, id: string, markdown: string): Promise<{ id: string; filePath: string }> { let candidateId = id; for (let attempt = 0; attempt < 5; attempt += 1) { const filePath = path.join(agentDir, `${candidateId}.md`); if (!existsSync(filePath)) { await writeFile(filePath, markdown, { encoding: 'utf8', flag: 'wx' }); return { id: candidateId, filePath }; } candidateId = `${id}-${randomBytes(2).toString('hex')}`; } throw new Error('Failed to create a unique subagent id'); } async function buildRuntimeSubagentMarkdown(input: RuntimeSubagentCreateInput): Promise { return input.templateRoleId === 'custom' ? buildCustomAgentMarkdown(input.displayName, normalizePrompt(input.prompt)) : await readBuiltInTemplate(input.templateRoleId); } function splitFrontmatter(markdown: string): { frontmatter: string; body: string } { const match = markdown.match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n)?([\s\S]*)$/); if (!match) throw new Error('Subagent file is missing YAML frontmatter'); return { frontmatter: match[1] ?? '', body: match[2] ?? '', }; } function countIndent(line: string): number { const match = line.match(/^ */); return match?.[0].length ?? 0; } function yamlKey(value: string): string { return /^[a-z0-9][a-z0-9-]*$/.test(value) ? value : yamlString(value); } function buildSkillPermissionLines(skillIds: string[]): string[] { return [ ' skill:', ...(skillIds.length > 0 ? skillIds.map((skillId) => ` ${yamlKey(skillId)}: allow`) : [' "*": deny']), ]; } function findPermissionBlock(lines: string[]): { start: number; end: number } | null { const start = lines.findIndex((line) => /^permission:\s*$/.test(line)); if (start < 0) return null; let end = lines.length; for (let index = start + 1; index < lines.length; index += 1) { const line = lines[index]; if (line.trim() && countIndent(line) === 0) { end = index; break; } } return { start, end }; } function findPermissionSkillBlock(lines: string[], permissionStart: number, permissionEnd: number): { start: number; end: number } | null { for (let index = permissionStart + 1; index < permissionEnd; index += 1) { if (/^ {2}skill:\s*$/.test(lines[index])) { let end = permissionEnd; for (let cursor = index + 1; cursor < permissionEnd; cursor += 1) { const line = lines[cursor]; if (line.trim() && countIndent(line) <= 2) { end = cursor; break; } } return { start: index, end }; } } return null; } function updateAgentSkillPermissions(markdown: string, skillIds: string[]): string { const { frontmatter, body } = splitFrontmatter(markdown); const lines = frontmatter.split(/\r?\n/); const skillLines = buildSkillPermissionLines(skillIds); const permissionBlock = findPermissionBlock(lines); if (!permissionBlock) { lines.push('permission:', ...skillLines); } else { const skillBlock = findPermissionSkillBlock(lines, permissionBlock.start, permissionBlock.end); if (skillBlock) { lines.splice(skillBlock.start, skillBlock.end - skillBlock.start, ...skillLines); } else { lines.splice(permissionBlock.end, 0, ...skillLines); } } return `---\n${lines.join('\n')}\n---\n\n${body.replace(/^\r?\n/, '')}`; } function readBoundSkillIds(markdown: string): string[] { const { frontmatter } = splitFrontmatter(markdown); const lines = frontmatter.split(/\r?\n/); const permissionBlock = findPermissionBlock(lines); if (!permissionBlock) return []; const skillBlock = findPermissionSkillBlock(lines, permissionBlock.start, permissionBlock.end); if (!skillBlock) return []; const result: string[] = []; for (let index = skillBlock.start + 1; index < skillBlock.end; index += 1) { const match = lines[index].match(/^ {4}(?:"([^"]+)"|([A-Za-z0-9][A-Za-z0-9._-]*)):\s*(allow|deny)\s*$/); if (!match) continue; const key = match[1] ?? match[2] ?? ''; const action = match[3]; if (key !== '*' && action === 'allow') result.push(key); } return result; } function canRefreshFromMetadata(input: RuntimeSubagentUpdateInput): boolean { const hasDisplayName = typeof input.displayName === 'string' && input.displayName.trim().length > 0; if (!hasDisplayName || !isTemplateRoleId(input.templateRoleId)) return false; if (input.templateRoleId === 'custom') { return typeof input.prompt === 'string' && input.prompt.trim().length > 0; } return true; } export async function createRuntimeSubagent(input: RuntimeSubagentCreateInput): Promise { const normalized = validateInput(input); const requiredSkillIds = normalizeOptionalSkillIds(input.requiredSkillIds); const agentDir = path.join(path.resolve(normalized.projectPath), '.opencode', 'agent'); await mkdir(agentDir, { recursive: true }); const baseMarkdown = await buildRuntimeSubagentMarkdown(normalized); const skillIds = mergeSkillIds(readBoundSkillIds(baseMarkdown), requiredSkillIds, BASE_REQUIRED_SKILL_IDS); const markdown = updateAgentSkillPermissions(baseMarkdown, skillIds); const createdAt = new Date().toISOString(); const written = await writeUniqueAgentFile(agentDir, createAgentId(normalized.templateRoleId), markdown); return { id: written.id, displayName: normalized.displayName, templateRoleId: normalized.templateRoleId, filePath: written.filePath, skillIds, createdAt, updatedAt: createdAt, }; } export async function updateRuntimeSubagent(input: RuntimeSubagentUpdateInput): Promise { const projectPath = path.resolve(input.projectPath); const agentId = normalizeAgentId(input.agentId); const skillIds = mergeSkillIds( normalizeSkillIds(input.skillIds), normalizeOptionalSkillIds(input.requiredSkillIds), BASE_REQUIRED_SKILL_IDS, ); const agentDir = path.join(projectPath, '.opencode', 'agent'); const filePath = path.resolve(agentDir, `${agentId}.md`); const relative = path.relative(agentDir, filePath); if (relative.startsWith('..') || path.isAbsolute(relative)) throw new Error('Invalid subagent id'); let currentMarkdown: string; try { currentMarkdown = await readFile(filePath, 'utf8'); } catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; const restoreInput = validateRestoreInput(input); currentMarkdown = await buildRuntimeSubagentMarkdown(restoreInput); await mkdir(agentDir, { recursive: true }); } const baseMarkdown = canRefreshFromMetadata(input) ? await buildRuntimeSubagentMarkdown(validateRestoreInput(input)) : currentMarkdown; const nextMarkdown = updateAgentSkillPermissions(baseMarkdown, skillIds); await writeFile(filePath, nextMarkdown, 'utf8'); return { id: agentId, filePath, skillIds, updatedAt: new Date().toISOString(), }; }