import * as fs from "node:fs"; import * as path from "node:path"; import type { AgentConfig, ResourceSource, RoutingMetadata } from "../agents/agent-config.ts"; import { serializeAgent } from "../agents/agent-serializer.ts"; import { allAgents, discoverAgents } from "../agents/discover-agents.ts"; import type { TeamToolDetails } from "./team-tool-types.ts"; import { toolResult, type PiTeamsToolResult } from "./tool-result.ts"; import type { TeamToolParamsValue } from "../schema/team-tool-schema.ts"; import type { TeamConfig, TeamRole } from "../teams/team-config.ts"; import { serializeTeam } from "../teams/team-serializer.ts"; import { allTeams, discoverTeams } from "../teams/discover-teams.ts"; import type { WorkflowConfig, WorkflowStep } from "../workflows/workflow-config.ts"; import { serializeWorkflow } from "../workflows/workflow-serializer.ts"; import { allWorkflows, discoverWorkflows } from "../workflows/discover-workflows.ts"; import { projectCrewRoot, userPiRoot } from "../utils/paths.ts"; import { hasOwn, parseConfigObject, requireString, sanitizeName } from "../utils/names.ts"; interface ManagementContext { cwd: string; } type MutableSource = "user" | "project"; type MutableResource = AgentConfig | TeamConfig | WorkflowConfig; function result(text: string, status: TeamToolDetails["status"] = "ok", isError = false): PiTeamsToolResult { return toolResult(text, { action: "management", status }, isError); } function scopeDir(ctx: ManagementContext, resource: "agent" | "team" | "workflow", scope: MutableSource): string { const base = scope === "user" ? userPiRoot() : projectCrewRoot(ctx.cwd); if (resource === "agent") return path.join(base, "agents"); if (resource === "team") return path.join(base, "teams"); return path.join(base, "workflows"); } function extensionFor(resource: "agent" | "team" | "workflow"): string { if (resource === "agent") return ".md"; if (resource === "team") return ".team.md"; return ".workflow.md"; } function backupFile(filePath: string): string { // Include milliseconds and a short random suffix to prevent collision // when multiple backups happen within the same second. const ts = new Date().toISOString().replace(/[-:.TZ]/g, ""); const random = Math.random().toString(36).slice(2, 6); const backupPath = `${filePath}.bak-${ts.slice(0, 17)}-${random}`; fs.copyFileSync(filePath, backupPath); return backupPath; } function targetPath(ctx: ManagementContext, resource: "agent" | "team" | "workflow", scope: MutableSource, name: string): string { return path.join(scopeDir(ctx, resource, scope), `${name}${extensionFor(resource)}`); } function parseStringArray(value: unknown): string[] | undefined { if (typeof value === "string") return value.split(",").map((entry) => entry.trim()).filter(Boolean); if (Array.isArray(value)) return value.filter((entry): entry is string => typeof entry === "string" && entry.trim().length > 0).map((entry) => entry.trim()); return undefined; } function parseRouting(value: Record, fallback?: RoutingMetadata): RoutingMetadata | undefined { const routing = { triggers: hasOwn(value, "triggers") ? parseStringArray(value.triggers) : fallback?.triggers, useWhen: hasOwn(value, "useWhen") ? parseStringArray(value.useWhen) : fallback?.useWhen, avoidWhen: hasOwn(value, "avoidWhen") ? parseStringArray(value.avoidWhen) : fallback?.avoidWhen, cost: value.cost === "free" || value.cost === "cheap" || value.cost === "expensive" ? value.cost : fallback?.cost, category: hasOwn(value, "category") ? (typeof value.category === "string" && value.category.trim() ? value.category.trim() : undefined) : fallback?.category, }; return routing.triggers || routing.useWhen || routing.avoidWhen || routing.cost || routing.category ? routing : undefined; } function parseRoles(value: unknown): { roles?: TeamRole[]; error?: string } { if (!Array.isArray(value) || value.length === 0) return { error: "config.roles must be a non-empty array." }; const roles: TeamRole[] = []; for (let i = 0; i < value.length; i++) { const item = value[i]; if (!item || typeof item !== "object" || Array.isArray(item)) return { error: `config.roles[${i}] must be an object.` }; const obj = item as Record; const name = requireString(obj.name, `config.roles[${i}].name`); if (name.error) return { error: name.error }; const agent = requireString(obj.agent, `config.roles[${i}].agent`); if (agent.error) return { error: agent.error }; roles.push({ name: sanitizeName(name.value!), agent: sanitizeName(agent.value!), description: typeof obj.description === "string" ? obj.description.trim() : undefined, model: typeof obj.model === "string" ? obj.model.trim() : undefined, maxConcurrency: typeof obj.maxConcurrency === "number" && Number.isInteger(obj.maxConcurrency) && obj.maxConcurrency > 0 ? obj.maxConcurrency : undefined, }); } return { roles }; } function parseSteps(value: unknown): { steps?: WorkflowStep[]; error?: string } { if (!Array.isArray(value) || value.length === 0) return { error: "config.steps must be a non-empty array." }; const steps: WorkflowStep[] = []; for (let i = 0; i < value.length; i++) { const item = value[i]; if (!item || typeof item !== "object" || Array.isArray(item)) return { error: `config.steps[${i}] must be an object.` }; const obj = item as Record; const id = requireString(obj.id, `config.steps[${i}].id`); if (id.error) return { error: id.error }; const role = requireString(obj.role, `config.steps[${i}].role`); if (role.error) return { error: role.error }; steps.push({ id: sanitizeName(id.value!), role: sanitizeName(role.value!), task: typeof obj.task === "string" ? obj.task : "{goal}", dependsOn: parseStringArray(obj.dependsOn), parallelGroup: typeof obj.parallelGroup === "string" ? obj.parallelGroup.trim() : undefined, output: obj.output === false ? false : typeof obj.output === "string" ? obj.output.trim() : undefined, reads: obj.reads === false ? false : parseStringArray(obj.reads), model: typeof obj.model === "string" ? obj.model.trim() : undefined, skills: obj.skills === false ? false : parseStringArray(obj.skills), progress: typeof obj.progress === "boolean" ? obj.progress : undefined, worktree: typeof obj.worktree === "boolean" ? obj.worktree : undefined, verify: typeof obj.verify === "boolean" ? obj.verify : undefined, }); } return { steps }; } function parseWorkflowMaxConcurrency(value: unknown): number | undefined { if (typeof value !== "number" || !Number.isInteger(value) || value < 1) return undefined; return value; } function findResource(ctx: ManagementContext, resource: "agent" | "team" | "workflow", name: string, scope?: string): MutableResource[] { const normalized = sanitizeName(name); const sourceMatches = (item: { name: string; source: ResourceSource }) => (scope === "user" || scope === "project" ? item.source === scope : item.source !== "builtin") && item.name === normalized; if (resource === "agent") return allAgents(discoverAgents(ctx.cwd)).filter(sourceMatches); if (resource === "team") return allTeams(discoverTeams(ctx.cwd)).filter(sourceMatches); return allWorkflows(discoverWorkflows(ctx.cwd)).filter(sourceMatches); } // Note: only checks agent→team references and defaultWorkflow. Does not detect // workflow-step→agent/team references or team name in workflow metadata. function findReferences(ctx: ManagementContext, resource: "agent" | "team" | "workflow", name: string): string[] { const refs: string[] = []; if (resource === "agent") { for (const team of allTeams(discoverTeams(ctx.cwd))) { for (const role of team.roles) { if (role.agent === name) refs.push(`team '${team.name}' role '${role.name}'`); } } } if (resource === "workflow") { for (const team of allTeams(discoverTeams(ctx.cwd))) { if (team.defaultWorkflow === name) refs.push(`team '${team.name}' defaultWorkflow`); } } return refs; } function updateReferencesForRename(ctx: ManagementContext, resource: "agent" | "team" | "workflow", oldName: string, newName: string, scope: MutableSource, dryRun: boolean): string[] { if (oldName === newName) return []; if (resource !== "agent" && resource !== "workflow") return []; const changed: string[] = []; for (const team of allTeams(discoverTeams(ctx.cwd)).filter((candidate) => candidate.source === scope)) { let updated = false; let nextTeam = team; if (resource === "agent") { const roles = team.roles.map((role) => role.agent === oldName ? { ...role, agent: newName } : role); updated = roles.some((role, index) => role.agent !== team.roles[index]!.agent); nextTeam = { ...team, roles }; } if (resource === "workflow" && team.defaultWorkflow === oldName) { updated = true; nextTeam = { ...team, defaultWorkflow: newName }; } if (!updated) continue; changed.push(team.filePath); if (!dryRun) { backupFile(team.filePath); fs.writeFileSync(team.filePath, serializeTeam(nextTeam), "utf-8"); } } return changed; } function resolveMutable(ctx: ManagementContext, params: TeamToolParamsValue): { resource?: MutableResource; error?: PiTeamsToolResult } { if (!params.resource) return { error: result("resource is required for update/delete.", "error", true) }; const name = params.resource === "agent" ? params.agent : params.resource === "team" ? params.team : params.workflow; if (!name) return { error: result(`${params.resource} name is required.`, "error", true) }; const matches = findResource(ctx, params.resource, name, params.scope); if (matches.length === 0) return { error: result(`${params.resource} '${name}' not found in mutable user/project scopes.`, "error", true) }; if (matches.length > 1) return { error: result(`${params.resource} '${name}' exists in multiple scopes. Specify scope: 'user' or 'project'.`, "error", true) }; return { resource: matches[0] }; } export function handleCreate(params: TeamToolParamsValue, ctx: ManagementContext): PiTeamsToolResult { if (!params.resource) return result("resource is required for create.", "error", true); const parsed = parseConfigObject(params.config); if (parsed.error) return result(parsed.error, "error", true); const cfg = parsed.value!; const nameValue = requireString(cfg.name, "config.name"); if (nameValue.error) return result(nameValue.error, "error", true); const descriptionValue = requireString(cfg.description, "config.description"); if (descriptionValue.error) return result(descriptionValue.error, "error", true); const name = sanitizeName(nameValue.value!); if (!name) return result("config.name is invalid after sanitization.", "error", true); const scope = cfg.scope === "project" ? "project" : "user"; const filePath = targetPath(ctx, params.resource, scope, name); if (fs.existsSync(filePath)) return result(`File already exists: ${filePath}`, "error", true); fs.mkdirSync(path.dirname(filePath), { recursive: true }); let content: string; if (params.resource === "agent") { const agent: AgentConfig = { name, description: descriptionValue.value!, source: scope, filePath, systemPrompt: typeof cfg.systemPrompt === "string" ? cfg.systemPrompt : "", model: typeof cfg.model === "string" ? cfg.model : undefined, fallbackModels: parseStringArray(cfg.fallbackModels), thinking: typeof cfg.thinking === "string" ? cfg.thinking : undefined, tools: parseStringArray(cfg.tools), extensions: hasOwn(cfg, "extensions") ? parseStringArray(cfg.extensions) ?? [] : undefined, skills: parseStringArray(cfg.skills), systemPromptMode: cfg.systemPromptMode === "append" ? "append" : "replace", inheritProjectContext: cfg.inheritProjectContext === true, inheritSkills: cfg.inheritSkills === true, routing: parseRouting(cfg), }; content = serializeAgent(agent); } else if (params.resource === "team") { const parsedRoles = parseRoles(cfg.roles); if (parsedRoles.error) return result(parsedRoles.error, "error", true); content = serializeTeam({ name, description: descriptionValue.value!, source: scope, filePath, roles: parsedRoles.roles!, defaultWorkflow: typeof cfg.defaultWorkflow === "string" ? sanitizeName(cfg.defaultWorkflow) : undefined, workspaceMode: cfg.workspaceMode === "worktree" ? "worktree" : "single", maxConcurrency: typeof cfg.maxConcurrency === "number" && Number.isInteger(cfg.maxConcurrency) && cfg.maxConcurrency > 0 ? cfg.maxConcurrency : undefined, routing: parseRouting(cfg), }); } else { const parsedSteps = parseSteps(cfg.steps); if (parsedSteps.error) return result(parsedSteps.error, "error", true); content = serializeWorkflow({ name, description: descriptionValue.value!, source: scope, filePath, maxConcurrency: parseWorkflowMaxConcurrency(cfg.maxConcurrency), steps: parsedSteps.steps!, }); } if (params.dryRun) return result(`[dry-run] Would create ${params.resource} '${name}' at ${filePath}:\n\n${content}`); try { fs.writeFileSync(filePath, content, "utf-8"); } catch (writeError) { return result(`Failed to create ${params.resource}: ${writeError instanceof Error ? writeError.message : String(writeError)}`, "error", true); } return result(`Created ${params.resource} '${name}' at ${filePath}.`); } export function handleUpdate(params: TeamToolParamsValue, ctx: ManagementContext): PiTeamsToolResult { const resolved = resolveMutable(ctx, params); if (resolved.error) return resolved.error; const parsed = parseConfigObject(params.config); if (parsed.error) return result(parsed.error, "error", true); const cfg = parsed.value!; const current = resolved.resource!; const nextName = hasOwn(cfg, "name") ? sanitizeName(String(cfg.name ?? "")) : current.name; if (!nextName) return result("config.name is invalid after sanitization.", "error", true); const source = current.source === "project" ? "project" : "user"; const nextPath = targetPath(ctx, params.resource!, source, nextName); if (nextPath !== current.filePath && fs.existsSync(nextPath)) return result(`Target file already exists: ${nextPath}`, "error", true); let content: string; if (params.resource === "agent") { const agent = current as AgentConfig; content = serializeAgent({ ...agent, name: nextName, filePath: nextPath, description: typeof cfg.description === "string" && cfg.description.trim() ? cfg.description.trim() : agent.description, systemPrompt: typeof cfg.systemPrompt === "string" ? cfg.systemPrompt : agent.systemPrompt, model: hasOwn(cfg, "model") ? (typeof cfg.model === "string" && cfg.model.trim() ? cfg.model.trim() : undefined) : agent.model, fallbackModels: hasOwn(cfg, "fallbackModels") ? parseStringArray(cfg.fallbackModels) : agent.fallbackModels, thinking: hasOwn(cfg, "thinking") ? (typeof cfg.thinking === "string" && cfg.thinking.trim() ? cfg.thinking.trim() : undefined) : agent.thinking, tools: hasOwn(cfg, "tools") ? parseStringArray(cfg.tools) : agent.tools, extensions: hasOwn(cfg, "extensions") ? parseStringArray(cfg.extensions) ?? [] : agent.extensions, skills: hasOwn(cfg, "skills") ? parseStringArray(cfg.skills) : agent.skills, systemPromptMode: cfg.systemPromptMode === "append" ? "append" : cfg.systemPromptMode === "replace" ? "replace" : agent.systemPromptMode, inheritProjectContext: typeof cfg.inheritProjectContext === "boolean" ? cfg.inheritProjectContext : agent.inheritProjectContext, inheritSkills: typeof cfg.inheritSkills === "boolean" ? cfg.inheritSkills : agent.inheritSkills, routing: parseRouting(cfg, agent.routing), }); } else if (params.resource === "team") { const team = current as TeamConfig; let roles = team.roles; if (hasOwn(cfg, "roles")) { const parsedRoles = parseRoles(cfg.roles); if (parsedRoles.error) return result(parsedRoles.error, "error", true); roles = parsedRoles.roles!; } content = serializeTeam({ ...team, name: nextName, filePath: nextPath, description: typeof cfg.description === "string" && cfg.description.trim() ? cfg.description.trim() : team.description, roles, defaultWorkflow: hasOwn(cfg, "defaultWorkflow") ? (typeof cfg.defaultWorkflow === "string" ? sanitizeName(cfg.defaultWorkflow) : undefined) : team.defaultWorkflow, workspaceMode: cfg.workspaceMode === "worktree" ? "worktree" : cfg.workspaceMode === "single" ? "single" : team.workspaceMode, maxConcurrency: typeof cfg.maxConcurrency === "number" && Number.isInteger(cfg.maxConcurrency) && cfg.maxConcurrency > 0 ? cfg.maxConcurrency : team.maxConcurrency, routing: parseRouting(cfg, team.routing), }); } else { const workflow = current as WorkflowConfig; let steps = workflow.steps; if (hasOwn(cfg, "steps")) { const parsedSteps = parseSteps(cfg.steps); if (parsedSteps.error) return result(parsedSteps.error, "error", true); steps = parsedSteps.steps!; } content = serializeWorkflow({ ...workflow, name: nextName, filePath: nextPath, description: typeof cfg.description === "string" && cfg.description.trim() ? cfg.description.trim() : workflow.description, maxConcurrency: hasOwn(cfg, "maxConcurrency") ? parseWorkflowMaxConcurrency(cfg.maxConcurrency) : workflow.maxConcurrency, steps, }); } const referenceUpdates = params.updateReferences ? updateReferencesForRename(ctx, params.resource!, current.name, nextName, source, true) : []; if (params.dryRun) { return result([`[dry-run] Would update ${params.resource} at ${current.filePath}:`, "", content, ...(referenceUpdates.length ? ["", "Would update references in:", ...referenceUpdates.map((filePath) => `- ${filePath}`)] : [])].join("\n")); } const backupPath = backupFile(current.filePath); try { if (nextPath !== current.filePath) { try { fs.renameSync(current.filePath, nextPath); } catch (renameError) { if ((renameError as NodeJS.ErrnoException).code === "EXDEV") { fs.copyFileSync(current.filePath, nextPath); fs.unlinkSync(current.filePath); } else { throw renameError; } } } fs.writeFileSync(nextPath, content, "utf-8"); } catch (updateError) { return result(`Failed to update ${params.resource}: ${updateError instanceof Error ? updateError.message : String(updateError)}`, "error", true); } const updatedRefs = params.updateReferences ? updateReferencesForRename(ctx, params.resource!, current.name, nextName, source, false) : []; return result([`Updated ${params.resource} at ${nextPath}. Backup: ${backupPath}.`, ...(updatedRefs.length ? ["Updated references:", ...updatedRefs.map((filePath) => `- ${filePath}`)] : [])].join("\n")); } export function handleDelete(params: TeamToolParamsValue, ctx: ManagementContext): PiTeamsToolResult { if (!params.confirm) return result("delete requires confirm: true.", "error", true); const resolved = resolveMutable(ctx, params); if (resolved.error) return resolved.error; const refs = findReferences(ctx, params.resource!, resolved.resource!.name); if (refs.length > 0 && !params.force) { return result(`${params.resource} '${resolved.resource!.name}' is still referenced. Use force: true to delete anyway.\n${refs.map((ref) => `- ${ref}`).join("\n")}`, "error", true); } if (params.dryRun) return result(`[dry-run] Would delete ${params.resource} at ${resolved.resource!.filePath}.${refs.length ? `\nReferences:\n${refs.map((ref) => `- ${ref}`).join("\n")}` : ""}`); const backupPath = backupFile(resolved.resource!.filePath); try { fs.unlinkSync(resolved.resource!.filePath); } catch (deleteError) { return result(`Failed to delete ${params.resource}: ${deleteError instanceof Error ? deleteError.message : String(deleteError)}`, "error", true); } return result(`Deleted ${params.resource} at ${resolved.resource!.filePath}. Backup: ${backupPath}.`); }