add-ai-tools 1.1.4 → 1.2.0

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.
Files changed (2) hide show
  1. package/dist/index.mjs +450 -126
  2. package/package.json +2 -5
package/dist/index.mjs CHANGED
@@ -2,11 +2,15 @@
2
2
  import { program } from "commander";
3
3
  import inquirer from "inquirer";
4
4
  import ora from "ora";
5
+ import * as os from "os";
5
6
  import { homedir } from "os";
6
7
  import * as path from "path";
7
8
  import { join } from "path";
8
9
  import { parse } from "yaml";
9
10
  import { basename, dirname, join as join$1 } from "node:path";
11
+ import { exec } from "child_process";
12
+ import { promisify } from "util";
13
+ import * as fs$1 from "fs/promises";
10
14
  import { existsSync } from "node:fs";
11
15
  import { copyFile, mkdir, readFile, rename, unlink, writeFile } from "node:fs/promises";
12
16
  import { createHash, randomBytes } from "node:crypto";
@@ -43,17 +47,21 @@ const agents = {
43
47
  },
44
48
  cursor: {
45
49
  name: "Cursor",
46
- supportedTypes: ["skills", "rules"],
50
+ supportedTypes: [
51
+ "skills",
52
+ "rules",
53
+ "agents"
54
+ ],
47
55
  paths: {
48
56
  project: {
49
57
  skills: ".cursor/skills/",
50
58
  rules: ".cursor/rules/",
51
- agents: null
59
+ agents: ".cursor/agents/"
52
60
  },
53
61
  global: {
54
62
  skills: "~/.cursor/skills/",
55
63
  rules: "~/.cursor/rules/",
56
- agents: null
64
+ agents: "~/.cursor/agents/"
57
65
  }
58
66
  }
59
67
  },
@@ -196,6 +204,13 @@ const GITHUB_URL_REGEX = /^https?:\/\/(?:www\.)?github\.com\/([^/]+)\/([^/]+?)(?
196
204
  */
197
205
  const GITLAB_URL_REGEX = /^https?:\/\/(?:www\.)?gitlab\.com\/([^/]+)\/([^/]+?)(?:\.git)?(?:\/-\/(?:tree|blob)\/([^/]+)(?:\/(.+))?)?$/;
198
206
  /**
207
+ * Bitbucket URL 매칭 정규식
208
+ * 예: https://bitbucket.org/workspace/repo
209
+ * https://bitbucket.org/workspace/repo/src/branch/path
210
+ * https://username@bitbucket.org/workspace/repo.git
211
+ */
212
+ const BITBUCKET_URL_REGEX = /^https?:\/\/(?:[^@]+@)?(?:www\.)?bitbucket\.org\/([^/]+)\/([^/]+?)(?:\.git)?(?:\/src\/([^/]+)(?:\/(.+))?)?$/;
213
+ /**
199
214
  * GitHub shorthand 매칭 정규식
200
215
  * 예: owner/repo
201
216
  * vercel-labs/agent-skills
@@ -255,12 +270,25 @@ function parseSource(input) {
255
270
  raw: input
256
271
  };
257
272
  }
273
+ const bitbucketMatch = trimmed.match(BITBUCKET_URL_REGEX);
274
+ if (bitbucketMatch) {
275
+ const [, owner, repo, ref, subpath] = bitbucketMatch;
276
+ return {
277
+ type: "bitbucket",
278
+ url: `https://bitbucket.org/${owner}/${repo}`,
279
+ owner,
280
+ repo,
281
+ ref: ref || void 0,
282
+ subpath: subpath || void 0,
283
+ raw: input
284
+ };
285
+ }
258
286
  const gitMatch = trimmed.match(GIT_URL_REGEX);
259
287
  if (gitMatch) {
260
288
  if (gitMatch[1]) {
261
289
  const [, host, owner, repo] = gitMatch;
262
290
  return {
263
- type: host === "github.com" ? "github" : host === "gitlab.com" ? "gitlab" : "git",
291
+ type: host === "github.com" ? "github" : host === "gitlab.com" ? "gitlab" : host === "bitbucket.org" ? "bitbucket" : "git",
264
292
  url: `https://${host}/${owner}/${repo}`,
265
293
  owner,
266
294
  repo,
@@ -270,7 +298,7 @@ function parseSource(input) {
270
298
  if (gitMatch[4]) {
271
299
  const [, , , , host, owner, repo] = gitMatch;
272
300
  return {
273
- type: host === "github.com" ? "github" : host === "gitlab.com" ? "gitlab" : "git",
301
+ type: host === "github.com" ? "github" : host === "gitlab.com" ? "gitlab" : host === "bitbucket.org" ? "bitbucket" : "git",
274
302
  url: `https://${host}/${owner}/${repo}`,
275
303
  owner,
276
304
  repo,
@@ -308,6 +336,7 @@ function getSourceDisplayName(parsed) {
308
336
  switch (parsed.type) {
309
337
  case "github": return parsed.owner && parsed.repo ? `GitHub: ${parsed.owner}/${parsed.repo}${parsed.subpath ? `/${parsed.subpath}` : ""}` : parsed.url || parsed.raw;
310
338
  case "gitlab": return parsed.owner && parsed.repo ? `GitLab: ${parsed.owner}/${parsed.repo}${parsed.subpath ? `/${parsed.subpath}` : ""}` : parsed.url || parsed.raw;
339
+ case "bitbucket": return parsed.owner && parsed.repo ? `Bitbucket: ${parsed.owner}/${parsed.repo}${parsed.subpath ? `/${parsed.subpath}` : ""}` : parsed.url || parsed.raw;
311
340
  case "git": return `Git: ${parsed.url || parsed.raw}`;
312
341
  case "direct-url": return `URL: ${parsed.url}`;
313
342
  default: return parsed.raw;
@@ -431,11 +460,16 @@ var GitHubRateLimitError = class extends GitHubApiError {
431
460
  };
432
461
  /**
433
462
  * GitHubFetcher - GitHub 레포지토리에서 리소스를 가져옵니다
463
+ *
464
+ * 최적화: Git Trees API로 구조를 한 번에 가져오고, 파일은 병렬로 fetch합니다.
465
+ * - API 호출: 2회 (branch SHA + tree)
466
+ * - 파일 fetch: raw.githubusercontent.com (Rate Limit 영향 없음)
434
467
  */
435
468
  var GitHubFetcher = class {
436
469
  parser;
437
470
  baseApiUrl = "https://api.github.com";
438
471
  baseRawUrl = "https://raw.githubusercontent.com";
472
+ concurrencyLimit = 10;
439
473
  constructor() {
440
474
  this.parser = new ResourceParser();
441
475
  }
@@ -445,144 +479,93 @@ var GitHubFetcher = class {
445
479
  async fetchResources(source, types) {
446
480
  if (source.type !== "github" || !source.owner || !source.repo) throw new Error("Invalid GitHub source");
447
481
  const resources = [];
448
- const ref = source.ref || "main";
482
+ const ref = source.ref || await this.getDefaultBranch(source.owner, source.repo);
483
+ const tree = await this.fetchTree(source.owner, source.repo, ref);
449
484
  for (const type of types) {
450
485
  const dirPath = this.getResourceDirPath(type, source.subpath);
451
- const typeResources = await this.fetchResourcesFromDir(source.owner, source.repo, dirPath, type, ref);
486
+ const dirFiles = this.filterTreeByPath(tree, dirPath);
487
+ if (dirFiles.length === 0) continue;
488
+ const cachedFiles = await this.fetchFilesParallel(source.owner, source.repo, ref, dirFiles);
489
+ const typeResources = this.parseResourcesFromCache(cachedFiles, dirPath, type);
452
490
  resources.push(...typeResources);
453
491
  }
454
492
  return resources;
455
493
  }
456
494
  /**
457
- * 디렉토리에서 리소스를 가져옵니다
495
+ * Git Trees API로 전체 트리 구조를 가져옵니다
458
496
  */
459
- async fetchResourcesFromDir(owner, repo, dirPath, type, ref) {
460
- try {
461
- const contents = await this.listDirectory(owner, repo, dirPath, ref);
462
- const resources = [];
463
- for (const item of contents) if (item.type === "dir") {
464
- const resource = await this.fetchResourceFromSubdir(owner, repo, item.path, type, ref);
465
- if (resource) resources.push(resource);
466
- } else if (item.type === "file" && item.name.endsWith(".md")) {
467
- const resource = await this.fetchSingleResource(owner, repo, item.path, type, ref);
468
- if (resource) resources.push(resource);
469
- }
470
- return resources;
471
- } catch (error) {
472
- if (error instanceof GitHubNotFoundError) return [];
473
- throw error;
474
- }
497
+ async fetchTree(owner, repo, ref) {
498
+ const sha = await this.getRefSha(owner, repo, ref);
499
+ const url = `${this.baseApiUrl}/repos/${owner}/${repo}/git/trees/${sha}?recursive=1`;
500
+ const response = await fetch(url, { headers: {
501
+ Accept: "application/vnd.github.v3+json",
502
+ "User-Agent": "ai-toolkit"
503
+ } });
504
+ if (!response.ok) this.handleHttpError(response.status, `tree/${sha}`);
505
+ const data = await response.json();
506
+ if (data.truncated) console.warn("Warning: Repository tree was truncated. Some files may be missing.");
507
+ return data.tree;
475
508
  }
476
509
  /**
477
- * 하위 디렉토리에서 리소스를 가져옵니다 (디렉토리 전체 복제)
510
+ * 레포지토리의 기본 브랜치를 가져옵니다
478
511
  */
479
- async fetchResourceFromSubdir(owner, repo, dirPath, type, ref) {
480
- try {
481
- const contents = await this.listDirectory(owner, repo, dirPath, ref);
482
- const mainFile = this.findMainResourceFile(contents, type);
483
- if (!mainFile) return null;
484
- const content = await this.fetchFileContent(owner, repo, mainFile.path, ref);
485
- const siblingFiles = await this.fetchAllFilesInDir(owner, repo, dirPath, contents, ref, mainFile.name);
486
- const sourceFile = {
487
- path: mainFile.path,
488
- content,
489
- isDirectory: false,
490
- siblingFiles
491
- };
492
- return this.parser.parseResource(sourceFile, type);
493
- } catch (error) {
494
- if (error instanceof GitHubNotFoundError) return null;
495
- console.warn(`Failed to fetch resource from ${dirPath}:`, error instanceof Error ? error.message : error);
496
- return null;
497
- }
512
+ async getDefaultBranch(owner, repo) {
513
+ const url = `${this.baseApiUrl}/repos/${owner}/${repo}`;
514
+ const response = await fetch(url, { headers: {
515
+ Accept: "application/vnd.github.v3+json",
516
+ "User-Agent": "ai-toolkit"
517
+ } });
518
+ if (!response.ok) return "main";
519
+ return (await response.json()).default_branch || "main";
498
520
  }
499
521
  /**
500
- * 디렉토리 모든 파일을 가져옵니다 (메인 파일 제외, 상대 경로)
522
+ * ref의 commit SHA를 가져옵니다
501
523
  */
502
- async fetchAllFilesInDir(owner, repo, basePath, contents, ref, excludeFile) {
503
- const files = [];
504
- for (const item of contents) {
505
- if (item.type === "file" && item.name === excludeFile) continue;
506
- if (item.type === "file") try {
507
- const content = await this.fetchFileContent(owner, repo, item.path, ref);
508
- files.push({
509
- path: item.name,
510
- content,
511
- isDirectory: false
512
- });
513
- } catch (error) {
514
- console.warn(`Failed to fetch file ${item.path}:`, error instanceof Error ? error.message : error);
515
- }
516
- else if (item.type === "dir") {
517
- const dirFiles = await this.fetchDirectoryFilesRecursive(owner, repo, item.path, ref, basePath);
518
- files.push(...dirFiles);
519
- }
520
- }
521
- return files;
524
+ async getRefSha(owner, repo, ref) {
525
+ const branchUrl = `${this.baseApiUrl}/repos/${owner}/${repo}/branches/${ref}`;
526
+ const response = await fetch(branchUrl, { headers: {
527
+ Accept: "application/vnd.github.v3+json",
528
+ "User-Agent": "ai-toolkit"
529
+ } });
530
+ if (response.ok) return (await response.json()).commit.sha;
531
+ if (response.status === 404) return ref;
532
+ this.handleHttpError(response.status, `branches/${ref}`);
522
533
  }
523
534
  /**
524
- * 단일 리소스 파일을 가져옵니다
535
+ * 트리에서 특정 경로의 파일들을 필터링합니다
525
536
  */
526
- async fetchSingleResource(owner, repo, filePath, type, ref) {
527
- try {
528
- const sourceFile = {
529
- path: filePath,
530
- content: await this.fetchFileContent(owner, repo, filePath, ref),
531
- isDirectory: false
532
- };
533
- return this.parser.parseResource(sourceFile, type);
534
- } catch (error) {
535
- if (error instanceof GitHubNotFoundError) return null;
536
- console.warn(`Failed to fetch resource ${filePath}:`, error instanceof Error ? error.message : error);
537
- return null;
538
- }
537
+ filterTreeByPath(tree, basePath) {
538
+ const prefix = basePath.endsWith("/") ? basePath : `${basePath}/`;
539
+ return tree.filter((item) => {
540
+ if (item.type !== "blob") return false;
541
+ return item.path.startsWith(prefix) || item.path === basePath;
542
+ });
539
543
  }
540
544
  /**
541
- * 디렉토리 모든 파일을 재귀적으로 가져옵니다 (상대 경로 반환)
545
+ * 파일들을 병렬로 가져옵니다 (동시성 제한 적용)
542
546
  */
543
- async fetchDirectoryFilesRecursive(owner, repo, dirPath, ref, basePath) {
544
- try {
545
- const contents = await this.listDirectory(owner, repo, dirPath, ref);
546
- const files = [];
547
- for (const item of contents) {
548
- const relativePath = item.path.startsWith(`${basePath}/`) ? item.path.slice(basePath.length + 1) : item.path;
549
- if (item.type === "file") try {
547
+ async fetchFilesParallel(owner, repo, ref, items) {
548
+ const results = [];
549
+ for (let i = 0; i < items.length; i += this.concurrencyLimit) {
550
+ const batch = items.slice(i, i + this.concurrencyLimit);
551
+ const batchResults = await Promise.all(batch.map(async (item) => {
552
+ try {
550
553
  const content = await this.fetchFileContent(owner, repo, item.path, ref);
551
- files.push({
552
- path: relativePath,
553
- content,
554
- isDirectory: false
555
- });
554
+ return {
555
+ path: item.path,
556
+ content
557
+ };
556
558
  } catch (error) {
557
559
  console.warn(`Failed to fetch file ${item.path}:`, error instanceof Error ? error.message : error);
560
+ return null;
558
561
  }
559
- else if (item.type === "dir") {
560
- const subFiles = await this.fetchDirectoryFilesRecursive(owner, repo, item.path, ref, basePath);
561
- files.push(...subFiles);
562
- }
563
- }
564
- return files;
565
- } catch (error) {
566
- if (error instanceof GitHubNotFoundError) return [];
567
- console.warn(`Failed to fetch directory ${dirPath}:`, error instanceof Error ? error.message : error);
568
- return [];
562
+ }));
563
+ results.push(...batchResults.filter((r) => r !== null));
569
564
  }
565
+ return results;
570
566
  }
571
567
  /**
572
- * GitHub API로 디렉토리 목록을 가져옵니다
573
- */
574
- async listDirectory(owner, repo, path, ref) {
575
- const url = `${this.baseApiUrl}/repos/${owner}/${repo}/contents/${path}?ref=${ref}`;
576
- const response = await fetch(url, { headers: {
577
- "Accept": "application/vnd.github.v3+json",
578
- "User-Agent": "ai-toolkit"
579
- } });
580
- if (!response.ok) this.handleHttpError(response.status, path);
581
- const data = await response.json();
582
- return Array.isArray(data) ? data : [];
583
- }
584
- /**
585
- * 파일 내용을 가져옵니다
568
+ * 파일 내용을 가져옵니다 (raw.githubusercontent.com 사용)
586
569
  */
587
570
  async fetchFileContent(owner, repo, path, ref) {
588
571
  const url = `${this.baseRawUrl}/${owner}/${repo}/${ref}/${path}`;
@@ -591,6 +574,87 @@ var GitHubFetcher = class {
591
574
  return response.text();
592
575
  }
593
576
  /**
577
+ * 캐시된 파일에서 리소스를 파싱합니다
578
+ */
579
+ parseResourcesFromCache(cachedFiles, dirPath, type) {
580
+ const resources = [];
581
+ const structure = this.analyzeDirectoryStructure(cachedFiles, dirPath);
582
+ for (const [subdir, files] of structure.subdirectories) {
583
+ const resource = this.parseSubdirectoryResource(subdir, files, type);
584
+ if (resource) resources.push(resource);
585
+ }
586
+ for (const file of structure.rootFiles) if (file.path.endsWith(".md")) {
587
+ const resource = this.parseSingleFileResource(file, type);
588
+ if (resource) resources.push(resource);
589
+ }
590
+ return resources;
591
+ }
592
+ /**
593
+ * 디렉토리 구조 분석
594
+ */
595
+ analyzeDirectoryStructure(files, basePath) {
596
+ const subdirectories = /* @__PURE__ */ new Map();
597
+ const rootFiles = [];
598
+ const prefix = basePath.endsWith("/") ? basePath : `${basePath}/`;
599
+ for (const file of files) {
600
+ const parts = (file.path.startsWith(prefix) ? file.path.slice(prefix.length) : file.path).split("/");
601
+ if (parts.length === 1) rootFiles.push(file);
602
+ else {
603
+ const subdirPath = `${basePath}/${parts[0]}`;
604
+ if (!subdirectories.has(subdirPath)) subdirectories.set(subdirPath, []);
605
+ subdirectories.get(subdirPath).push(file);
606
+ }
607
+ }
608
+ return {
609
+ subdirectories,
610
+ rootFiles
611
+ };
612
+ }
613
+ /**
614
+ * 하위 디렉토리에서 리소스 파싱
615
+ */
616
+ parseSubdirectoryResource(subdirPath, files, type) {
617
+ const mainFile = this.findMainResourceFile(files, type);
618
+ if (!mainFile) return null;
619
+ const mainFileData = files.find((f) => f.path === mainFile.path);
620
+ if (!mainFileData) return null;
621
+ const siblingFiles = files.filter((f) => f.path !== mainFile.path).map((f) => {
622
+ return {
623
+ path: f.path.startsWith(`${subdirPath}/`) ? f.path.slice(subdirPath.length + 1) : this.getFileName(f.path),
624
+ content: f.content,
625
+ isDirectory: false
626
+ };
627
+ });
628
+ const sourceFile = {
629
+ path: mainFile.path,
630
+ content: mainFileData.content,
631
+ isDirectory: false,
632
+ siblingFiles
633
+ };
634
+ try {
635
+ return this.parser.parseResource(sourceFile, type);
636
+ } catch (error) {
637
+ console.warn(`Failed to parse resource from ${subdirPath}:`, error instanceof Error ? error.message : error);
638
+ return null;
639
+ }
640
+ }
641
+ /**
642
+ * 단일 파일 리소스 파싱
643
+ */
644
+ parseSingleFileResource(file, type) {
645
+ const sourceFile = {
646
+ path: file.path,
647
+ content: file.content,
648
+ isDirectory: false
649
+ };
650
+ try {
651
+ return this.parser.parseResource(sourceFile, type);
652
+ } catch (error) {
653
+ console.warn(`Failed to parse resource ${file.path}:`, error instanceof Error ? error.message : error);
654
+ return null;
655
+ }
656
+ }
657
+ /**
594
658
  * HTTP 에러를 처리합니다
595
659
  */
596
660
  handleHttpError(status, path) {
@@ -612,9 +676,16 @@ var GitHubFetcher = class {
612
676
  return type;
613
677
  }
614
678
  /**
679
+ * 경로에서 파일명만 추출합니다
680
+ */
681
+ getFileName(filePath) {
682
+ const parts = filePath.split("/");
683
+ return parts[parts.length - 1];
684
+ }
685
+ /**
615
686
  * 메인 리소스 파일을 찾습니다
616
687
  */
617
- findMainResourceFile(contents, type) {
688
+ findMainResourceFile(files, type) {
618
689
  const candidates = {
619
690
  skills: ["SKILL.md", "skill.md"],
620
691
  rules: [
@@ -627,14 +698,254 @@ var GitHubFetcher = class {
627
698
  agents: ["AGENT.md", "agent.md"]
628
699
  }[type] || [];
629
700
  for (const candidate of candidates) {
630
- const found = contents.find((item) => item.type === "file" && item.name.toLowerCase() === candidate.toLowerCase());
701
+ const found = files.find((file) => {
702
+ return this.getFileName(file.path).toLowerCase() === candidate.toLowerCase();
703
+ });
631
704
  if (found) return found;
632
705
  }
633
- return contents.find((item) => item.type === "file" && item.name.endsWith(".md")) || null;
706
+ return files.find((file) => file.path.endsWith(".md")) || null;
634
707
  }
635
708
  };
636
709
  const githubFetcher = new GitHubFetcher();
637
710
 
711
+ //#endregion
712
+ //#region src/fetch/BitbucketFetcher.ts
713
+ const execAsync = promisify(exec);
714
+ /**
715
+ * Bitbucket SSH 에러
716
+ */
717
+ var BitbucketApiError = class extends Error {
718
+ constructor(message, status, path) {
719
+ super(message);
720
+ this.status = status;
721
+ this.path = path;
722
+ this.name = "BitbucketApiError";
723
+ }
724
+ };
725
+ /**
726
+ * BitbucketFetcher - SSH를 통해 Bitbucket 레포지토리에서 리소스를 가져옵니다
727
+ *
728
+ * git archive --remote 명령을 사용하여 SSH 키 인증을 활용합니다.
729
+ * 최적화: 단일 SSH 호출로 전체 디렉토리를 가져와 캐시합니다.
730
+ */
731
+ var BitbucketFetcher = class {
732
+ parser;
733
+ fileCache = /* @__PURE__ */ new Map();
734
+ constructor() {
735
+ this.parser = new ResourceParser();
736
+ }
737
+ /**
738
+ * SSH URL 생성
739
+ */
740
+ getSshUrl(owner, repo) {
741
+ return `git@bitbucket.org:${owner}/${repo}.git`;
742
+ }
743
+ /**
744
+ * Bitbucket 소스에서 리소스 목록을 가져옵니다
745
+ */
746
+ async fetchResources(source, types) {
747
+ if (source.type !== "bitbucket" || !source.owner || !source.repo) throw new Error("Invalid Bitbucket source");
748
+ const resources = [];
749
+ const ref = source.ref || "HEAD";
750
+ this.fileCache.clear();
751
+ for (const type of types) {
752
+ const dirPath = this.getResourceDirPath(type, source.subpath);
753
+ const cachedFiles = await this.fetchAndCacheDirectory(source.owner, source.repo, dirPath, ref);
754
+ if (cachedFiles.length === 0) continue;
755
+ const typeResources = this.parseResourcesFromCache(cachedFiles, dirPath, type);
756
+ resources.push(...typeResources);
757
+ }
758
+ return resources;
759
+ }
760
+ /**
761
+ * 단일 SSH 호출로 전체 디렉토리를 가져와 캐시합니다
762
+ */
763
+ async fetchAndCacheDirectory(owner, repo, dirPath, ref) {
764
+ const cacheKey = `${owner}/${repo}/${dirPath}/${ref}`;
765
+ const cached = this.fileCache.get(cacheKey);
766
+ if (cached) return cached;
767
+ const sshUrl = this.getSshUrl(owner, repo);
768
+ const tempDir = await fs$1.mkdtemp(path.join(os.tmpdir(), "bitbucket-"));
769
+ try {
770
+ await execAsync(`git archive --remote=${sshUrl} ${ref} ${dirPath}/ 2>/dev/null | tar -xf - -C "${tempDir}" 2>/dev/null`, { maxBuffer: 50 * 1024 * 1024 });
771
+ const files = await this.loadFilesFromDirectory(tempDir, dirPath);
772
+ this.fileCache.set(cacheKey, files);
773
+ return files;
774
+ } catch (error) {
775
+ const errorMessage = error instanceof Error ? error.message : String(error);
776
+ if (errorMessage.includes("Permission denied") || errorMessage.includes("Could not read")) throw new BitbucketApiError("SSH access denied. Check your SSH key configuration.", 403, dirPath);
777
+ if (errorMessage.includes("did not match any files") || errorMessage.includes("path not found") || errorMessage.includes("tar: Error exit")) {
778
+ this.fileCache.set(cacheKey, []);
779
+ return [];
780
+ }
781
+ throw new BitbucketApiError(`SSH error: ${errorMessage}`, 500, dirPath);
782
+ } finally {
783
+ try {
784
+ await fs$1.rm(tempDir, {
785
+ recursive: true,
786
+ force: true
787
+ });
788
+ } catch {}
789
+ }
790
+ }
791
+ /**
792
+ * 디렉토리에서 모든 파일을 재귀적으로 로드합니다
793
+ */
794
+ async loadFilesFromDirectory(baseDir, relativeTo) {
795
+ const files = [];
796
+ const targetDir = path.join(baseDir, relativeTo);
797
+ try {
798
+ await this.loadFilesRecursive(targetDir, relativeTo, files);
799
+ } catch {}
800
+ return files;
801
+ }
802
+ /**
803
+ * 재귀적으로 파일 로드
804
+ */
805
+ async loadFilesRecursive(dir, basePath, files) {
806
+ const entries = await fs$1.readdir(dir, { withFileTypes: true });
807
+ for (const entry of entries) {
808
+ const fullPath = path.join(dir, entry.name);
809
+ const relativePath = path.join(basePath, entry.name);
810
+ if (entry.isDirectory()) await this.loadFilesRecursive(fullPath, relativePath, files);
811
+ else if (entry.isFile()) try {
812
+ const content = await fs$1.readFile(fullPath, "utf-8");
813
+ files.push({
814
+ path: relativePath,
815
+ content
816
+ });
817
+ } catch {}
818
+ }
819
+ }
820
+ /**
821
+ * 캐시된 파일에서 리소스를 파싱합니다
822
+ */
823
+ parseResourcesFromCache(cachedFiles, dirPath, type) {
824
+ const resources = [];
825
+ const structure = this.analyzeDirectoryStructure(cachedFiles, dirPath);
826
+ for (const [subdir, files] of structure.subdirectories) {
827
+ const resource = this.parseSubdirectoryResource(subdir, files, type);
828
+ if (resource) resources.push(resource);
829
+ }
830
+ for (const file of structure.rootFiles) if (file.path.endsWith(".md")) {
831
+ const resource = this.parseSingleFileResource(file, type);
832
+ if (resource) resources.push(resource);
833
+ }
834
+ return resources;
835
+ }
836
+ /**
837
+ * 디렉토리 구조 분석
838
+ */
839
+ analyzeDirectoryStructure(files, basePath) {
840
+ const subdirectories = /* @__PURE__ */ new Map();
841
+ const rootFiles = [];
842
+ const prefix = basePath.endsWith("/") ? basePath : `${basePath}/`;
843
+ for (const file of files) {
844
+ const parts = (file.path.startsWith(prefix) ? file.path.slice(prefix.length) : file.path).split("/");
845
+ if (parts.length === 1) rootFiles.push(file);
846
+ else {
847
+ const subdirPath = `${basePath}/${parts[0]}`;
848
+ if (!subdirectories.has(subdirPath)) subdirectories.set(subdirPath, []);
849
+ subdirectories.get(subdirPath).push(file);
850
+ }
851
+ }
852
+ return {
853
+ subdirectories,
854
+ rootFiles
855
+ };
856
+ }
857
+ /**
858
+ * 하위 디렉토리에서 리소스 파싱
859
+ */
860
+ parseSubdirectoryResource(subdirPath, files, type) {
861
+ const entries = files.map((f) => ({
862
+ path: f.path,
863
+ type: "file"
864
+ }));
865
+ const mainFile = this.findMainResourceFile(entries, type);
866
+ if (!mainFile) return null;
867
+ const mainFileData = files.find((f) => f.path === mainFile.path);
868
+ if (!mainFileData) return null;
869
+ const siblingFiles = files.filter((f) => f.path !== mainFile.path).map((f) => {
870
+ return {
871
+ path: f.path.startsWith(`${subdirPath}/`) ? f.path.slice(subdirPath.length + 1) : this.getFileName(f.path),
872
+ content: f.content,
873
+ isDirectory: false
874
+ };
875
+ });
876
+ const sourceFile = {
877
+ path: mainFile.path,
878
+ content: mainFileData.content,
879
+ isDirectory: false,
880
+ siblingFiles
881
+ };
882
+ try {
883
+ return this.parser.parseResource(sourceFile, type);
884
+ } catch (error) {
885
+ console.warn(`Failed to parse resource from ${subdirPath}:`, error instanceof Error ? error.message : error);
886
+ return null;
887
+ }
888
+ }
889
+ /**
890
+ * 단일 파일 리소스 파싱
891
+ */
892
+ parseSingleFileResource(file, type) {
893
+ const sourceFile = {
894
+ path: file.path,
895
+ content: file.content,
896
+ isDirectory: false
897
+ };
898
+ try {
899
+ return this.parser.parseResource(sourceFile, type);
900
+ } catch (error) {
901
+ console.warn(`Failed to parse resource ${file.path}:`, error instanceof Error ? error.message : error);
902
+ return null;
903
+ }
904
+ }
905
+ /**
906
+ * 리소스 타입에 맞는 디렉토리 경로를 반환합니다
907
+ */
908
+ getResourceDirPath(type, subpath) {
909
+ if (subpath) return subpath;
910
+ return type;
911
+ }
912
+ /**
913
+ * 경로에서 파일명만 추출합니다
914
+ */
915
+ getFileName(filePath) {
916
+ const parts = (filePath.endsWith("/") ? filePath.slice(0, -1) : filePath).split("/");
917
+ return parts[parts.length - 1];
918
+ }
919
+ /**
920
+ * 메인 리소스 파일을 찾습니다
921
+ */
922
+ findMainResourceFile(entries, type) {
923
+ const candidates = {
924
+ skills: ["SKILL.md", "skill.md"],
925
+ rules: [
926
+ "RULE.md",
927
+ "RULES.md",
928
+ "rule.md",
929
+ "rules.md",
930
+ "README.md"
931
+ ],
932
+ agents: ["AGENT.md", "agent.md"]
933
+ }[type] || [];
934
+ for (const candidate of candidates) {
935
+ const found = entries.find((entry) => {
936
+ const fileName = this.getFileName(entry.path);
937
+ return entry.type === "file" && fileName.toLowerCase() === candidate.toLowerCase();
938
+ });
939
+ if (found) return found;
940
+ }
941
+ return entries.find((entry) => {
942
+ const fileName = this.getFileName(entry.path);
943
+ return entry.type === "file" && fileName.endsWith(".md");
944
+ }) || null;
945
+ }
946
+ };
947
+ const bitbucketFetcher = new BitbucketFetcher();
948
+
638
949
  //#endregion
639
950
  //#region src/prompts/InteractivePrompt.ts
640
951
  /**
@@ -656,6 +967,7 @@ var InteractivePrompt = class {
656
967
  let availableResources = [];
657
968
  try {
658
969
  if (parsedSource.type === "github") availableResources = await githubFetcher.fetchResources(parsedSource, types);
970
+ else if (parsedSource.type === "bitbucket") availableResources = await bitbucketFetcher.fetchResources(parsedSource, types);
659
971
  else spinner.warn(`Source type "${parsedSource.type}" is not yet supported.`);
660
972
  spinner.succeed(`Found ${availableResources.length} resource(s)`);
661
973
  } catch (error) {
@@ -724,7 +1036,7 @@ var InteractivePrompt = class {
724
1036
  choices: supportedTypes.map((type) => ({
725
1037
  name: typeDescriptions[type],
726
1038
  value: type,
727
- checked: type === "skills"
1039
+ checked: false
728
1040
  })),
729
1041
  validate: (input) => {
730
1042
  if (input.length === 0) return "Please select at least one type";
@@ -748,11 +1060,20 @@ var InteractivePrompt = class {
748
1060
  name: "Global - Install in home directory",
749
1061
  value: "global"
750
1062
  }],
751
- default: "project"
1063
+ default: void 0
752
1064
  }]);
753
1065
  return scope;
754
1066
  }
755
1067
  /**
1068
+ * description에서 첫 줄만 추출하고 길이 제한
1069
+ */
1070
+ truncateDescription(description, maxLength = 60) {
1071
+ if (!description) return "No description";
1072
+ const firstLine = description.split(/\r?\n/)[0].trim();
1073
+ if (firstLine.length > maxLength) return firstLine.slice(0, maxLength - 3) + "...";
1074
+ return firstLine;
1075
+ }
1076
+ /**
756
1077
  * 리소스 선택 (가져온 리소스 목록에서)
757
1078
  */
758
1079
  async selectResources(availableResources, types) {
@@ -766,10 +1087,11 @@ var InteractivePrompt = class {
766
1087
  name: "resources",
767
1088
  message: "Select resources to install:",
768
1089
  choices: filteredResources.map((r) => ({
769
- name: `[${r.type}] ${r.name} - ${r.description || "No description"}`,
1090
+ name: `[${r.type}] ${r.name} - ${this.truncateDescription(r.description)}`,
770
1091
  value: r,
771
- checked: true
1092
+ checked: false
772
1093
  })),
1094
+ pageSize: 15,
773
1095
  validate: (input) => {
774
1096
  if (input.length === 0) return "Please select at least one resource";
775
1097
  return true;
@@ -794,7 +1116,7 @@ var InteractivePrompt = class {
794
1116
  type: "confirm",
795
1117
  name: "confirmed",
796
1118
  message: "Proceed with installation?",
797
- default: true
1119
+ default: false
798
1120
  }]);
799
1121
  return confirmed;
800
1122
  }
@@ -1455,6 +1777,7 @@ var CommandHandler = class {
1455
1777
  try {
1456
1778
  let resources;
1457
1779
  if (parsed.type === "github") resources = await githubFetcher.fetchResources(parsed, types);
1780
+ else if (parsed.type === "bitbucket") resources = await bitbucketFetcher.fetchResources(parsed, types);
1458
1781
  else {
1459
1782
  this.logger.failProgress(`Source type "${parsed.type}" is not yet supported.`);
1460
1783
  return;
@@ -1565,7 +1888,7 @@ var ZipPrompt = class {
1565
1888
  choices: allTypes.map((type) => ({
1566
1889
  name: descriptions[type],
1567
1890
  value: type,
1568
- checked: type === "skills"
1891
+ checked: false
1569
1892
  })),
1570
1893
  validate: (input) => {
1571
1894
  if (input.length === 0) return "Please select at least one type";
@@ -1612,7 +1935,7 @@ var ZipPrompt = class {
1612
1935
  type: "confirm",
1613
1936
  name: "confirmed",
1614
1937
  message: "Proceed with export?",
1615
- default: true
1938
+ default: false
1616
1939
  }]);
1617
1940
  return confirmed;
1618
1941
  }
@@ -1715,6 +2038,7 @@ var ZipHandler = class {
1715
2038
  ];
1716
2039
  let resources;
1717
2040
  if (parsed.type === "github") resources = await githubFetcher.fetchResources(parsed, types);
2041
+ else if (parsed.type === "bitbucket") resources = await bitbucketFetcher.fetchResources(parsed, types);
1718
2042
  else {
1719
2043
  this.logger.failProgress(`Source type "${parsed.type}" is not yet supported.`);
1720
2044
  return;
package/package.json CHANGED
@@ -1,16 +1,13 @@
1
1
  {
2
2
  "name": "add-ai-tools",
3
- "version": "1.1.4",
3
+ "version": "1.2.0",
4
4
  "description": "Universal AI agent resource installer CLI",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "add-ai-tools": "./dist/index.mjs"
8
8
  },
9
9
  "files": [
10
- "dist",
11
- "README.ko.md",
12
- "README.ja.md",
13
- "README.zh-CN.md"
10
+ "dist"
14
11
  ],
15
12
  "dependencies": {
16
13
  "archiver": "^7.0.1",