387 lines
13 KiB
TypeScript
387 lines
13 KiB
TypeScript
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<string> {
|
|
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<string>();
|
|
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<readonly string[]>): string[] {
|
|
const result: string[] = [];
|
|
const seen = new Set<string>();
|
|
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<RuntimeSubagentCreateInput, 'prompt'> & { 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<string> {
|
|
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<RuntimeSubagentCreateResult> {
|
|
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<RuntimeSubagentUpdateResult> {
|
|
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(),
|
|
};
|
|
}
|