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.
- package/dist/index.mjs +450 -126
- 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: [
|
|
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:
|
|
59
|
+
agents: ".cursor/agents/"
|
|
52
60
|
},
|
|
53
61
|
global: {
|
|
54
62
|
skills: "~/.cursor/skills/",
|
|
55
63
|
rules: "~/.cursor/rules/",
|
|
56
|
-
agents:
|
|
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 ||
|
|
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
|
|
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
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
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
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
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
|
|
503
|
-
const
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
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
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
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
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
const
|
|
547
|
-
|
|
548
|
-
|
|
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
|
-
|
|
552
|
-
path:
|
|
553
|
-
content
|
|
554
|
-
|
|
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
|
-
|
|
560
|
-
|
|
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
|
-
*
|
|
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(
|
|
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 =
|
|
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
|
|
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:
|
|
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:
|
|
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
|
|
1090
|
+
name: `[${r.type}] ${r.name} - ${this.truncateDescription(r.description)}`,
|
|
770
1091
|
value: r,
|
|
771
|
-
checked:
|
|
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:
|
|
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:
|
|
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:
|
|
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.
|
|
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",
|