makecc 0.2.5 → 0.2.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,384 @@
1
+ import * as fs from 'fs/promises';
2
+ import * as path from 'path';
3
+
4
+ interface NodeData {
5
+ id: string;
6
+ type: 'skill' | 'subagent' | 'command' | 'hook' | 'input' | 'output';
7
+ label: string;
8
+ description?: string;
9
+ // Skill specific
10
+ skillId?: string;
11
+ skillPath?: string;
12
+ // Subagent specific
13
+ tools?: string[];
14
+ model?: string;
15
+ skills?: string[]; // Connected skills
16
+ systemPrompt?: string;
17
+ // Command specific
18
+ commandName?: string;
19
+ commandContent?: string;
20
+ // Hook specific
21
+ hookEvent?: string;
22
+ hookMatcher?: string;
23
+ hookCommand?: string;
24
+ }
25
+
26
+ interface EdgeData {
27
+ source: string;
28
+ target: string;
29
+ sourceType: string;
30
+ targetType: string;
31
+ }
32
+
33
+ export class NodeSyncService {
34
+ private projectRoot: string;
35
+
36
+ constructor(projectRoot?: string) {
37
+ this.projectRoot = projectRoot || process.env.MAKECC_PROJECT_PATH || process.cwd();
38
+ }
39
+
40
+ /**
41
+ * 노드 생성/수정 시 파일 동기화
42
+ */
43
+ async syncNode(node: NodeData): Promise<{ success: boolean; path?: string; error?: string }> {
44
+ try {
45
+ switch (node.type) {
46
+ case 'skill':
47
+ return await this.syncSkillNode(node);
48
+ case 'subagent':
49
+ return await this.syncSubagentNode(node);
50
+ case 'command':
51
+ return await this.syncCommandNode(node);
52
+ case 'hook':
53
+ return await this.syncHookNode(node);
54
+ case 'input':
55
+ case 'output':
56
+ // 입력/출력 노드는 파일 저장 불필요
57
+ return { success: true };
58
+ default:
59
+ return { success: false, error: `Unknown node type: ${node.type}` };
60
+ }
61
+ } catch (error) {
62
+ const errorMessage = error instanceof Error ? error.message : 'Unknown error';
63
+ return { success: false, error: errorMessage };
64
+ }
65
+ }
66
+
67
+ /**
68
+ * 노드 삭제 시 파일 삭제
69
+ */
70
+ async deleteNode(node: NodeData): Promise<{ success: boolean; error?: string }> {
71
+ try {
72
+ switch (node.type) {
73
+ case 'skill':
74
+ if (node.skillId) {
75
+ const skillPath = path.join(this.projectRoot, '.claude', 'skills', node.skillId);
76
+ await fs.rm(skillPath, { recursive: true, force: true });
77
+ }
78
+ break;
79
+ case 'subagent':
80
+ const agentName = this.toKebabCase(node.label);
81
+ const agentPath = path.join(this.projectRoot, '.claude', 'agents', `${agentName}.md`);
82
+ await fs.unlink(agentPath).catch(() => {});
83
+ break;
84
+ case 'command':
85
+ const cmdName = node.commandName || this.toKebabCase(node.label);
86
+ const cmdPath = path.join(this.projectRoot, '.claude', 'commands', `${cmdName}.md`);
87
+ await fs.unlink(cmdPath).catch(() => {});
88
+ break;
89
+ case 'hook':
90
+ await this.removeHookFromSettings(node);
91
+ break;
92
+ }
93
+ return { success: true };
94
+ } catch (error) {
95
+ const errorMessage = error instanceof Error ? error.message : 'Unknown error';
96
+ return { success: false, error: errorMessage };
97
+ }
98
+ }
99
+
100
+ /**
101
+ * 엣지 연결 시 관계 업데이트
102
+ */
103
+ async syncEdge(edge: EdgeData, nodes: NodeData[]): Promise<{ success: boolean; error?: string }> {
104
+ try {
105
+ const sourceNode = nodes.find(n => n.id === edge.source);
106
+ const targetNode = nodes.find(n => n.id === edge.target);
107
+
108
+ if (!sourceNode || !targetNode) {
109
+ return { success: false, error: 'Source or target node not found' };
110
+ }
111
+
112
+ // 서브에이전트 → 스킬 연결: 서브에이전트의 skills 필드 업데이트
113
+ if (sourceNode.type === 'subagent' && targetNode.type === 'skill') {
114
+ const skills = sourceNode.skills || [];
115
+ const skillId = targetNode.skillId || this.toKebabCase(targetNode.label);
116
+ if (!skills.includes(skillId)) {
117
+ skills.push(skillId);
118
+ sourceNode.skills = skills;
119
+ await this.syncSubagentNode(sourceNode);
120
+ }
121
+ }
122
+
123
+ // 스킬 → 서브에이전트 연결: 서브에이전트의 skills 필드 업데이트
124
+ if (sourceNode.type === 'skill' && targetNode.type === 'subagent') {
125
+ const skills = targetNode.skills || [];
126
+ const skillId = sourceNode.skillId || this.toKebabCase(sourceNode.label);
127
+ if (!skills.includes(skillId)) {
128
+ skills.push(skillId);
129
+ targetNode.skills = skills;
130
+ await this.syncSubagentNode(targetNode);
131
+ }
132
+ }
133
+
134
+ return { success: true };
135
+ } catch (error) {
136
+ const errorMessage = error instanceof Error ? error.message : 'Unknown error';
137
+ return { success: false, error: errorMessage };
138
+ }
139
+ }
140
+
141
+ /**
142
+ * 엣지 삭제 시 관계 업데이트
143
+ */
144
+ async removeEdge(edge: EdgeData, nodes: NodeData[]): Promise<{ success: boolean; error?: string }> {
145
+ try {
146
+ const sourceNode = nodes.find(n => n.id === edge.source);
147
+ const targetNode = nodes.find(n => n.id === edge.target);
148
+
149
+ if (!sourceNode || !targetNode) {
150
+ return { success: true }; // 노드가 없으면 무시
151
+ }
152
+
153
+ // 서브에이전트에서 스킬 제거
154
+ if (sourceNode.type === 'subagent' && targetNode.type === 'skill') {
155
+ const skillId = targetNode.skillId || this.toKebabCase(targetNode.label);
156
+ sourceNode.skills = (sourceNode.skills || []).filter(s => s !== skillId);
157
+ await this.syncSubagentNode(sourceNode);
158
+ }
159
+
160
+ if (sourceNode.type === 'skill' && targetNode.type === 'subagent') {
161
+ const skillId = sourceNode.skillId || this.toKebabCase(sourceNode.label);
162
+ targetNode.skills = (targetNode.skills || []).filter(s => s !== skillId);
163
+ await this.syncSubagentNode(targetNode);
164
+ }
165
+
166
+ return { success: true };
167
+ } catch (error) {
168
+ const errorMessage = error instanceof Error ? error.message : 'Unknown error';
169
+ return { success: false, error: errorMessage };
170
+ }
171
+ }
172
+
173
+ // ===== Private Methods =====
174
+
175
+ private async syncSkillNode(node: NodeData): Promise<{ success: boolean; path?: string; error?: string }> {
176
+ const skillId = node.skillId || this.toKebabCase(node.label);
177
+ const skillPath = path.join(this.projectRoot, '.claude', 'skills', skillId);
178
+ const skillMdPath = path.join(skillPath, 'SKILL.md');
179
+
180
+ await fs.mkdir(skillPath, { recursive: true });
181
+
182
+ // 기존 파일이 있으면 읽어서 업데이트, 없으면 새로 생성
183
+ let content = '';
184
+ try {
185
+ content = await fs.readFile(skillMdPath, 'utf-8');
186
+ // frontmatter 업데이트
187
+ content = this.updateFrontmatter(content, {
188
+ name: skillId,
189
+ description: node.description || node.label,
190
+ });
191
+ } catch {
192
+ // 새로 생성
193
+ content = `---
194
+ name: ${skillId}
195
+ description: ${node.description || node.label}
196
+ ---
197
+
198
+ # ${node.label}
199
+
200
+ ## 사용 시점
201
+ 이 스킬은 다음 상황에서 사용됩니다:
202
+ - ${node.description || '설명을 추가하세요'}
203
+
204
+ ## 사용 방법
205
+
206
+ \`\`\`bash
207
+ # 스킬 사용 방법을 여기에 작성하세요
208
+ \`\`\`
209
+ `;
210
+ }
211
+
212
+ await fs.writeFile(skillMdPath, content, 'utf-8');
213
+ return { success: true, path: skillPath };
214
+ }
215
+
216
+ private async syncSubagentNode(node: NodeData): Promise<{ success: boolean; path?: string; error?: string }> {
217
+ const agentName = this.toKebabCase(node.label);
218
+ const agentsDir = path.join(this.projectRoot, '.claude', 'agents');
219
+ const agentPath = path.join(agentsDir, `${agentName}.md`);
220
+
221
+ await fs.mkdir(agentsDir, { recursive: true });
222
+
223
+ // Frontmatter 구성
224
+ const frontmatter: Record<string, string> = {
225
+ name: agentName,
226
+ description: node.description || node.label,
227
+ };
228
+
229
+ if (node.tools && node.tools.length > 0) {
230
+ frontmatter.tools = node.tools.join(', ');
231
+ }
232
+
233
+ if (node.model) {
234
+ frontmatter.model = node.model;
235
+ }
236
+
237
+ if (node.skills && node.skills.length > 0) {
238
+ frontmatter.skills = node.skills.join(', ');
239
+ }
240
+
241
+ const frontmatterStr = Object.entries(frontmatter)
242
+ .map(([key, value]) => `${key}: ${value}`)
243
+ .join('\n');
244
+
245
+ const content = `---
246
+ ${frontmatterStr}
247
+ ---
248
+
249
+ ${node.systemPrompt || `You are ${node.label}.
250
+
251
+ ${node.description || ''}
252
+ `}
253
+ `;
254
+
255
+ await fs.writeFile(agentPath, content, 'utf-8');
256
+ return { success: true, path: agentPath };
257
+ }
258
+
259
+ private async syncCommandNode(node: NodeData): Promise<{ success: boolean; path?: string; error?: string }> {
260
+ const cmdName = node.commandName || this.toKebabCase(node.label);
261
+ const commandsDir = path.join(this.projectRoot, '.claude', 'commands');
262
+ const cmdPath = path.join(commandsDir, `${cmdName}.md`);
263
+
264
+ await fs.mkdir(commandsDir, { recursive: true });
265
+
266
+ const content = node.commandContent || `---
267
+ description: ${node.description || node.label}
268
+ ---
269
+
270
+ ${node.description || '커맨드 내용을 여기에 작성하세요'}
271
+
272
+ $ARGUMENTS
273
+ `;
274
+
275
+ await fs.writeFile(cmdPath, content, 'utf-8');
276
+ return { success: true, path: cmdPath };
277
+ }
278
+
279
+ private async syncHookNode(node: NodeData): Promise<{ success: boolean; path?: string; error?: string }> {
280
+ const settingsPath = path.join(this.projectRoot, '.claude', 'settings.json');
281
+
282
+ // 기존 settings 읽기
283
+ let settings: Record<string, unknown> = {};
284
+ try {
285
+ const content = await fs.readFile(settingsPath, 'utf-8');
286
+ settings = JSON.parse(content);
287
+ } catch {
288
+ // 파일 없음
289
+ }
290
+
291
+ // hooks 섹션 확인/생성
292
+ if (!settings.hooks) {
293
+ settings.hooks = {};
294
+ }
295
+
296
+ const hooks = settings.hooks as Record<string, unknown[]>;
297
+ const event = node.hookEvent || 'PreToolUse';
298
+
299
+ if (!hooks[event]) {
300
+ hooks[event] = [];
301
+ }
302
+
303
+ // 기존 훅 찾기 (같은 matcher로)
304
+ const eventHooks = hooks[event] as Array<{ matcher: string; hooks: Array<{ type: string; command: string }> }>;
305
+ const existingIndex = eventHooks.findIndex(h => h.matcher === (node.hookMatcher || '*'));
306
+
307
+ const hookConfig = {
308
+ matcher: node.hookMatcher || '*',
309
+ hooks: [
310
+ {
311
+ type: 'command',
312
+ command: node.hookCommand || 'echo "Hook triggered"',
313
+ },
314
+ ],
315
+ };
316
+
317
+ if (existingIndex >= 0) {
318
+ eventHooks[existingIndex] = hookConfig;
319
+ } else {
320
+ eventHooks.push(hookConfig);
321
+ }
322
+
323
+ // .claude 디렉토리 생성
324
+ await fs.mkdir(path.dirname(settingsPath), { recursive: true });
325
+ await fs.writeFile(settingsPath, JSON.stringify(settings, null, 2), 'utf-8');
326
+
327
+ return { success: true, path: settingsPath };
328
+ }
329
+
330
+ private async removeHookFromSettings(node: NodeData): Promise<void> {
331
+ const settingsPath = path.join(this.projectRoot, '.claude', 'settings.json');
332
+
333
+ let settings: Record<string, unknown> = {};
334
+ try {
335
+ const content = await fs.readFile(settingsPath, 'utf-8');
336
+ settings = JSON.parse(content);
337
+ } catch {
338
+ return;
339
+ }
340
+
341
+ const hooks = settings.hooks as Record<string, unknown[]> | undefined;
342
+ if (!hooks) return;
343
+
344
+ const event = node.hookEvent || 'PreToolUse';
345
+ if (!hooks[event]) return;
346
+
347
+ const eventHooks = hooks[event] as Array<{ matcher: string }>;
348
+ hooks[event] = eventHooks.filter(h => h.matcher !== (node.hookMatcher || '*'));
349
+
350
+ await fs.writeFile(settingsPath, JSON.stringify(settings, null, 2), 'utf-8');
351
+ }
352
+
353
+ private updateFrontmatter(content: string, updates: Record<string, string>): string {
354
+ const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---/);
355
+ if (!frontmatterMatch) {
356
+ // frontmatter 없으면 추가
357
+ const fm = Object.entries(updates)
358
+ .map(([k, v]) => `${k}: ${v}`)
359
+ .join('\n');
360
+ return `---\n${fm}\n---\n\n${content}`;
361
+ }
362
+
363
+ let frontmatter = frontmatterMatch[1];
364
+ for (const [key, value] of Object.entries(updates)) {
365
+ const regex = new RegExp(`^${key}:.*$`, 'm');
366
+ if (regex.test(frontmatter)) {
367
+ frontmatter = frontmatter.replace(regex, `${key}: ${value}`);
368
+ } else {
369
+ frontmatter += `\n${key}: ${value}`;
370
+ }
371
+ }
372
+
373
+ return content.replace(/^---\n[\s\S]*?\n---/, `---\n${frontmatter}\n---`);
374
+ }
375
+
376
+ private toKebabCase(str: string): string {
377
+ return str
378
+ .toLowerCase()
379
+ .replace(/[^a-z0-9가-힣]+/g, '-')
380
+ .replace(/^-|-$/g, '');
381
+ }
382
+ }
383
+
384
+ export const nodeSyncService = new NodeSyncService();
@@ -1,6 +1,8 @@
1
1
  import Anthropic from '@anthropic-ai/sdk';
2
2
  import * as fs from 'fs/promises';
3
3
  import * as path from 'path';
4
+ import { existsSync } from 'fs';
5
+ import { spawn } from 'child_process';
4
6
  import type { ApiSettings } from './workflowAIService';
5
7
 
6
8
  export interface GeneratedSkill {
@@ -21,6 +23,24 @@ export interface SkillGenerationResult {
21
23
  error?: string;
22
24
  }
23
25
 
26
+ export type SkillProgressStep =
27
+ | 'started'
28
+ | 'analyzing'
29
+ | 'designing'
30
+ | 'generating'
31
+ | 'saving'
32
+ | 'installing'
33
+ | 'completed'
34
+ | 'error';
35
+
36
+ export interface SkillProgressEvent {
37
+ step: SkillProgressStep;
38
+ message: string;
39
+ detail?: string;
40
+ }
41
+
42
+ export type SkillProgressCallback = (event: SkillProgressEvent) => void;
43
+
24
44
  const SYSTEM_PROMPT = `You are a Claude Code skill generator. You MUST respond with ONLY a valid JSON object. No markdown, no code blocks, no explanations - just pure JSON.
25
45
 
26
46
  Your response must follow this exact JSON schema:
@@ -106,14 +126,44 @@ export class SkillGeneratorService {
106
126
  return new Anthropic({ apiKey: envApiKey });
107
127
  }
108
128
 
109
- async generate(prompt: string, settings?: ApiSettings): Promise<SkillGenerationResult> {
129
+ async generate(
130
+ prompt: string,
131
+ settings?: ApiSettings,
132
+ onProgress?: SkillProgressCallback
133
+ ): Promise<SkillGenerationResult> {
134
+ const progress = onProgress || (() => {});
135
+
136
+ progress({
137
+ step: 'started',
138
+ message: '✨ 스킬 생성을 시작합니다',
139
+ detail: `요청: "${prompt.slice(0, 50)}${prompt.length > 50 ? '...' : ''}"`,
140
+ });
141
+
110
142
  const client = this.getClient(settings);
111
143
 
144
+ progress({
145
+ step: 'analyzing',
146
+ message: '🔍 요청을 분석하고 있어요',
147
+ detail: '어떤 스킬이 필요한지 파악 중...',
148
+ });
149
+
112
150
  const userPrompt = `Create a skill for: "${prompt}"
113
151
 
114
152
  Generate complete, working code. Respond with JSON only.`;
115
153
 
116
154
  try {
155
+ progress({
156
+ step: 'designing',
157
+ message: '📝 스킬 구조를 설계하고 있어요',
158
+ detail: 'AI가 최적의 스킬 구조를 결정 중...',
159
+ });
160
+
161
+ progress({
162
+ step: 'generating',
163
+ message: '⚙️ 코드를 생성하고 있어요',
164
+ detail: 'Python 스크립트와 설정 파일 작성 중...',
165
+ });
166
+
117
167
  const response = await client.messages.create({
118
168
  model: 'claude-sonnet-4-20250514',
119
169
  max_tokens: 8192,
@@ -132,6 +182,10 @@ Generate complete, working code. Respond with JSON only.`;
132
182
  }
133
183
 
134
184
  if (!responseText) {
185
+ progress({
186
+ step: 'error',
187
+ message: '❌ AI 응답을 받지 못했습니다',
188
+ });
135
189
  return { success: false, error: 'AI 응답을 받지 못했습니다.' };
136
190
  }
137
191
 
@@ -143,13 +197,52 @@ Generate complete, working code. Respond with JSON only.`;
143
197
  skill = JSON.parse(fullJson);
144
198
  } catch {
145
199
  console.error('Failed to parse skill response:', fullJson.slice(0, 500));
200
+ progress({
201
+ step: 'error',
202
+ message: '❌ AI 응답을 파싱할 수 없습니다',
203
+ detail: '다시 시도해주세요',
204
+ });
146
205
  return { success: false, error: 'AI 응답을 파싱할 수 없습니다. 다시 시도해주세요.' };
147
206
  }
148
207
 
208
+ progress({
209
+ step: 'saving',
210
+ message: '💾 파일을 저장하고 있어요',
211
+ detail: `${skill.files.length}개 파일 저장 중...`,
212
+ });
213
+
149
214
  // 파일 저장
150
215
  const skillPath = path.join(this.projectRoot, '.claude', 'skills', skill.skillId);
151
216
  await this.saveSkillFiles(skillPath, skill.files);
152
217
 
218
+ // requirements.txt가 있으면 의존성 설치
219
+ const requirementsPath = path.join(skillPath, 'requirements.txt');
220
+ if (existsSync(requirementsPath)) {
221
+ progress({
222
+ step: 'installing',
223
+ message: '📦 패키지를 설치하고 있어요',
224
+ detail: 'pip install 실행 중...',
225
+ });
226
+
227
+ try {
228
+ await this.installDependencies(requirementsPath, progress);
229
+ } catch (installError) {
230
+ // 설치 실패해도 스킬 생성은 성공으로 처리
231
+ console.error('Dependency installation failed:', installError);
232
+ progress({
233
+ step: 'installing',
234
+ message: '⚠️ 일부 패키지 설치에 실패했어요',
235
+ detail: '나중에 수동으로 설치해주세요',
236
+ });
237
+ }
238
+ }
239
+
240
+ progress({
241
+ step: 'completed',
242
+ message: '✅ 스킬이 생성되었습니다!',
243
+ detail: `${skill.skillName} → ${skillPath}`,
244
+ });
245
+
153
246
  return {
154
247
  success: true,
155
248
  skill,
@@ -158,6 +251,11 @@ Generate complete, working code. Respond with JSON only.`;
158
251
  } catch (error) {
159
252
  const errorMessage = error instanceof Error ? error.message : 'Unknown error';
160
253
  console.error('Skill generation error:', errorMessage);
254
+ progress({
255
+ step: 'error',
256
+ message: '❌ 스킬 생성에 실패했습니다',
257
+ detail: errorMessage,
258
+ });
161
259
  return { success: false, error: errorMessage };
162
260
  }
163
261
  }
@@ -181,6 +279,107 @@ Generate complete, working code. Respond with JSON only.`;
181
279
  console.log(`Saved: ${filePath}`);
182
280
  }
183
281
  }
282
+
283
+ private async installDependencies(
284
+ requirementsPath: string,
285
+ progress: SkillProgressCallback
286
+ ): Promise<void> {
287
+ return new Promise((resolve, reject) => {
288
+ const homeDir = process.env.HOME || process.env.USERPROFILE || '';
289
+ const venvPythonPath = path.join(homeDir, '.claude', 'venv', 'bin', 'python');
290
+
291
+ let command: string;
292
+ let args: string[];
293
+
294
+ // uv를 우선 사용 (10-100x 빠름)
295
+ // uv pip install --python <venv-python> -r requirements.txt
296
+ const useUv = this.checkCommandExists('uv');
297
+
298
+ if (useUv && existsSync(venvPythonPath)) {
299
+ command = 'uv';
300
+ args = ['pip', 'install', '--python', venvPythonPath, '-r', requirementsPath];
301
+ progress({
302
+ step: 'installing',
303
+ message: '⚡ uv로 패키지 설치 중 (고속)',
304
+ detail: 'uv pip install 실행 중...',
305
+ });
306
+ } else if (existsSync(path.join(homeDir, '.claude', 'venv', 'bin', 'pip'))) {
307
+ // fallback: 전역 venv pip 사용
308
+ command = path.join(homeDir, '.claude', 'venv', 'bin', 'pip');
309
+ args = ['install', '-r', requirementsPath];
310
+ progress({
311
+ step: 'installing',
312
+ message: '📦 pip으로 패키지 설치 중',
313
+ detail: 'pip install 실행 중...',
314
+ });
315
+ } else {
316
+ // fallback: 시스템 pip 사용
317
+ command = 'pip3';
318
+ args = ['install', '-r', requirementsPath];
319
+ progress({
320
+ step: 'installing',
321
+ message: '📦 pip으로 패키지 설치 중',
322
+ detail: 'pip install 실행 중...',
323
+ });
324
+ }
325
+
326
+ console.log(`Installing dependencies: ${command} ${args.join(' ')}`);
327
+
328
+ const proc = spawn(command, args, {
329
+ stdio: ['ignore', 'pipe', 'pipe'],
330
+ });
331
+
332
+ let output = '';
333
+ let errorOutput = '';
334
+
335
+ proc.stdout?.on('data', (data) => {
336
+ output += data.toString();
337
+ const lines = data.toString().trim().split('\n');
338
+ for (const line of lines) {
339
+ if (line.includes('Successfully installed') || line.includes('Requirement already satisfied') || line.includes('Installed')) {
340
+ progress({
341
+ step: 'installing',
342
+ message: '📦 패키지 설치 중',
343
+ detail: line.slice(0, 60) + (line.length > 60 ? '...' : ''),
344
+ });
345
+ }
346
+ }
347
+ });
348
+
349
+ proc.stderr?.on('data', (data) => {
350
+ errorOutput += data.toString();
351
+ });
352
+
353
+ proc.on('close', (code) => {
354
+ if (code === 0) {
355
+ progress({
356
+ step: 'installing',
357
+ message: '✅ 패키지 설치 완료',
358
+ detail: '모든 의존성이 설치되었습니다',
359
+ });
360
+ resolve();
361
+ } else {
362
+ console.error('Package install failed:', errorOutput);
363
+ reject(new Error(`Package install failed with code ${code}`));
364
+ }
365
+ });
366
+
367
+ proc.on('error', (err) => {
368
+ console.error('Failed to start package installer:', err);
369
+ reject(err);
370
+ });
371
+ });
372
+ }
373
+
374
+ private checkCommandExists(cmd: string): boolean {
375
+ try {
376
+ const { execSync } = require('child_process');
377
+ execSync(`which ${cmd}`, { stdio: 'ignore' });
378
+ return true;
379
+ } catch {
380
+ return false;
381
+ }
382
+ }
184
383
  }
185
384
 
186
385
  export const skillGeneratorService = new SkillGeneratorService();