takt/src/features/config/ejectBuiltin.ts
2026-02-07 09:06:49 +09:00

166 lines
5.3 KiB
TypeScript

/**
* /eject command implementation
*
* Copies a builtin piece (and its personas/stances/instructions) for user customization.
* Directory structure is mirrored so relative paths work as-is.
*
* Default target: project-local (.takt/)
* With --global: user global (~/.takt/)
*/
import { existsSync, readdirSync, statSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
import { join, dirname } from 'node:path';
import {
getGlobalPiecesDir,
getGlobalPersonasDir,
getProjectPiecesDir,
getProjectPersonasDir,
getBuiltinPiecesDir,
getLanguage,
} from '../../infra/config/index.js';
import { getLanguageResourcesDir } from '../../infra/resources/index.js';
import { header, success, info, warn, error, blankLine } from '../../shared/ui/index.js';
export interface EjectOptions {
global?: boolean;
projectDir?: string;
}
/**
* Eject a builtin piece to project or global space for customization.
* Copies the piece YAML and related agent .md files, preserving
* the directory structure so relative paths continue to work.
*/
export async function ejectBuiltin(name?: string, options: EjectOptions = {}): Promise<void> {
header('Eject Builtin');
const lang = getLanguage();
const builtinPiecesDir = getBuiltinPiecesDir(lang);
if (!name) {
// List available builtins
listAvailableBuiltins(builtinPiecesDir, options.global);
return;
}
const builtinPath = join(builtinPiecesDir, `${name}.yaml`);
if (!existsSync(builtinPath)) {
error(`Builtin piece not found: ${name}`);
info('Run "takt eject" to see available builtins.');
return;
}
const projectDir = options.projectDir || process.cwd();
const targetPiecesDir = options.global ? getGlobalPiecesDir() : getProjectPiecesDir(projectDir);
const targetBaseDir = options.global ? dirname(getGlobalPersonasDir()) : dirname(getProjectPersonasDir(projectDir));
const builtinBaseDir = getLanguageResourcesDir(lang);
const targetLabel = options.global ? 'global (~/.takt/)' : 'project (.takt/)';
info(`Ejecting to ${targetLabel}`);
blankLine();
// Copy piece YAML as-is (no path rewriting — directory structure mirrors builtin)
const pieceDest = join(targetPiecesDir, `${name}.yaml`);
if (existsSync(pieceDest)) {
warn(`User piece already exists: ${pieceDest}`);
warn('Skipping piece copy (user version takes priority).');
} else {
mkdirSync(dirname(pieceDest), { recursive: true });
const content = readFileSync(builtinPath, 'utf-8');
writeFileSync(pieceDest, content, 'utf-8');
success(`Ejected piece: ${pieceDest}`);
}
// Copy related resource files (agents, personas, stances, instructions, report-formats)
const resourceRefs = extractResourceRelativePaths(builtinPath);
let copiedCount = 0;
for (const ref of resourceRefs) {
const srcPath = join(builtinBaseDir, ref.type, ref.path);
const destPath = join(targetBaseDir, ref.type, ref.path);
if (!existsSync(srcPath)) continue;
if (existsSync(destPath)) {
info(` Already exists: ${destPath}`);
continue;
}
mkdirSync(dirname(destPath), { recursive: true });
writeFileSync(destPath, readFileSync(srcPath));
info(` ${destPath}`);
copiedCount++;
}
if (copiedCount > 0) {
success(`${copiedCount} resource file(s) ejected.`);
}
}
/** List available builtin pieces for ejection */
function listAvailableBuiltins(builtinPiecesDir: string, isGlobal?: boolean): void {
if (!existsSync(builtinPiecesDir)) {
warn('No builtin pieces found.');
return;
}
info('Available builtin pieces:');
blankLine();
for (const entry of readdirSync(builtinPiecesDir).sort()) {
if (!entry.endsWith('.yaml') && !entry.endsWith('.yml')) continue;
if (!statSync(join(builtinPiecesDir, entry)).isFile()) continue;
const name = entry.replace(/\.ya?ml$/, '');
info(` ${name}`);
}
blankLine();
const globalFlag = isGlobal ? ' --global' : '';
info(`Usage: takt eject {name}${globalFlag}`);
if (!isGlobal) {
info(' Add --global to eject to ~/.takt/ instead of .takt/');
}
}
/** Resource reference extracted from piece YAML */
interface ResourceRef {
/** Resource type directory (personas, stances, instructions, report-formats) */
type: string;
/** Relative path within the resource type directory */
path: string;
}
/** Known resource type directories that can be referenced from piece YAML */
const RESOURCE_TYPES = ['personas', 'stances', 'instructions', 'report-formats'];
/**
* Extract resource relative paths from a builtin piece YAML.
* Matches `../{type}/{path}` patterns for all known resource types.
*/
function extractResourceRelativePaths(piecePath: string): ResourceRef[] {
const content = readFileSync(piecePath, 'utf-8');
const seen = new Set<string>();
const refs: ResourceRef[] = [];
const typePattern = RESOURCE_TYPES.join('|');
const regex = new RegExp(`\\.\\.\\/(?:${typePattern})\\/(.+)`, 'g');
let match: RegExpExecArray | null;
while ((match = regex.exec(content)) !== null) {
// Re-parse to extract type and path separately
const fullMatch = match[0];
const typeMatch = fullMatch.match(/\.\.\/([^/]+)\/(.+)/);
if (typeMatch?.[1] && typeMatch[2]) {
const type = typeMatch[1];
const path = typeMatch[2].trim();
const key = `${type}/${path}`;
if (!seen.has(key)) {
seen.add(key);
refs.push({ type, path });
}
}
}
return refs;
}