claudekit-cli 1.0.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 (137) hide show
  1. package/.claude/agents/brainstormer.md +96 -0
  2. package/.claude/agents/code-reviewer.md +141 -0
  3. package/.claude/agents/copywriter.md +108 -0
  4. package/.claude/agents/database-admin.md +86 -0
  5. package/.claude/agents/debugger.md +124 -0
  6. package/.claude/agents/docs-manager.md +115 -0
  7. package/.claude/agents/git-manager.md +60 -0
  8. package/.claude/agents/journal-writer.md +111 -0
  9. package/.claude/agents/planner.md +87 -0
  10. package/.claude/agents/project-manager.md +113 -0
  11. package/.claude/agents/researcher.md +173 -0
  12. package/.claude/agents/scout.md +123 -0
  13. package/.claude/agents/tester.md +95 -0
  14. package/.claude/agents/ui-ux-designer.md +206 -0
  15. package/.claude/commands/bootstrap.md +104 -0
  16. package/.claude/commands/brainstorm.md +67 -0
  17. package/.claude/commands/content/enhance.md +13 -0
  18. package/.claude/commands/content/fast.md +11 -0
  19. package/.claude/commands/content/good.md +13 -0
  20. package/.claude/commands/cook.md +19 -0
  21. package/.claude/commands/debug.md +10 -0
  22. package/.claude/commands/design/3d.md +65 -0
  23. package/.claude/commands/design/describe.md +13 -0
  24. package/.claude/commands/design/fast.md +19 -0
  25. package/.claude/commands/design/good.md +23 -0
  26. package/.claude/commands/design/screenshot.md +23 -0
  27. package/.claude/commands/design/video.md +23 -0
  28. package/.claude/commands/docs/init.md +13 -0
  29. package/.claude/commands/docs/summarize.md +10 -0
  30. package/.claude/commands/docs/update.md +21 -0
  31. package/.claude/commands/fix/ci.md +11 -0
  32. package/.claude/commands/fix/fast.md +12 -0
  33. package/.claude/commands/fix/hard.md +18 -0
  34. package/.claude/commands/fix/logs.md +16 -0
  35. package/.claude/commands/fix/test.md +18 -0
  36. package/.claude/commands/fix/types.md +10 -0
  37. package/.claude/commands/git/cm.md +5 -0
  38. package/.claude/commands/git/cp.md +4 -0
  39. package/.claude/commands/integrate/polar.md +42 -0
  40. package/.claude/commands/plan/ci.md +12 -0
  41. package/.claude/commands/plan/two.md +13 -0
  42. package/.claude/commands/plan.md +10 -0
  43. package/.claude/commands/scout.md +29 -0
  44. package/.claude/commands/test.md +7 -0
  45. package/.claude/commands/watzup.md +8 -0
  46. package/.claude/hooks/telegram_notify.sh +136 -0
  47. package/.claude/send-discord.sh +64 -0
  48. package/.claude/settings.json +7 -0
  49. package/.claude/statusline.sh +143 -0
  50. package/.claude/workflows/development-rules.md +80 -0
  51. package/.claude/workflows/documentation-management.md +28 -0
  52. package/.claude/workflows/orchestration-protocol.md +16 -0
  53. package/.claude/workflows/primary-workflow.md +41 -0
  54. package/.github/workflows/ci.yml +43 -0
  55. package/.github/workflows/release.yml +58 -0
  56. package/.opencode/agent/code-reviewer.md +141 -0
  57. package/.opencode/agent/debugger.md +74 -0
  58. package/.opencode/agent/docs-manager.md +119 -0
  59. package/.opencode/agent/git-manager.md +60 -0
  60. package/.opencode/agent/planner-researcher.md +100 -0
  61. package/.opencode/agent/planner.md +87 -0
  62. package/.opencode/agent/project-manager.md +113 -0
  63. package/.opencode/agent/researcher.md +173 -0
  64. package/.opencode/agent/solution-brainstormer.md +89 -0
  65. package/.opencode/agent/system-architecture.md +192 -0
  66. package/.opencode/agent/tester.md +96 -0
  67. package/.opencode/agent/ui-ux-designer.md +203 -0
  68. package/.opencode/agent/ui-ux-developer.md +97 -0
  69. package/.opencode/command/cook.md +7 -0
  70. package/.opencode/command/debug.md +10 -0
  71. package/.opencode/command/design/3d.md +65 -0
  72. package/.opencode/command/design/fast.md +18 -0
  73. package/.opencode/command/design/good.md +21 -0
  74. package/.opencode/command/design/screenshot.md +22 -0
  75. package/.opencode/command/design/video.md +22 -0
  76. package/.opencode/command/docs/init.md +11 -0
  77. package/.opencode/command/docs/summarize.md +10 -0
  78. package/.opencode/command/docs/update.md +18 -0
  79. package/.opencode/command/fix/ci.md +8 -0
  80. package/.opencode/command/fix/fast.md +11 -0
  81. package/.opencode/command/fix/hard.md +15 -0
  82. package/.opencode/command/fix/logs.md +16 -0
  83. package/.opencode/command/fix/test.md +18 -0
  84. package/.opencode/command/fix/types.md +10 -0
  85. package/.opencode/command/git/cm.md +5 -0
  86. package/.opencode/command/git/cp.md +4 -0
  87. package/.opencode/command/plan/ci.md +12 -0
  88. package/.opencode/command/plan/two.md +13 -0
  89. package/.opencode/command/plan.md +10 -0
  90. package/.opencode/command/test.md +7 -0
  91. package/.opencode/command/watzup.md +8 -0
  92. package/.releaserc.json +17 -0
  93. package/.repomixignore +15 -0
  94. package/AGENTS.md +217 -0
  95. package/CHANGELOG.md +16 -0
  96. package/CLAUDE.md +33 -0
  97. package/README.md +214 -0
  98. package/biome.json +25 -0
  99. package/bun.lock +1238 -0
  100. package/dist/index.js +19100 -0
  101. package/docs/code-standards.md +1128 -0
  102. package/docs/codebase-summary.md +821 -0
  103. package/docs/github-setup.md +176 -0
  104. package/docs/project-pdr.md +739 -0
  105. package/docs/system-architecture.md +950 -0
  106. package/docs/tech-stack.md +290 -0
  107. package/package.json +60 -0
  108. package/plans/251008-claudekit-cli-implementation-plan.md +1469 -0
  109. package/plans/reports/251008-from-code-reviewer-to-developer-review-report.md +864 -0
  110. package/plans/reports/251008-from-tester-to-developer-test-summary-report.md +409 -0
  111. package/plans/reports/251008-researcher-download-extraction-report.md +1377 -0
  112. package/plans/reports/251008-researcher-github-api-report.md +1339 -0
  113. package/plans/research/251008-cli-frameworks-bun-research.md +1051 -0
  114. package/plans/templates/bug-fix-template.md +69 -0
  115. package/plans/templates/feature-implementation-template.md +84 -0
  116. package/plans/templates/refactor-template.md +82 -0
  117. package/plans/templates/template-usage-guide.md +58 -0
  118. package/src/commands/new.ts +118 -0
  119. package/src/commands/update.ts +114 -0
  120. package/src/index.ts +44 -0
  121. package/src/lib/auth.ts +157 -0
  122. package/src/lib/download.ts +180 -0
  123. package/src/lib/github.ts +157 -0
  124. package/src/lib/merge.ts +116 -0
  125. package/src/lib/prompts.ts +113 -0
  126. package/src/types.ts +149 -0
  127. package/src/utils/config.ts +87 -0
  128. package/src/utils/logger.ts +37 -0
  129. package/tests/lib/auth.test.ts +116 -0
  130. package/tests/lib/download.test.ts +70 -0
  131. package/tests/lib/github.test.ts +52 -0
  132. package/tests/lib/merge.test.ts +138 -0
  133. package/tests/lib/prompts.test.ts +66 -0
  134. package/tests/types.test.ts +255 -0
  135. package/tests/utils/config.test.ts +263 -0
  136. package/tests/utils/logger.test.ts +124 -0
  137. package/tsconfig.json +30 -0
@@ -0,0 +1,157 @@
1
+ import { execSync } from "node:child_process";
2
+ import * as clack from "@clack/prompts";
3
+ import keytar from "keytar";
4
+ import { type AuthMethod, AuthenticationError } from "../types.js";
5
+ import { ConfigManager } from "../utils/config.js";
6
+ import { logger } from "../utils/logger.js";
7
+
8
+ const SERVICE_NAME = "claudekit-cli";
9
+ const ACCOUNT_NAME = "github-token";
10
+
11
+ export class AuthManager {
12
+ private static token: string | null = null;
13
+ private static authMethod: AuthMethod | null = null;
14
+
15
+ /**
16
+ * Get GitHub token with multi-tier fallback
17
+ */
18
+ static async getToken(): Promise<{ token: string; method: AuthMethod }> {
19
+ if (AuthManager.token && AuthManager.authMethod) {
20
+ return { token: AuthManager.token, method: AuthManager.authMethod };
21
+ }
22
+
23
+ // Try 1: GitHub CLI
24
+ try {
25
+ const token = await AuthManager.getFromGhCli();
26
+ if (token) {
27
+ AuthManager.token = token;
28
+ AuthManager.authMethod = "gh-cli";
29
+ logger.debug("Using GitHub CLI authentication");
30
+ return { token, method: "gh-cli" };
31
+ }
32
+ } catch (error) {
33
+ logger.debug("GitHub CLI not available");
34
+ }
35
+
36
+ // Try 2: Environment variables
37
+ const envToken = process.env.GITHUB_TOKEN || process.env.GH_TOKEN;
38
+ if (envToken) {
39
+ AuthManager.token = envToken;
40
+ AuthManager.authMethod = "env-var";
41
+ logger.debug("Using environment variable authentication");
42
+ return { token: envToken, method: "env-var" };
43
+ }
44
+
45
+ // Try 3: Config file
46
+ try {
47
+ const configToken = await ConfigManager.getToken();
48
+ if (configToken) {
49
+ AuthManager.token = configToken;
50
+ AuthManager.authMethod = "env-var";
51
+ logger.debug("Using config file authentication");
52
+ return { token: configToken, method: "env-var" };
53
+ }
54
+ } catch (error) {
55
+ logger.debug("No token in config file");
56
+ }
57
+
58
+ // Try 4: OS Keychain
59
+ try {
60
+ const keychainToken = await keytar.getPassword(SERVICE_NAME, ACCOUNT_NAME);
61
+ if (keychainToken) {
62
+ AuthManager.token = keychainToken;
63
+ AuthManager.authMethod = "keychain";
64
+ logger.debug("Using keychain authentication");
65
+ return { token: keychainToken, method: "keychain" };
66
+ }
67
+ } catch (error) {
68
+ logger.debug("No token in keychain");
69
+ }
70
+
71
+ // Try 5: Prompt user
72
+ const promptedToken = await AuthManager.promptForToken();
73
+ AuthManager.token = promptedToken;
74
+ AuthManager.authMethod = "prompt";
75
+ return { token: promptedToken, method: "prompt" };
76
+ }
77
+
78
+ /**
79
+ * Get token from GitHub CLI
80
+ */
81
+ private static async getFromGhCli(): Promise<string | null> {
82
+ try {
83
+ const token = execSync("gh auth token", {
84
+ encoding: "utf-8",
85
+ stdio: ["pipe", "pipe", "ignore"],
86
+ }).trim();
87
+ if (token && token.length > 0) {
88
+ return token;
89
+ }
90
+ return null;
91
+ } catch {
92
+ return null;
93
+ }
94
+ }
95
+
96
+ /**
97
+ * Prompt user for token
98
+ */
99
+ private static async promptForToken(): Promise<string> {
100
+ const token = await clack.password({
101
+ message: "Enter your GitHub Personal Access Token:",
102
+ validate: (value) => {
103
+ if (!value || value.length === 0) {
104
+ return "Token is required";
105
+ }
106
+ if (!value.startsWith("ghp_") && !value.startsWith("github_pat_")) {
107
+ return 'Invalid token format. Token should start with "ghp_" or "github_pat_"';
108
+ }
109
+ return;
110
+ },
111
+ });
112
+
113
+ if (clack.isCancel(token)) {
114
+ throw new AuthenticationError("Authentication cancelled by user");
115
+ }
116
+
117
+ // Ask if user wants to save token
118
+ const save = await clack.confirm({
119
+ message: "Save token securely in OS keychain?",
120
+ });
121
+
122
+ if (save && !clack.isCancel(save)) {
123
+ try {
124
+ await keytar.setPassword(SERVICE_NAME, ACCOUNT_NAME, token);
125
+ logger.success("Token saved securely in keychain");
126
+ } catch (error) {
127
+ logger.warning("Failed to save token to keychain");
128
+ }
129
+ }
130
+
131
+ return token;
132
+ }
133
+
134
+ /**
135
+ * Clear stored token
136
+ */
137
+ static async clearToken(): Promise<void> {
138
+ // Always clear in-memory token
139
+ AuthManager.token = null;
140
+ AuthManager.authMethod = null;
141
+
142
+ // Try to clear from keychain (may fail in CI)
143
+ try {
144
+ await keytar.deletePassword(SERVICE_NAME, ACCOUNT_NAME);
145
+ logger.success("Token cleared from keychain");
146
+ } catch (error) {
147
+ logger.warning("Failed to clear token from keychain");
148
+ }
149
+ }
150
+
151
+ /**
152
+ * Validate token format
153
+ */
154
+ static isValidTokenFormat(token: string): boolean {
155
+ return token.startsWith("ghp_") || token.startsWith("github_pat_");
156
+ }
157
+ }
@@ -0,0 +1,180 @@
1
+ import { createReadStream, createWriteStream } from "node:fs";
2
+ import { mkdir } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { pipeline } from "node:stream";
6
+ import { promisify } from "node:util";
7
+ import cliProgress from "cli-progress";
8
+ import ora from "ora";
9
+ import * as tar from "tar";
10
+ import unzipper from "unzipper";
11
+ import {
12
+ type ArchiveType,
13
+ DownloadError,
14
+ ExtractionError,
15
+ type GitHubReleaseAsset,
16
+ } from "../types.js";
17
+ import { logger } from "../utils/logger.js";
18
+
19
+ const streamPipeline = promisify(pipeline);
20
+
21
+ export class DownloadManager {
22
+ /**
23
+ * Download asset from URL with progress tracking
24
+ */
25
+ async downloadAsset(asset: GitHubReleaseAsset, destDir: string): Promise<string> {
26
+ try {
27
+ const destPath = join(destDir, asset.name);
28
+
29
+ // Ensure destination directory exists
30
+ await mkdir(destDir, { recursive: true });
31
+
32
+ logger.info(`Downloading ${asset.name} (${this.formatBytes(asset.size)})...`);
33
+
34
+ // Create progress bar
35
+ const progressBar = new cliProgress.SingleBar({
36
+ format: "Progress |{bar}| {percentage}% | {value}/{total} MB",
37
+ barCompleteChar: "\u2588",
38
+ barIncompleteChar: "\u2591",
39
+ hideCursor: true,
40
+ });
41
+
42
+ const response = await fetch(asset.browser_download_url, {
43
+ headers: {
44
+ Accept: "application/octet-stream",
45
+ },
46
+ });
47
+
48
+ if (!response.ok) {
49
+ throw new DownloadError(`Failed to download: ${response.statusText}`);
50
+ }
51
+
52
+ const totalSize = asset.size;
53
+ let downloadedSize = 0;
54
+
55
+ progressBar.start(Math.round(totalSize / 1024 / 1024), 0);
56
+
57
+ const fileStream = createWriteStream(destPath);
58
+ const reader = response.body?.getReader();
59
+
60
+ if (!reader) {
61
+ throw new DownloadError("Failed to get response reader");
62
+ }
63
+
64
+ try {
65
+ while (true) {
66
+ const { done, value } = await reader.read();
67
+
68
+ if (done) {
69
+ break;
70
+ }
71
+
72
+ fileStream.write(value);
73
+ downloadedSize += value.length;
74
+ progressBar.update(Math.round(downloadedSize / 1024 / 1024));
75
+ }
76
+
77
+ fileStream.end();
78
+ progressBar.stop();
79
+
80
+ logger.success(`Downloaded ${asset.name}`);
81
+ return destPath;
82
+ } catch (error) {
83
+ fileStream.close();
84
+ progressBar.stop();
85
+ throw error;
86
+ }
87
+ } catch (error) {
88
+ throw new DownloadError(
89
+ `Failed to download ${asset.name}: ${error instanceof Error ? error.message : "Unknown error"}`,
90
+ );
91
+ }
92
+ }
93
+
94
+ /**
95
+ * Extract archive to destination
96
+ */
97
+ async extractArchive(
98
+ archivePath: string,
99
+ destDir: string,
100
+ archiveType?: ArchiveType,
101
+ ): Promise<void> {
102
+ const spinner = ora("Extracting files...").start();
103
+
104
+ try {
105
+ // Detect archive type from filename if not provided
106
+ const detectedType = archiveType || this.detectArchiveType(archivePath);
107
+
108
+ // Ensure destination directory exists
109
+ await mkdir(destDir, { recursive: true });
110
+
111
+ if (detectedType === "tar.gz") {
112
+ await this.extractTarGz(archivePath, destDir);
113
+ } else if (detectedType === "zip") {
114
+ await this.extractZip(archivePath, destDir);
115
+ } else {
116
+ throw new ExtractionError(`Unsupported archive type: ${detectedType}`);
117
+ }
118
+
119
+ spinner.succeed("Files extracted successfully");
120
+ } catch (error) {
121
+ spinner.fail("Extraction failed");
122
+ throw new ExtractionError(
123
+ `Failed to extract archive: ${error instanceof Error ? error.message : "Unknown error"}`,
124
+ );
125
+ }
126
+ }
127
+
128
+ /**
129
+ * Extract tar.gz archive
130
+ */
131
+ private async extractTarGz(archivePath: string, destDir: string): Promise<void> {
132
+ await tar.extract({
133
+ file: archivePath,
134
+ cwd: destDir,
135
+ strip: 1, // Strip the root directory from the archive
136
+ });
137
+ }
138
+
139
+ /**
140
+ * Extract zip archive
141
+ */
142
+ private async extractZip(archivePath: string, destDir: string): Promise<void> {
143
+ await streamPipeline(createReadStream(archivePath), unzipper.Extract({ path: destDir }));
144
+ }
145
+
146
+ /**
147
+ * Detect archive type from filename
148
+ */
149
+ private detectArchiveType(filename: string): ArchiveType {
150
+ if (filename.endsWith(".tar.gz") || filename.endsWith(".tgz")) {
151
+ return "tar.gz";
152
+ }
153
+ if (filename.endsWith(".zip")) {
154
+ return "zip";
155
+ }
156
+ throw new ExtractionError(`Cannot detect archive type from filename: ${filename}`);
157
+ }
158
+
159
+ /**
160
+ * Create temporary download directory
161
+ */
162
+ async createTempDir(): Promise<string> {
163
+ const tempDir = join(tmpdir(), `claudekit-${Date.now()}`);
164
+ await mkdir(tempDir, { recursive: true });
165
+ return tempDir;
166
+ }
167
+
168
+ /**
169
+ * Format bytes to human readable string
170
+ */
171
+ private formatBytes(bytes: number): string {
172
+ if (bytes === 0) return "0 Bytes";
173
+
174
+ const k = 1024;
175
+ const sizes = ["Bytes", "KB", "MB", "GB"];
176
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
177
+
178
+ return `${Math.round((bytes / k ** i) * 100) / 100} ${sizes[i]}`;
179
+ }
180
+ }
@@ -0,0 +1,157 @@
1
+ import { Octokit } from "@octokit/rest";
2
+ import { GitHubError, type GitHubRelease, GitHubReleaseSchema, type KitConfig } from "../types.js";
3
+ import { logger } from "../utils/logger.js";
4
+ import { AuthManager } from "./auth.js";
5
+
6
+ export class GitHubClient {
7
+ private octokit: Octokit | null = null;
8
+
9
+ /**
10
+ * Initialize Octokit client with authentication
11
+ */
12
+ private async getClient(): Promise<Octokit> {
13
+ if (this.octokit) {
14
+ return this.octokit;
15
+ }
16
+
17
+ const { token } = await AuthManager.getToken();
18
+
19
+ this.octokit = new Octokit({
20
+ auth: token,
21
+ userAgent: "claudekit-cli",
22
+ request: {
23
+ timeout: 30000, // 30 seconds
24
+ },
25
+ });
26
+
27
+ return this.octokit;
28
+ }
29
+
30
+ /**
31
+ * Get latest release for a kit
32
+ */
33
+ async getLatestRelease(kit: KitConfig): Promise<GitHubRelease> {
34
+ try {
35
+ const client = await this.getClient();
36
+
37
+ logger.debug(`Fetching latest release for ${kit.owner}/${kit.repo}`);
38
+
39
+ const { data } = await client.repos.getLatestRelease({
40
+ owner: kit.owner,
41
+ repo: kit.repo,
42
+ });
43
+
44
+ return GitHubReleaseSchema.parse(data);
45
+ } catch (error: any) {
46
+ if (error?.status === 404) {
47
+ throw new GitHubError(`No releases found for ${kit.name}`, 404);
48
+ }
49
+ if (error?.status === 401) {
50
+ throw new GitHubError("Authentication failed. Please check your GitHub token.", 401);
51
+ }
52
+ if (error?.status === 403) {
53
+ throw new GitHubError(
54
+ "Access denied. Make sure your token has access to private repositories.",
55
+ 403,
56
+ );
57
+ }
58
+ throw new GitHubError(
59
+ `Failed to fetch release: ${error?.message || "Unknown error"}`,
60
+ error?.status,
61
+ );
62
+ }
63
+ }
64
+
65
+ /**
66
+ * Get specific release by version tag
67
+ */
68
+ async getReleaseByTag(kit: KitConfig, tag: string): Promise<GitHubRelease> {
69
+ try {
70
+ const client = await this.getClient();
71
+
72
+ logger.debug(`Fetching release ${tag} for ${kit.owner}/${kit.repo}`);
73
+
74
+ const { data } = await client.repos.getReleaseByTag({
75
+ owner: kit.owner,
76
+ repo: kit.repo,
77
+ tag,
78
+ });
79
+
80
+ return GitHubReleaseSchema.parse(data);
81
+ } catch (error: any) {
82
+ if (error?.status === 404) {
83
+ throw new GitHubError(`Release ${tag} not found for ${kit.name}`, 404);
84
+ }
85
+ if (error?.status === 401) {
86
+ throw new GitHubError("Authentication failed. Please check your GitHub token.", 401);
87
+ }
88
+ if (error?.status === 403) {
89
+ throw new GitHubError(
90
+ "Access denied. Make sure your token has access to private repositories.",
91
+ 403,
92
+ );
93
+ }
94
+ throw new GitHubError(
95
+ `Failed to fetch release: ${error?.message || "Unknown error"}`,
96
+ error?.status,
97
+ );
98
+ }
99
+ }
100
+
101
+ /**
102
+ * List all releases for a kit
103
+ */
104
+ async listReleases(kit: KitConfig, limit = 10): Promise<GitHubRelease[]> {
105
+ try {
106
+ const client = await this.getClient();
107
+
108
+ logger.debug(`Listing releases for ${kit.owner}/${kit.repo}`);
109
+
110
+ const { data } = await client.repos.listReleases({
111
+ owner: kit.owner,
112
+ repo: kit.repo,
113
+ per_page: limit,
114
+ });
115
+
116
+ return data.map((release) => GitHubReleaseSchema.parse(release));
117
+ } catch (error: any) {
118
+ if (error?.status === 401) {
119
+ throw new GitHubError("Authentication failed. Please check your GitHub token.", 401);
120
+ }
121
+ if (error?.status === 403) {
122
+ throw new GitHubError(
123
+ "Access denied. Make sure your token has access to private repositories.",
124
+ 403,
125
+ );
126
+ }
127
+ throw new GitHubError(
128
+ `Failed to list releases: ${error?.message || "Unknown error"}`,
129
+ error?.status,
130
+ );
131
+ }
132
+ }
133
+
134
+ /**
135
+ * Check if user has access to repository
136
+ */
137
+ async checkAccess(kit: KitConfig): Promise<boolean> {
138
+ try {
139
+ const client = await this.getClient();
140
+
141
+ await client.repos.get({
142
+ owner: kit.owner,
143
+ repo: kit.repo,
144
+ });
145
+
146
+ return true;
147
+ } catch (error: any) {
148
+ if (error?.status === 404 || error?.status === 403) {
149
+ return false;
150
+ }
151
+ throw new GitHubError(
152
+ `Failed to check repository access: ${error?.message || "Unknown error"}`,
153
+ error?.status,
154
+ );
155
+ }
156
+ }
157
+ }
@@ -0,0 +1,116 @@
1
+ import { join, relative } from "node:path";
2
+ import * as clack from "@clack/prompts";
3
+ import { copy, pathExists, readdir, stat } from "fs-extra";
4
+ import ignore from "ignore";
5
+ import { PROTECTED_PATTERNS } from "../types.js";
6
+ import { logger } from "../utils/logger.js";
7
+
8
+ export class FileMerger {
9
+ private ig = ignore().add(PROTECTED_PATTERNS);
10
+
11
+ /**
12
+ * Merge files from source to destination with conflict detection
13
+ */
14
+ async merge(sourceDir: string, destDir: string, skipConfirmation = false): Promise<void> {
15
+ // Get list of files that will be affected
16
+ const conflicts = await this.detectConflicts(sourceDir, destDir);
17
+
18
+ if (conflicts.length > 0 && !skipConfirmation) {
19
+ logger.warning(`Found ${conflicts.length} file(s) that will be overwritten:`);
20
+ conflicts.slice(0, 10).forEach((file) => logger.info(` - ${file}`));
21
+ if (conflicts.length > 10) {
22
+ logger.info(` ... and ${conflicts.length - 10} more`);
23
+ }
24
+
25
+ const confirm = await clack.confirm({
26
+ message: "Do you want to continue?",
27
+ });
28
+
29
+ if (clack.isCancel(confirm) || !confirm) {
30
+ throw new Error("Merge cancelled by user");
31
+ }
32
+ }
33
+
34
+ // Copy files
35
+ await this.copyFiles(sourceDir, destDir);
36
+ }
37
+
38
+ /**
39
+ * Detect files that will be overwritten
40
+ */
41
+ private async detectConflicts(sourceDir: string, destDir: string): Promise<string[]> {
42
+ const conflicts: string[] = [];
43
+ const files = await this.getFiles(sourceDir);
44
+
45
+ for (const file of files) {
46
+ const relativePath = relative(sourceDir, file);
47
+
48
+ // Skip protected files
49
+ if (this.ig.ignores(relativePath)) {
50
+ continue;
51
+ }
52
+
53
+ const destPath = join(destDir, relativePath);
54
+ if (await pathExists(destPath)) {
55
+ conflicts.push(relativePath);
56
+ }
57
+ }
58
+
59
+ return conflicts;
60
+ }
61
+
62
+ /**
63
+ * Copy files from source to destination, skipping protected patterns
64
+ */
65
+ private async copyFiles(sourceDir: string, destDir: string): Promise<void> {
66
+ const files = await this.getFiles(sourceDir);
67
+ let copiedCount = 0;
68
+ let skippedCount = 0;
69
+
70
+ for (const file of files) {
71
+ const relativePath = relative(sourceDir, file);
72
+
73
+ // Skip protected files
74
+ if (this.ig.ignores(relativePath)) {
75
+ logger.debug(`Skipping protected file: ${relativePath}`);
76
+ skippedCount++;
77
+ continue;
78
+ }
79
+
80
+ const destPath = join(destDir, relativePath);
81
+ await copy(file, destPath, { overwrite: true });
82
+ copiedCount++;
83
+ }
84
+
85
+ logger.success(`Copied ${copiedCount} file(s), skipped ${skippedCount} protected file(s)`);
86
+ }
87
+
88
+ /**
89
+ * Recursively get all files in a directory
90
+ */
91
+ private async getFiles(dir: string): Promise<string[]> {
92
+ const files: string[] = [];
93
+ const entries = await readdir(dir);
94
+
95
+ for (const entry of entries) {
96
+ const fullPath = join(dir, entry);
97
+ const stats = await stat(fullPath);
98
+
99
+ if (stats.isDirectory()) {
100
+ const subFiles = await this.getFiles(fullPath);
101
+ files.push(...subFiles);
102
+ } else {
103
+ files.push(fullPath);
104
+ }
105
+ }
106
+
107
+ return files;
108
+ }
109
+
110
+ /**
111
+ * Add custom patterns to ignore
112
+ */
113
+ addIgnorePatterns(patterns: string[]): void {
114
+ this.ig.add(patterns);
115
+ }
116
+ }