aiwf 0.3.6 → 0.3.7

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.
@@ -6,6 +6,10 @@ import path from 'path';
6
6
 
7
7
  const GITHUB_RAW_URL = 'https://raw.githubusercontent.com/moonklabs/aiwf/main';
8
8
  const GITHUB_CONTENT_PREFIX = 'claude-code/aiwf';
9
+ const GITHUB_API_URL = 'https://api.github.com/repos/moonklabs/aiwf/contents';
10
+ const GITHUB_OWNER = 'moonklabs';
11
+ const GITHUB_REPO = 'aiwf';
12
+ const GITHUB_BRANCH = 'main';
9
13
 
10
14
  // Pre-defined list of command files
11
15
  const COMMAND_FILES = [
@@ -82,16 +86,22 @@ const PROMPT_FILES = [
82
86
  /**
83
87
  * Fetch content from URL
84
88
  * @param {string} url - The URL to fetch from
89
+ * @param {Object} headers - Optional headers
85
90
  * @returns {Promise<string>} The response content
86
91
  */
87
- async function fetchContent(url) {
92
+ async function fetchContent(url, headers = {}) {
88
93
  return new Promise((resolve, reject) => {
89
- https.get(url, { headers: { 'User-Agent': 'aiwf' } }, (res) => {
94
+ const defaultHeaders = { 'User-Agent': 'aiwf' };
95
+ const requestHeaders = { ...defaultHeaders, ...headers };
96
+
97
+ https.get(url, { headers: requestHeaders }, (res) => {
90
98
  let data = '';
91
99
  res.on('data', chunk => data += chunk);
92
100
  res.on('end', () => {
93
101
  if (res.statusCode === 200) {
94
102
  resolve(data);
103
+ } else if (res.statusCode === 403 && res.headers['x-ratelimit-remaining'] === '0') {
104
+ reject(new Error('GitHub API rate limit exceeded. Please try again later.'));
95
105
  } else {
96
106
  reject(new Error(`Failed to fetch ${url}: ${res.statusCode}`));
97
107
  }
@@ -104,35 +114,164 @@ async function fetchContent(url) {
104
114
  }
105
115
 
106
116
  /**
107
- * Download a file from URL to destination path
117
+ * Sleep for specified milliseconds
118
+ * @param {number} ms - Milliseconds to sleep
119
+ * @returns {Promise<void>}
120
+ */
121
+ function sleep(ms) {
122
+ return new Promise(resolve => setTimeout(resolve, ms));
123
+ }
124
+
125
+ /**
126
+ * Fetch directory contents from GitHub API with retry logic
127
+ * @param {string} path - Repository path
128
+ * @param {number} retries - Number of retries for rate limit
129
+ * @returns {Promise<Array>} Array of file/directory objects
130
+ */
131
+ async function fetchGitHubDirectory(path, retries = 3) {
132
+ const apiUrl = `${GITHUB_API_URL}/${path}?ref=${GITHUB_BRANCH}`;
133
+
134
+ for (let attempt = 0; attempt <= retries; attempt++) {
135
+ try {
136
+ const response = await fetchContent(apiUrl, {
137
+ 'Accept': 'application/vnd.github.v3+json'
138
+ });
139
+ return JSON.parse(response);
140
+ } catch (error) {
141
+ if (error.message.includes('rate limit exceeded') && attempt < retries) {
142
+ console.warn(`GitHub API rate limit hit. Retrying in ${(attempt + 1) * 10} seconds...`);
143
+ await sleep((attempt + 1) * 10000); // 10s, 20s, 30s
144
+ continue;
145
+ }
146
+
147
+ // GitHub API가 실패하면 빈 배열 반환
148
+ console.warn(`Failed to fetch directory listing for ${path}: ${error.message}`);
149
+
150
+ // 404 에러의 경우 정상적으로 빈 배열 반환 (디렉토리가 없는 경우)
151
+ if (error.message.includes('404')) {
152
+ return [];
153
+ }
154
+
155
+ // Rate limit이나 다른 에러의 경우 재시도 후 실패
156
+ if (attempt === retries) {
157
+ throw error;
158
+ }
159
+ }
160
+ }
161
+
162
+ return [];
163
+ }
164
+
165
+ /**
166
+ * Recursively download directory contents
167
+ * @param {string} githubPath - GitHub repository path
168
+ * @param {string} localPath - Local destination path
169
+ * @param {Object} spinner - Ora spinner instance
170
+ * @param {boolean} debugLog - Debug logging flag
171
+ * @returns {Promise<void>}
172
+ */
173
+ async function downloadDirectoryRecursive(githubPath, localPath, spinner, debugLog = false) {
174
+ try {
175
+ // GitHub API로 디렉토리 내용 가져오기
176
+ const contents = await fetchGitHubDirectory(githubPath);
177
+
178
+ if (contents.length === 0) {
179
+ if (debugLog) console.log(`No contents found in ${githubPath}`);
180
+ return;
181
+ }
182
+
183
+ // 로컬 디렉토리 생성
184
+ await fs.mkdir(localPath, { recursive: true });
185
+
186
+ for (const item of contents) {
187
+ if (item.type === 'file') {
188
+ // 파일 다운로드
189
+ const destPath = path.join(localPath, item.name);
190
+ const downloadUrl = item.download_url;
191
+
192
+ try {
193
+ await downloadFile(downloadUrl, destPath);
194
+ if (spinner) {
195
+ spinner.text = `Downloaded: ${item.name}`;
196
+ }
197
+ if (debugLog) console.log(`Downloaded: ${githubPath}/${item.name}`);
198
+ } catch (error) {
199
+ if (debugLog) console.error(`Failed to download ${item.name}: ${error.message}`);
200
+ }
201
+ } else if (item.type === 'dir') {
202
+ // 하위 디렉토리 재귀적으로 처리
203
+ const subGithubPath = `${githubPath}/${item.name}`;
204
+ const subLocalPath = path.join(localPath, item.name);
205
+
206
+ if (spinner) {
207
+ spinner.text = `Processing directory: ${item.name}`;
208
+ }
209
+
210
+ await downloadDirectoryRecursive(subGithubPath, subLocalPath, spinner, debugLog);
211
+ }
212
+ }
213
+ } catch (error) {
214
+ if (debugLog) console.error(`Error processing directory ${githubPath}: ${error.message}`);
215
+ throw error;
216
+ }
217
+ }
218
+
219
+ /**
220
+ * Download a file from URL to destination path with retry logic
108
221
  * @param {string} url - The URL to download from
109
222
  * @param {string} destPath - The destination file path
223
+ * @param {number} retries - Number of retries
110
224
  * @returns {Promise<void>}
111
225
  */
112
- async function downloadFile(url, destPath) {
226
+ async function downloadFile(url, destPath, retries = 3) {
113
227
  // Ensure destination directory exists
114
228
  const destDir = path.dirname(destPath);
115
229
  await fs.mkdir(destDir, { recursive: true });
116
230
 
117
- return new Promise((resolve, reject) => {
118
- https.get(url, { headers: { 'User-Agent': 'aiwf' } }, (response) => {
119
- if (response.statusCode !== 200) {
120
- response.destroy();
121
- reject(new Error(`Failed to download ${url}: ${response.statusCode}`));
122
- return;
123
- }
124
- const file = createWriteStream(destPath);
125
- pipeline(response, file)
126
- .then(() => {
127
- resolve();
128
- })
129
- .catch((error) => {
130
- reject(error);
231
+ for (let attempt = 0; attempt <= retries; attempt++) {
232
+ try {
233
+ await new Promise((resolve, reject) => {
234
+ https.get(url, { headers: { 'User-Agent': 'aiwf' } }, (response) => {
235
+ if (response.statusCode === 403 && response.headers['x-ratelimit-remaining'] === '0') {
236
+ response.destroy();
237
+ reject(new Error('GitHub API rate limit exceeded'));
238
+ return;
239
+ }
240
+
241
+ if (response.statusCode !== 200) {
242
+ response.destroy();
243
+ reject(new Error(`Failed to download ${url}: ${response.statusCode}`));
244
+ return;
245
+ }
246
+
247
+ const file = createWriteStream(destPath);
248
+ pipeline(response, file)
249
+ .then(() => {
250
+ resolve();
251
+ })
252
+ .catch((error) => {
253
+ reject(error);
254
+ });
255
+ }).on('error', (err) => {
256
+ reject(err);
131
257
  });
132
- }).on('error', (err) => {
133
- reject(err);
134
- });
135
- });
258
+ });
259
+
260
+ // 성공하면 반환
261
+ return;
262
+ } catch (error) {
263
+ if (error.message.includes('rate limit exceeded') && attempt < retries) {
264
+ console.warn(`Rate limit hit while downloading ${path.basename(destPath)}. Retrying in ${(attempt + 1) * 10} seconds...`);
265
+ await sleep((attempt + 1) * 10000);
266
+ continue;
267
+ }
268
+
269
+ // 마지막 시도에서도 실패하면 에러 throw
270
+ if (attempt === retries) {
271
+ throw error;
272
+ }
273
+ }
274
+ }
136
275
  }
137
276
 
138
277
  /**
@@ -164,17 +303,33 @@ async function downloadFileList(githubPath, localPath, fileList, spinner, messag
164
303
  }
165
304
 
166
305
  /**
167
- * Download directory contents using predefined file lists
306
+ * Download directory contents using predefined file lists or dynamic fetching
168
307
  * @param {string} githubPath - GitHub repository path
169
308
  * @param {string} localPath - Local destination path
170
309
  * @param {Object} spinner - Ora spinner instance
171
310
  * @param {Object} messages - Localized messages
172
311
  * @param {string} [selectedLanguage='en'] - Selected language
312
+ * @param {boolean} [useDynamicFetch=false] - Use GitHub API for dynamic fetching
173
313
  * @returns {Promise<void>}
174
314
  */
175
- async function downloadDirectory(githubPath, localPath, spinner, messages, selectedLanguage = 'en') {
315
+ async function downloadDirectory(githubPath, localPath, spinner, messages, selectedLanguage = 'en', useDynamicFetch = false) {
176
316
  try {
177
- // Determine which file list to use based on the path
317
+ // .aiwf 디렉토리나 알 수 없는 디렉토리의 경우 동적 다운로드 사용
318
+ if (useDynamicFetch || githubPath.includes('.aiwf') ||
319
+ (!githubPath.includes('.claude/commands/aiwf') &&
320
+ !githubPath.includes('rules/global') &&
321
+ !githubPath.includes('rules/manual'))) {
322
+
323
+ if (spinner) {
324
+ spinner.text = `Fetching directory structure: ${githubPath}`;
325
+ }
326
+
327
+ // GitHub API를 사용한 동적 디렉토리 다운로드
328
+ await downloadDirectoryRecursive(githubPath, localPath, spinner, messages && messages.debugLog);
329
+ return;
330
+ }
331
+
332
+ // 기존 로직: 사전 정의된 파일 리스트 사용
178
333
  let fileList = [];
179
334
 
180
335
  if (githubPath.includes('.claude/commands/aiwf')) {
@@ -183,16 +338,11 @@ async function downloadDirectory(githubPath, localPath, spinner, messages, selec
183
338
  fileList = GLOBAL_RULES_FILES;
184
339
  } else if (githubPath.includes('rules/manual')) {
185
340
  fileList = MANUAL_RULES_FILES;
186
- } else if (githubPath.includes('98_PROMPTS')) {
187
- fileList = PROMPT_FILES;
188
- } else if (githubPath.includes('99_TEMPLATES')) {
189
- fileList = TEMPLATE_FILES;
190
- } else {
191
- // For unknown directories, try to download common files
192
- fileList = ['README.md', 'index.md'];
193
341
  }
194
342
 
195
- await downloadFileList(githubPath, localPath, fileList, spinner, messages);
343
+ if (fileList.length > 0) {
344
+ await downloadFileList(githubPath, localPath, fileList, spinner, messages);
345
+ }
196
346
  } catch (error) {
197
347
  throw new Error(`Failed to download directory ${githubPath}: ${error.message}`);
198
348
  }
@@ -203,8 +353,14 @@ export {
203
353
  downloadFile,
204
354
  downloadFileList,
205
355
  downloadDirectory,
356
+ downloadDirectoryRecursive,
357
+ fetchGitHubDirectory,
206
358
  GITHUB_RAW_URL,
207
359
  GITHUB_CONTENT_PREFIX,
360
+ GITHUB_API_URL,
361
+ GITHUB_OWNER,
362
+ GITHUB_REPO,
363
+ GITHUB_BRANCH,
208
364
  COMMAND_FILES,
209
365
  GLOBAL_RULES_FILES,
210
366
  MANUAL_RULES_FILES,
@@ -219,20 +219,46 @@ async function downloadManifest(languagePath, spinner, msg, debugLog) {
219
219
  async function downloadTemplates(languagePath, spinner, msg, debugLog) {
220
220
  try {
221
221
  logWithSpinner(spinner, msg.downloadingTemplates, debugLog);
222
+
223
+ // .aiwf 전체 디렉토리를 동적으로 다운로드
224
+ logWithSpinner(spinner, `Downloading .aiwf directory structure...`, debugLog);
222
225
  await downloadDirectory(
223
- `${GITHUB_CONTENT_PREFIX}/${languagePath}/.aiwf/98_PROMPTS`,
224
- AIWF_DIRS.PROMPTS,
226
+ `${GITHUB_CONTENT_PREFIX}/${languagePath}/.aiwf`,
227
+ AIWF_DIRS.ROOT,
225
228
  spinner,
226
- msg
227
- );
228
- await downloadDirectory(
229
- `${GITHUB_CONTENT_PREFIX}/${languagePath}/.aiwf/99_TEMPLATES`,
230
- AIWF_DIRS.TEMPLATES,
231
- spinner,
232
- msg
229
+ msg,
230
+ languagePath,
231
+ true // useDynamicFetch = true
233
232
  );
233
+
234
+ logWithSpinner(spinner, `Successfully downloaded .aiwf directory`, debugLog);
234
235
  } catch (error) {
235
- logWithSpinner(spinner, msg.templatesNotFound, debugLog);
236
+ logWithSpinner(spinner, `Error downloading .aiwf: ${error.message}`, debugLog);
237
+
238
+ // Fallback: 개별 디렉토리 다운로드 시도
239
+ try {
240
+ logWithSpinner(spinner, `Trying individual directory downloads...`, debugLog);
241
+
242
+ await downloadDirectory(
243
+ `${GITHUB_CONTENT_PREFIX}/${languagePath}/.aiwf/98_PROMPTS`,
244
+ AIWF_DIRS.PROMPTS,
245
+ spinner,
246
+ msg,
247
+ languagePath,
248
+ true
249
+ );
250
+
251
+ await downloadDirectory(
252
+ `${GITHUB_CONTENT_PREFIX}/${languagePath}/.aiwf/99_TEMPLATES`,
253
+ AIWF_DIRS.TEMPLATES,
254
+ spinner,
255
+ msg,
256
+ languagePath,
257
+ true
258
+ );
259
+ } catch (fallbackError) {
260
+ logWithSpinner(spinner, msg.templatesNotFound, debugLog);
261
+ }
236
262
  }
237
263
  }
238
264
 
@@ -282,7 +308,8 @@ async function updateCommands(languagePath, spinner, msg, debugLog) {
282
308
  TOOL_DIRS.CLAUDE_COMMANDS,
283
309
  spinner,
284
310
  msg,
285
- languagePath
311
+ languagePath,
312
+ false // commands는 기존 방식 유지 (파일 리스트 사용)
286
313
  );
287
314
  } catch (error) {
288
315
  logWithSpinner(spinner, msg.commandsNotFound, debugLog);