takt/src/features/config/deploySkill.ts

192 lines
6.7 KiB
TypeScript

/**
* takt export-cc — Deploy takt skill files to Claude Code.
*
* Copies the following to ~/.claude/skills/takt/:
* SKILL.md — Engine overview (user-invocable as /takt)
* references/ — Engine logic + YAML schema
* pieces/ — Builtin piece YAML files
* personas/ — Builtin persona .md files
* stances/ — Builtin stance files
* instructions/ — Builtin instruction files
* knowledge/ — Builtin knowledge files
* report-formats/ — Builtin report format files
* templates/ — Builtin template files
*
* Piece YAML persona paths (../personas/...) work as-is because
* the directory structure is mirrored.
*/
import { existsSync, readdirSync, readFileSync, writeFileSync, mkdirSync, rmSync, statSync } from 'node:fs';
import { homedir } from 'node:os';
import { join, dirname, relative } from 'node:path';
import { getLanguage } from '../../infra/config/index.js';
import { getResourcesDir, getLanguageResourcesDir } from '../../infra/resources/index.js';
import { confirm } from '../../shared/prompt/index.js';
import { header, success, info, warn, blankLine } from '../../shared/ui/index.js';
/** Files to skip during directory copy */
const SKIP_FILES = new Set(['.DS_Store', 'Thumbs.db']);
/** Target paths under ~/.claude/ */
function getSkillDir(): string {
return join(homedir(), '.claude', 'skills', 'takt');
}
/** Directories within builtins/{lang}/ to copy as resource types */
const RESOURCE_DIRS = [
'pieces',
'personas',
'stances',
'instructions',
'knowledge',
'report-formats',
'templates',
] as const;
/**
* Deploy takt skill to Claude Code (~/.claude/).
*/
export async function deploySkill(): Promise<void> {
header('takt export-cc — Deploy to Claude Code');
const lang = getLanguage();
const skillResourcesDir = join(getResourcesDir(), 'skill');
const langResourcesDir = getLanguageResourcesDir(lang);
const skillDir = getSkillDir();
// Verify source directories exist
if (!existsSync(skillResourcesDir)) {
warn('Skill resources not found. Ensure takt is installed correctly.');
return;
}
// Check if skill already exists and ask for confirmation
const skillExists = existsSync(join(skillDir, 'SKILL.md'));
if (skillExists) {
info('Claude Code Skill が既にインストールされています。');
const overwrite = await confirm(
'既存のスキルファイルをすべて削除し、最新版に置き換えます。続行しますか?',
false,
);
if (!overwrite) {
info('キャンセルしました。');
return;
}
blankLine();
}
const copiedFiles: string[] = [];
// 1. Deploy SKILL.md
const skillSrc = join(skillResourcesDir, 'SKILL.md');
const skillDest = join(skillDir, 'SKILL.md');
copyFile(skillSrc, skillDest, copiedFiles);
// 2. Deploy references/ (engine.md, yaml-schema.md)
const refsSrcDir = join(skillResourcesDir, 'references');
const refsDestDir = join(skillDir, 'references');
cleanDir(refsDestDir);
copyDirRecursive(refsSrcDir, refsDestDir, copiedFiles);
// 3. Deploy all resource directories from builtins/{lang}/
for (const resourceDir of RESOURCE_DIRS) {
const srcDir = join(langResourcesDir, resourceDir);
const destDir = join(skillDir, resourceDir);
cleanDir(destDir);
copyDirRecursive(srcDir, destDir, copiedFiles);
}
// Report results
blankLine();
if (copiedFiles.length > 0) {
success(`${copiedFiles.length} ファイルをデプロイしました。`);
blankLine();
// Show summary by category
const skillBase = join(homedir(), '.claude');
const skillFiles = copiedFiles.filter(
(f) =>
f.startsWith(skillDir) &&
!RESOURCE_DIRS.some((dir) => f.includes(`/${dir}/`)),
);
const pieceFiles = copiedFiles.filter((f) => f.includes('/pieces/'));
const personaFiles = copiedFiles.filter((f) => f.includes('/personas/'));
const stanceFiles = copiedFiles.filter((f) => f.includes('/stances/'));
const instructionFiles = copiedFiles.filter((f) => f.includes('/instructions/'));
const knowledgeFiles = copiedFiles.filter((f) => f.includes('/knowledge/'));
const reportFormatFiles = copiedFiles.filter((f) => f.includes('/report-formats/'));
const templateFiles = copiedFiles.filter((f) => f.includes('/templates/'));
if (skillFiles.length > 0) {
info(` スキル: ${skillFiles.length} ファイル`);
for (const f of skillFiles) {
info(` ${relative(skillBase, f)}`);
}
}
if (pieceFiles.length > 0) {
info(` ピース: ${pieceFiles.length} ファイル`);
}
if (personaFiles.length > 0) {
info(` ペルソナ: ${personaFiles.length} ファイル`);
}
if (stanceFiles.length > 0) {
info(` スタンス: ${stanceFiles.length} ファイル`);
}
if (instructionFiles.length > 0) {
info(` インストラクション: ${instructionFiles.length} ファイル`);
}
if (knowledgeFiles.length > 0) {
info(` ナレッジ: ${knowledgeFiles.length} ファイル`);
}
if (reportFormatFiles.length > 0) {
info(` レポート形式: ${reportFormatFiles.length} ファイル`);
}
if (templateFiles.length > 0) {
info(` テンプレート: ${templateFiles.length} ファイル`);
}
blankLine();
info('使い方: /takt <piece-name> <task>');
info('例: /takt passthrough "Hello World テスト"');
} else {
info('デプロイするファイルがありませんでした。');
}
}
/** Remove a directory and all its contents so stale files don't persist across deploys. */
function cleanDir(dir: string): void {
if (existsSync(dir)) {
rmSync(dir, { recursive: true });
}
}
/** Copy a single file, creating parent directories as needed. */
function copyFile(src: string, dest: string, copiedFiles: string[]): void {
if (!existsSync(src)) return;
mkdirSync(dirname(dest), { recursive: true });
writeFileSync(dest, readFileSync(src));
copiedFiles.push(dest);
}
/** Recursively copy directory contents, always overwriting. */
function copyDirRecursive(srcDir: string, destDir: string, copiedFiles: string[]): void {
if (!existsSync(srcDir)) return;
mkdirSync(destDir, { recursive: true });
for (const entry of readdirSync(srcDir)) {
if (SKIP_FILES.has(entry)) continue;
const srcPath = join(srcDir, entry);
const destPath = join(destDir, entry);
const stat = statSync(srcPath);
if (stat.isDirectory()) {
copyDirRecursive(srcPath, destPath, copiedFiles);
} else {
writeFileSync(destPath, readFileSync(srcPath));
copiedFiles.push(destPath);
}
}
}