claudekit-cli 1.0.1 → 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 (69) hide show
  1. package/.github/workflows/ci.yml +2 -0
  2. package/.github/workflows/release.yml +44 -0
  3. package/CHANGELOG.md +28 -0
  4. package/CLAUDE.md +3 -2
  5. package/LICENSE +21 -0
  6. package/README.md +73 -3
  7. package/dist/index.js +11556 -10926
  8. package/package.json +1 -1
  9. package/src/commands/new.ts +41 -9
  10. package/src/commands/update.ts +59 -13
  11. package/src/commands/version.ts +135 -0
  12. package/src/index.ts +53 -1
  13. package/src/lib/download.ts +231 -1
  14. package/src/lib/github.ts +56 -0
  15. package/src/lib/prompts.ts +4 -3
  16. package/src/types.ts +11 -2
  17. package/src/utils/file-scanner.ts +134 -0
  18. package/src/utils/logger.ts +108 -21
  19. package/src/utils/safe-prompts.ts +54 -0
  20. package/tests/commands/version.test.ts +297 -0
  21. package/tests/lib/github-download-priority.test.ts +301 -0
  22. package/tests/lib/github.test.ts +2 -2
  23. package/tests/lib/merge.test.ts +77 -0
  24. package/tests/types.test.ts +4 -0
  25. package/tests/utils/file-scanner.test.ts +202 -0
  26. package/tests/utils/logger.test.ts +115 -0
  27. package/.opencode/agent/code-reviewer.md +0 -141
  28. package/.opencode/agent/debugger.md +0 -74
  29. package/.opencode/agent/docs-manager.md +0 -119
  30. package/.opencode/agent/git-manager.md +0 -60
  31. package/.opencode/agent/planner-researcher.md +0 -100
  32. package/.opencode/agent/planner.md +0 -87
  33. package/.opencode/agent/project-manager.md +0 -113
  34. package/.opencode/agent/researcher.md +0 -173
  35. package/.opencode/agent/solution-brainstormer.md +0 -89
  36. package/.opencode/agent/system-architecture.md +0 -192
  37. package/.opencode/agent/tester.md +0 -96
  38. package/.opencode/agent/ui-ux-designer.md +0 -203
  39. package/.opencode/agent/ui-ux-developer.md +0 -97
  40. package/.opencode/command/cook.md +0 -7
  41. package/.opencode/command/debug.md +0 -10
  42. package/.opencode/command/design/3d.md +0 -65
  43. package/.opencode/command/design/fast.md +0 -18
  44. package/.opencode/command/design/good.md +0 -21
  45. package/.opencode/command/design/screenshot.md +0 -22
  46. package/.opencode/command/design/video.md +0 -22
  47. package/.opencode/command/fix/ci.md +0 -8
  48. package/.opencode/command/fix/fast.md +0 -11
  49. package/.opencode/command/fix/hard.md +0 -15
  50. package/.opencode/command/fix/logs.md +0 -16
  51. package/.opencode/command/fix/test.md +0 -18
  52. package/.opencode/command/fix/types.md +0 -10
  53. package/.opencode/command/git/cm.md +0 -5
  54. package/.opencode/command/git/cp.md +0 -4
  55. package/.opencode/command/plan/ci.md +0 -12
  56. package/.opencode/command/plan/two.md +0 -13
  57. package/.opencode/command/plan.md +0 -10
  58. package/.opencode/command/test.md +0 -7
  59. package/.opencode/command/watzup.md +0 -8
  60. package/plans/251008-claudekit-cli-implementation-plan.md +0 -1469
  61. package/plans/reports/251008-from-code-reviewer-to-developer-review-report.md +0 -864
  62. package/plans/reports/251008-from-tester-to-developer-test-summary-report.md +0 -409
  63. package/plans/reports/251008-researcher-download-extraction-report.md +0 -1377
  64. package/plans/reports/251008-researcher-github-api-report.md +0 -1339
  65. package/plans/research/251008-cli-frameworks-bun-research.md +0 -1051
  66. package/plans/templates/bug-fix-template.md +0 -69
  67. package/plans/templates/feature-implementation-template.md +0 -84
  68. package/plans/templates/refactor-template.md +0 -82
  69. package/plans/templates/template-usage-guide.md +0 -58
package/src/lib/github.ts CHANGED
@@ -154,4 +154,60 @@ export class GitHubClient {
154
154
  );
155
155
  }
156
156
  }
157
+
158
+ /**
159
+ * Get downloadable asset or source code URL from release
160
+ * Priority:
161
+ * 1. "ClaudeKit Engineer Package" or "ClaudeKit Marketing Package" zip file
162
+ * 2. Other custom uploaded assets (.tar.gz, .tgz, .zip)
163
+ * 3. GitHub's automatic tarball URL
164
+ */
165
+ static getDownloadableAsset(release: GitHubRelease): {
166
+ type: "asset" | "tarball" | "zipball";
167
+ url: string;
168
+ name: string;
169
+ size?: number;
170
+ } {
171
+ // First priority: Look for official ClaudeKit package assets
172
+ const packageAsset = release.assets.find(
173
+ (a) =>
174
+ a.name.toLowerCase().includes("claudekit") &&
175
+ a.name.toLowerCase().includes("package") &&
176
+ a.name.endsWith(".zip"),
177
+ );
178
+
179
+ if (packageAsset) {
180
+ logger.debug(`Using ClaudeKit package asset: ${packageAsset.name}`);
181
+ return {
182
+ type: "asset",
183
+ url: packageAsset.browser_download_url,
184
+ name: packageAsset.name,
185
+ size: packageAsset.size,
186
+ };
187
+ }
188
+
189
+ // Second priority: Look for any custom uploaded assets
190
+ const customAsset = release.assets.find(
191
+ (a) => a.name.endsWith(".tar.gz") || a.name.endsWith(".tgz") || a.name.endsWith(".zip"),
192
+ );
193
+
194
+ if (customAsset) {
195
+ logger.debug(`Using custom asset: ${customAsset.name}`);
196
+ return {
197
+ type: "asset",
198
+ url: customAsset.browser_download_url,
199
+ name: customAsset.name,
200
+ size: customAsset.size,
201
+ };
202
+ }
203
+
204
+ // Fall back to GitHub's automatic tarball
205
+ logger.debug("Using GitHub automatic tarball");
206
+ return {
207
+ type: "tarball",
208
+ url: release.tarball_url,
209
+ name: `${release.tag_name}.tar.gz`,
210
+ size: undefined, // Size unknown for automatic tarballs
211
+ };
212
+ }
157
213
  }
@@ -1,5 +1,6 @@
1
1
  import * as clack from "@clack/prompts";
2
2
  import { AVAILABLE_KITS, type KitType } from "../types.js";
3
+ import { intro, note, outro } from "../utils/safe-prompts.js";
3
4
 
4
5
  export class PromptsManager {
5
6
  /**
@@ -94,20 +95,20 @@ export class PromptsManager {
94
95
  * Show intro message
95
96
  */
96
97
  intro(message: string): void {
97
- clack.intro(message);
98
+ intro(message);
98
99
  }
99
100
 
100
101
  /**
101
102
  * Show outro message
102
103
  */
103
104
  outro(message: string): void {
104
- clack.outro(message);
105
+ outro(message);
105
106
  }
106
107
 
107
108
  /**
108
109
  * Show note
109
110
  */
110
111
  note(message: string, title?: string): void {
111
- clack.note(message, title);
112
+ note(message, title);
112
113
  }
113
114
  }
package/src/types.ts CHANGED
@@ -19,6 +19,13 @@ export const UpdateCommandOptionsSchema = z.object({
19
19
  });
20
20
  export type UpdateCommandOptions = z.infer<typeof UpdateCommandOptionsSchema>;
21
21
 
22
+ export const VersionCommandOptionsSchema = z.object({
23
+ kit: KitType.optional(),
24
+ limit: z.number().optional(),
25
+ all: z.boolean().optional(),
26
+ });
27
+ export type VersionCommandOptions = z.infer<typeof VersionCommandOptionsSchema>;
28
+
22
29
  // Config schemas
23
30
  export const ConfigSchema = z.object({
24
31
  github: z
@@ -53,6 +60,8 @@ export const GitHubReleaseSchema = z.object({
53
60
  prerelease: z.boolean(),
54
61
  assets: z.array(GitHubReleaseAssetSchema),
55
62
  published_at: z.string().optional(),
63
+ tarball_url: z.string().url(),
64
+ zipball_url: z.string().url(),
56
65
  });
57
66
  export type GitHubRelease = z.infer<typeof GitHubReleaseSchema>;
58
67
 
@@ -70,13 +79,13 @@ export const AVAILABLE_KITS: Record<KitType, KitConfig> = {
70
79
  engineer: {
71
80
  name: "ClaudeKit Engineer",
72
81
  repo: "claudekit-engineer",
73
- owner: "mrgoonie",
82
+ owner: "claudekit",
74
83
  description: "Engineering toolkit for building with Claude",
75
84
  },
76
85
  marketing: {
77
86
  name: "ClaudeKit Marketing",
78
87
  repo: "claudekit-marketing",
79
- owner: "mrgoonie",
88
+ owner: "claudekit",
80
89
  description: "[Coming Soon] Marketing toolkit",
81
90
  },
82
91
  };
@@ -0,0 +1,134 @@
1
+ import { join, relative, resolve } from "node:path";
2
+ import { lstat, pathExists, readdir } from "fs-extra";
3
+ import { logger } from "./logger.js";
4
+
5
+ /**
6
+ * Utility class for scanning directories and comparing file structures
7
+ */
8
+ export class FileScanner {
9
+ /**
10
+ * Get all files in a directory recursively
11
+ *
12
+ * @param dirPath - Directory path to scan
13
+ * @param relativeTo - Base path for calculating relative paths (defaults to dirPath)
14
+ * @returns Array of relative file paths
15
+ *
16
+ * @example
17
+ * ```typescript
18
+ * const files = await FileScanner.getFiles('/path/to/dir');
19
+ * // Returns: ['file1.txt', 'subdir/file2.txt', ...]
20
+ * ```
21
+ */
22
+ static async getFiles(dirPath: string, relativeTo?: string): Promise<string[]> {
23
+ const basePath = relativeTo || dirPath;
24
+ const files: string[] = [];
25
+
26
+ // Check if directory exists
27
+ if (!(await pathExists(dirPath))) {
28
+ return files;
29
+ }
30
+
31
+ try {
32
+ const entries = await readdir(dirPath);
33
+
34
+ for (const entry of entries) {
35
+ const fullPath = join(dirPath, entry);
36
+
37
+ // Security: Validate path to prevent traversal
38
+ if (!FileScanner.isSafePath(basePath, fullPath)) {
39
+ logger.warning(`Skipping potentially unsafe path: ${entry}`);
40
+ continue;
41
+ }
42
+
43
+ const stats = await lstat(fullPath);
44
+
45
+ // Skip symlinks for security
46
+ if (stats.isSymbolicLink()) {
47
+ logger.debug(`Skipping symlink: ${entry}`);
48
+ continue;
49
+ }
50
+
51
+ if (stats.isDirectory()) {
52
+ // Recursively scan subdirectories
53
+ const subFiles = await FileScanner.getFiles(fullPath, basePath);
54
+ files.push(...subFiles);
55
+ } else if (stats.isFile()) {
56
+ // Add relative path
57
+ const relativePath = relative(basePath, fullPath);
58
+ files.push(relativePath);
59
+ }
60
+ }
61
+ } catch (error) {
62
+ const errorMessage =
63
+ error instanceof Error
64
+ ? `Failed to scan directory: ${dirPath} - ${error.message}`
65
+ : `Failed to scan directory: ${dirPath}`;
66
+ logger.error(errorMessage);
67
+ throw error;
68
+ }
69
+
70
+ return files;
71
+ }
72
+
73
+ /**
74
+ * Find files in destination that don't exist in source
75
+ *
76
+ * @param destDir - Destination directory path
77
+ * @param sourceDir - Source directory path
78
+ * @param subPath - Subdirectory to compare (e.g., '.claude')
79
+ * @returns Array of relative file paths that are custom (exist in dest but not in source)
80
+ *
81
+ * @example
82
+ * ```typescript
83
+ * const customFiles = await FileScanner.findCustomFiles(
84
+ * '/path/to/project',
85
+ * '/path/to/release',
86
+ * '.claude'
87
+ * );
88
+ * // Returns: ['.claude/custom-command.md', '.claude/workflows/my-workflow.md']
89
+ * ```
90
+ */
91
+ static async findCustomFiles(
92
+ destDir: string,
93
+ sourceDir: string,
94
+ subPath: string,
95
+ ): Promise<string[]> {
96
+ const destSubDir = join(destDir, subPath);
97
+ const sourceSubDir = join(sourceDir, subPath);
98
+
99
+ // Get files from both directories
100
+ const destFiles = await FileScanner.getFiles(destSubDir, destDir);
101
+ const sourceFiles = await FileScanner.getFiles(sourceSubDir, sourceDir);
102
+
103
+ // Create a Set of source files for O(1) lookup
104
+ const sourceFileSet = new Set(sourceFiles);
105
+
106
+ // Find files in destination that don't exist in source
107
+ const customFiles = destFiles.filter((file) => !sourceFileSet.has(file));
108
+
109
+ if (customFiles.length > 0) {
110
+ logger.info(`Found ${customFiles.length} custom file(s) in ${subPath}/`);
111
+ customFiles.slice(0, 5).forEach((file) => logger.debug(` - ${file}`));
112
+ if (customFiles.length > 5) {
113
+ logger.debug(` ... and ${customFiles.length - 5} more`);
114
+ }
115
+ }
116
+
117
+ return customFiles;
118
+ }
119
+
120
+ /**
121
+ * Validate path to prevent path traversal attacks
122
+ *
123
+ * @param basePath - Base directory path
124
+ * @param targetPath - Target path to validate
125
+ * @returns true if path is safe, false otherwise
126
+ */
127
+ private static isSafePath(basePath: string, targetPath: string): boolean {
128
+ const resolvedBase = resolve(basePath);
129
+ const resolvedTarget = resolve(targetPath);
130
+
131
+ // Ensure target is within base
132
+ return resolvedTarget.startsWith(resolvedBase);
133
+ }
134
+ }
@@ -1,37 +1,124 @@
1
+ import { type WriteStream, createWriteStream } from "node:fs";
1
2
  import pc from "picocolors";
2
3
 
3
- export const logger = {
4
- info: (message: string) => {
5
- console.log(pc.blue(""), message);
6
- },
4
+ // Use ASCII-safe symbols to avoid unicode rendering issues in certain terminals
5
+ const symbols = {
6
+ info: "[i]",
7
+ success: "[✓]",
8
+ warning: "[!]",
9
+ error: "[✗]",
10
+ };
11
+
12
+ interface LogContext {
13
+ [key: string]: any;
14
+ }
15
+
16
+ class Logger {
17
+ private verboseEnabled = false;
18
+ private logFileStream?: WriteStream;
7
19
 
8
- success: (message: string) => {
9
- console.log(pc.green("✔"), message);
10
- },
20
+ info(message: string): void {
21
+ console.log(pc.blue(symbols.info), message);
22
+ }
11
23
 
12
- warning: (message: string) => {
13
- console.log(pc.yellow("⚠"), message);
14
- },
24
+ success(message: string): void {
25
+ console.log(pc.green(symbols.success), message);
26
+ }
15
27
 
16
- error: (message: string) => {
17
- console.error(pc.red("✖"), message);
18
- },
28
+ warning(message: string): void {
29
+ console.log(pc.yellow(symbols.warning), message);
30
+ }
19
31
 
20
- debug: (message: string) => {
32
+ error(message: string): void {
33
+ console.error(pc.red(symbols.error), message);
34
+ }
35
+
36
+ debug(message: string): void {
21
37
  if (process.env.DEBUG) {
22
38
  console.log(pc.gray("[DEBUG]"), message);
23
39
  }
24
- },
40
+ }
41
+
42
+ verbose(message: string, context?: LogContext): void {
43
+ if (!this.verboseEnabled) return;
44
+
45
+ const timestamp = this.getTimestamp();
46
+ const sanitizedMessage = this.sanitize(message);
47
+ const formattedContext = context ? this.formatContext(context) : "";
48
+
49
+ const logLine = `${timestamp} ${pc.gray("[VERBOSE]")} ${sanitizedMessage}${formattedContext}`;
50
+
51
+ console.error(logLine);
52
+
53
+ if (this.logFileStream) {
54
+ const plainLogLine = `${timestamp} [VERBOSE] ${sanitizedMessage}${formattedContext}`;
55
+ this.logFileStream.write(`${plainLogLine}\n`);
56
+ }
57
+ }
58
+
59
+ setVerbose(enabled: boolean): void {
60
+ this.verboseEnabled = enabled;
61
+ if (enabled) {
62
+ this.verbose("Verbose logging enabled");
63
+ }
64
+ }
65
+
66
+ isVerbose(): boolean {
67
+ return this.verboseEnabled;
68
+ }
69
+
70
+ setLogFile(path?: string): void {
71
+ if (this.logFileStream) {
72
+ this.logFileStream.end();
73
+ this.logFileStream = undefined;
74
+ }
75
+
76
+ if (path) {
77
+ this.logFileStream = createWriteStream(path, {
78
+ flags: "a",
79
+ mode: 0o600,
80
+ });
81
+ this.verbose(`Logging to file: ${path}`);
82
+ }
83
+ }
25
84
 
26
- // Sanitize sensitive data from logs
27
- sanitize: (text: string): string => {
28
- // Remove GitHub tokens
85
+ sanitize(text: string): string {
29
86
  return text
30
87
  .replace(/ghp_[a-zA-Z0-9]{36}/g, "ghp_***")
31
88
  .replace(/github_pat_[a-zA-Z0-9_]{82}/g, "github_pat_***")
32
89
  .replace(/gho_[a-zA-Z0-9]{36}/g, "gho_***")
33
90
  .replace(/ghu_[a-zA-Z0-9]{36}/g, "ghu_***")
34
91
  .replace(/ghs_[a-zA-Z0-9]{36}/g, "ghs_***")
35
- .replace(/ghr_[a-zA-Z0-9]{36}/g, "ghr_***");
36
- },
37
- };
92
+ .replace(/ghr_[a-zA-Z0-9]{36}/g, "ghr_***")
93
+ .replace(/Bearer [a-zA-Z0-9_-]+/g, "Bearer ***")
94
+ .replace(/token=[a-zA-Z0-9_-]+/g, "token=***");
95
+ }
96
+
97
+ private getTimestamp(): string {
98
+ return new Date().toISOString();
99
+ }
100
+
101
+ private formatContext(context: LogContext): string {
102
+ const sanitized = Object.entries(context).reduce((acc, [key, value]) => {
103
+ if (typeof value === "string") {
104
+ acc[key] = this.sanitize(value);
105
+ } else if (value && typeof value === "object") {
106
+ // Recursively sanitize nested objects
107
+ try {
108
+ const stringified = JSON.stringify(value);
109
+ const sanitizedStr = this.sanitize(stringified);
110
+ acc[key] = JSON.parse(sanitizedStr);
111
+ } catch {
112
+ acc[key] = "[Object]";
113
+ }
114
+ } else {
115
+ acc[key] = value;
116
+ }
117
+ return acc;
118
+ }, {} as LogContext);
119
+
120
+ return `\n ${JSON.stringify(sanitized, null, 2).split("\n").join("\n ")}`;
121
+ }
122
+ }
123
+
124
+ export const logger = new Logger();
@@ -0,0 +1,54 @@
1
+ import * as clack from "@clack/prompts";
2
+
3
+ /**
4
+ * Safe wrapper around clack prompts that handles unicode rendering issues.
5
+ * Sets up proper environment to minimize encoding problems.
6
+ */
7
+
8
+ // Store original methods
9
+ const originalIntro = clack.intro;
10
+ const originalOutro = clack.outro;
11
+ const originalNote = clack.note;
12
+
13
+ /**
14
+ * Wrapped intro that handles encoding better
15
+ */
16
+ export function intro(message: string): void {
17
+ try {
18
+ originalIntro(message);
19
+ } catch {
20
+ // Fallback to simple console log if clack fails
21
+ console.log(`\n=== ${message} ===\n`);
22
+ }
23
+ }
24
+
25
+ /**
26
+ * Wrapped outro that handles encoding better
27
+ */
28
+ export function outro(message: string): void {
29
+ try {
30
+ originalOutro(message);
31
+ } catch {
32
+ // Fallback to simple console log if clack fails
33
+ console.log(`\n=== ${message} ===\n`);
34
+ }
35
+ }
36
+
37
+ /**
38
+ * Wrapped note that handles encoding better
39
+ */
40
+ export function note(message: string, title?: string): void {
41
+ try {
42
+ originalNote(message, title);
43
+ } catch {
44
+ // Fallback to simple console log if clack fails
45
+ if (title) {
46
+ console.log(`\n--- ${title} ---`);
47
+ }
48
+ console.log(message);
49
+ console.log();
50
+ }
51
+ }
52
+
53
+ // Re-export other clack functions unchanged
54
+ export { select, confirm, text, isCancel } from "@clack/prompts";