takt/src/infra/config/loaders/pieceResolver.ts

336 lines
10 KiB
TypeScript

/**
* Piece resolution — 3-layer lookup logic.
*
* Resolves piece names and paths to concrete PieceConfig objects,
* using the priority chain: project-local → user → builtin.
*/
import { existsSync, readdirSync, statSync } from 'node:fs';
import { join, resolve, isAbsolute } from 'node:path';
import { homedir } from 'node:os';
import type { PieceConfig, PieceMovement } from '../../../core/models/index.js';
import { getGlobalPiecesDir, getBuiltinPiecesDir, getProjectConfigDir } from '../paths.js';
import { getLanguage, getDisabledBuiltins, getBuiltinPiecesEnabled } from '../global/globalConfig.js';
import { createLogger, getErrorMessage } from '../../../shared/utils/index.js';
import { loadPieceFromFile } from './pieceParser.js';
const log = createLogger('piece-resolver');
export type PieceSource = 'builtin' | 'user' | 'project';
export interface PieceWithSource {
config: PieceConfig;
source: PieceSource;
}
export function listBuiltinPieceNames(options?: { includeDisabled?: boolean }): string[] {
const lang = getLanguage();
const dir = getBuiltinPiecesDir(lang);
const disabled = options?.includeDisabled ? undefined : getDisabledBuiltins();
const names = new Set<string>();
for (const entry of iteratePieceDir(dir, 'builtin', disabled)) {
names.add(entry.name);
}
return Array.from(names);
}
/** Get builtin piece by name */
export function getBuiltinPiece(name: string): PieceConfig | null {
if (!getBuiltinPiecesEnabled()) return null;
const lang = getLanguage();
const disabled = getDisabledBuiltins();
if (disabled.includes(name)) return null;
const builtinDir = getBuiltinPiecesDir(lang);
const yamlPath = join(builtinDir, `${name}.yaml`);
if (existsSync(yamlPath)) {
return loadPieceFromFile(yamlPath);
}
return null;
}
/**
* Resolve a path that may be relative, absolute, or home-directory-relative.
*/
function resolvePath(pathInput: string, basePath: string): string {
if (pathInput.startsWith('~')) {
const home = homedir();
return resolve(home, pathInput.slice(1).replace(/^\//, ''));
}
if (isAbsolute(pathInput)) {
return pathInput;
}
return resolve(basePath, pathInput);
}
/**
* Load piece from a file path.
*/
function loadPieceFromPath(
filePath: string,
basePath: string,
): PieceConfig | null {
const resolvedPath = resolvePath(filePath, basePath);
if (!existsSync(resolvedPath)) {
return null;
}
return loadPieceFromFile(resolvedPath);
}
/**
* Resolve a piece YAML file path by trying both .yaml and .yml extensions.
* For category/name identifiers (e.g. "frontend/react"), resolves to
* {piecesDir}/frontend/react.yaml (or .yml).
*/
function resolvePieceFile(piecesDir: string, name: string): string | null {
for (const ext of ['.yaml', '.yml']) {
const filePath = join(piecesDir, `${name}${ext}`);
if (existsSync(filePath)) return filePath;
}
return null;
}
/**
* Load piece by name (name-based loading only, no path detection).
* Supports category/name identifiers (e.g. "frontend/react").
*
* Priority:
* 1. Project-local pieces → .takt/pieces/{name}.yaml
* 2. User pieces → ~/.takt/pieces/{name}.yaml
* 3. Builtin pieces → resources/global/{lang}/pieces/{name}.yaml
*/
export function loadPiece(
name: string,
projectCwd: string,
): PieceConfig | null {
const projectPiecesDir = join(getProjectConfigDir(projectCwd), 'pieces');
const projectMatch = resolvePieceFile(projectPiecesDir, name);
if (projectMatch) {
return loadPieceFromFile(projectMatch);
}
const globalPiecesDir = getGlobalPiecesDir();
const globalMatch = resolvePieceFile(globalPiecesDir, name);
if (globalMatch) {
return loadPieceFromFile(globalMatch);
}
return getBuiltinPiece(name);
}
/**
* Check if a piece identifier looks like a file path (vs a piece name).
*/
export function isPiecePath(identifier: string): boolean {
return (
identifier.startsWith('/') ||
identifier.startsWith('~') ||
identifier.startsWith('./') ||
identifier.startsWith('../') ||
identifier.endsWith('.yaml') ||
identifier.endsWith('.yml')
);
}
/**
* Load piece by identifier (auto-detects name vs path).
*/
export function loadPieceByIdentifier(
identifier: string,
projectCwd: string,
): PieceConfig | null {
if (isPiecePath(identifier)) {
return loadPieceFromPath(identifier, projectCwd);
}
return loadPiece(identifier, projectCwd);
}
/**
* Build workflow structure string from piece movements.
* Formats as numbered list with indented parallel sub-movements.
*
* @param movements - Piece movements list
* @returns Workflow structure string (newline-separated list)
*/
function buildWorkflowString(movements: PieceMovement[]): string {
if (!movements || movements.length === 0) return '';
const lines: string[] = [];
let index = 1;
for (const movement of movements) {
const desc = movement.description ? ` (${movement.description})` : '';
lines.push(`${index}. ${movement.name}${desc}`);
if (movement.parallel && movement.parallel.length > 0) {
for (const sub of movement.parallel) {
const subDesc = sub.description ? ` (${sub.description})` : '';
lines.push(` - ${sub.name}${subDesc}`);
}
}
index++;
}
return lines.join('\n');
}
/**
* Get piece description by identifier.
* Returns the piece name, description, and workflow structure.
*/
export function getPieceDescription(
identifier: string,
projectCwd: string,
): { name: string; description: string; pieceStructure: string } {
const piece = loadPieceByIdentifier(identifier, projectCwd);
if (!piece) {
return { name: identifier, description: '', pieceStructure: '' };
}
return {
name: piece.name,
description: piece.description ?? '',
pieceStructure: buildWorkflowString(piece.movements),
};
}
/** Entry for a piece file found in a directory */
export interface PieceDirEntry {
/** Piece name (e.g. "react") */
name: string;
/** Full file path */
path: string;
/** Category (subdirectory name), undefined for root-level pieces */
category?: string;
/** Piece source (builtin, user, project) */
source: PieceSource;
}
/**
* Iterate piece YAML files in a directory, yielding name, path, and category.
* Scans root-level files (no category) and 1-level subdirectories (category = dir name).
* Shared by both loadAllPieces and listPieces to avoid DRY violation.
*/
function* iteratePieceDir(
dir: string,
source: PieceSource,
disabled?: string[],
): Generator<PieceDirEntry> {
if (!existsSync(dir)) return;
for (const entry of readdirSync(dir)) {
const entryPath = join(dir, entry);
const stat = statSync(entryPath);
if (stat.isFile() && (entry.endsWith('.yaml') || entry.endsWith('.yml'))) {
const pieceName = entry.replace(/\.ya?ml$/, '');
if (disabled?.includes(pieceName)) continue;
yield { name: pieceName, path: entryPath, source };
continue;
}
// 1-level subdirectory scan: directory name becomes the category
if (stat.isDirectory()) {
const category = entry;
for (const subEntry of readdirSync(entryPath)) {
if (!subEntry.endsWith('.yaml') && !subEntry.endsWith('.yml')) continue;
const subEntryPath = join(entryPath, subEntry);
if (!statSync(subEntryPath).isFile()) continue;
const pieceName = subEntry.replace(/\.ya?ml$/, '');
const qualifiedName = `${category}/${pieceName}`;
if (disabled?.includes(qualifiedName)) continue;
yield { name: qualifiedName, path: subEntryPath, category, source };
}
}
}
}
/** Get the 3-layer directory list (builtin → user → project-local) */
function getPieceDirs(cwd: string): { dir: string; source: PieceSource; disabled?: string[] }[] {
const disabled = getDisabledBuiltins();
const lang = getLanguage();
const dirs: { dir: string; source: PieceSource; disabled?: string[] }[] = [];
if (getBuiltinPiecesEnabled()) {
dirs.push({ dir: getBuiltinPiecesDir(lang), disabled, source: 'builtin' });
}
dirs.push({ dir: getGlobalPiecesDir(), source: 'user' });
dirs.push({ dir: join(getProjectConfigDir(cwd), 'pieces'), source: 'project' });
return dirs;
}
/**
* Load all pieces with source metadata.
*
* Priority (later entries override earlier):
* 1. Builtin pieces
* 2. User pieces (~/.takt/pieces/)
* 3. Project-local pieces (.takt/pieces/)
*/
export function loadAllPiecesWithSources(cwd: string): Map<string, PieceWithSource> {
const pieces = new Map<string, PieceWithSource>();
for (const { dir, source, disabled } of getPieceDirs(cwd)) {
for (const entry of iteratePieceDir(dir, source, disabled)) {
try {
pieces.set(entry.name, { config: loadPieceFromFile(entry.path), source: entry.source });
} catch (err) {
log.debug('Skipping invalid piece file', { path: entry.path, error: getErrorMessage(err) });
}
}
}
return pieces;
}
/**
* Load all pieces with descriptions (for switch command).
*
* Priority (later entries override earlier):
* 1. Builtin pieces
* 2. User pieces (~/.takt/pieces/)
* 3. Project-local pieces (.takt/pieces/)
*/
export function loadAllPieces(cwd: string): Map<string, PieceConfig> {
const pieces = new Map<string, PieceConfig>();
const withSources = loadAllPiecesWithSources(cwd);
for (const [name, entry] of withSources) {
pieces.set(name, entry.config);
}
return pieces;
}
/**
* List available piece names (builtin + user + project-local, excluding disabled).
* Category pieces use qualified names like "frontend/react".
*/
export function listPieces(cwd: string): string[] {
const pieces = new Set<string>();
for (const { dir, source, disabled } of getPieceDirs(cwd)) {
for (const entry of iteratePieceDir(dir, source, disabled)) {
pieces.add(entry.name);
}
}
return Array.from(pieces).sort();
}
/**
* List available pieces with category information for UI display.
* Returns entries grouped by category for 2-stage selection.
*
* Root-level pieces (no category) and category names are presented
* at the same level. Selecting a category drills into its pieces.
*/
export function listPieceEntries(cwd: string): PieceDirEntry[] {
// Later entries override earlier (project-local > user > builtin)
const pieces = new Map<string, PieceDirEntry>();
for (const { dir, source, disabled } of getPieceDirs(cwd)) {
for (const entry of iteratePieceDir(dir, source, disabled)) {
pieces.set(entry.name, entry);
}
}
return Array.from(pieces.values());
}