@khanhcan148/mk 0.1.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.
@@ -0,0 +1,130 @@
1
+ import { join, relative } from 'node:path';
2
+ import { statSync, lstatSync, existsSync } from 'node:fs';
3
+ import fsExtra from 'fs-extra';
4
+ import { KIT_SUBDIRS, COPY_FILTER_PATTERNS, WINDOWS_PATH_WARN_LENGTH } from './constants.js';
5
+
6
+ /**
7
+ * Check if a path should be filtered out during copy.
8
+ * @param {string} filePath
9
+ * @returns {boolean} true if the file should be excluded
10
+ */
11
+ function shouldFilter(filePath) {
12
+ const normalized = filePath.replace(/\\/g, '/');
13
+ for (const pattern of COPY_FILTER_PATTERNS) {
14
+ if (normalized.includes(pattern)) return true;
15
+ }
16
+ return false;
17
+ }
18
+
19
+ /**
20
+ * Collect all file paths recursively in a directory.
21
+ * @param {string} dir
22
+ * @returns {string[]}
23
+ */
24
+ function collectFiles(dir) {
25
+ const results = [];
26
+ function walk(current) {
27
+ let entries;
28
+ try {
29
+ entries = fsExtra.readdirSync(current);
30
+ } catch {
31
+ return;
32
+ }
33
+ for (const entry of entries) {
34
+ const fullPath = join(current, entry);
35
+ try {
36
+ // Use lstatSync (does not follow symlinks) to detect and skip symlinks.
37
+ // Symlinks inside the bundled .claude/ could otherwise redirect writes
38
+ // to arbitrary locations on the user's filesystem.
39
+ const lstat = lstatSync(fullPath);
40
+ if (lstat.isSymbolicLink()) continue;
41
+ if (lstat.isDirectory()) {
42
+ walk(fullPath);
43
+ } else {
44
+ results.push(fullPath);
45
+ }
46
+ } catch {
47
+ // skip inaccessible files
48
+ }
49
+ }
50
+ }
51
+ walk(dir);
52
+ return results;
53
+ }
54
+
55
+ /**
56
+ * Copy kit files from sourceDir (.claude/) to targetDir (.claude/).
57
+ * Only copies KIT_SUBDIRS (agents/, skills/, workflows/).
58
+ *
59
+ * @param {string} sourceDir - Absolute path to source .claude/
60
+ * @param {string} targetDir - Absolute path to target .claude/
61
+ * @param {{ dryRun: boolean }} options
62
+ * @returns {Array<{ relativePath: string, absolutePath: string, sourceAbsPath: string, size: number }>}
63
+ */
64
+ export function copyKitFiles(sourceDir, targetDir, options = {}) {
65
+ const { dryRun = false } = options;
66
+ const fileList = [];
67
+ const warnings = [];
68
+
69
+ for (const subdir of KIT_SUBDIRS) {
70
+ const srcSubdir = join(sourceDir, subdir);
71
+ if (!existsSync(srcSubdir)) continue;
72
+
73
+ // Collect files in this subdir
74
+ const files = collectFiles(srcSubdir);
75
+
76
+ for (const absPath of files) {
77
+ if (shouldFilter(absPath)) continue;
78
+
79
+ const relFromSubdir = relative(srcSubdir, absPath);
80
+ const relPath = join('.claude', subdir, relFromSubdir).replace(/\\/g, '/');
81
+ const destAbs = join(targetDir, subdir, relFromSubdir);
82
+
83
+ // Windows path length check
84
+ if (destAbs.length > WINDOWS_PATH_WARN_LENGTH) {
85
+ warnings.push(`Warning: Long path (${destAbs.length} chars): ${destAbs}`);
86
+ }
87
+
88
+ let size = 0;
89
+ try {
90
+ size = statSync(absPath).size;
91
+ } catch {
92
+ // ignore
93
+ }
94
+
95
+ fileList.push({ relativePath: relPath, absolutePath: destAbs, sourceAbsPath: absPath, size });
96
+ }
97
+ }
98
+
99
+ // Print warnings
100
+ for (const w of warnings) {
101
+ process.stderr.write(w + '\n');
102
+ }
103
+
104
+ if (dryRun) {
105
+ return fileList;
106
+ }
107
+
108
+ // Actually copy each subdir using fs-extra
109
+ for (const subdir of KIT_SUBDIRS) {
110
+ const srcSubdir = join(sourceDir, subdir);
111
+ if (!existsSync(srcSubdir)) continue;
112
+ const destSubdir = join(targetDir, subdir);
113
+
114
+ fsExtra.copySync(srcSubdir, destSubdir, {
115
+ overwrite: true,
116
+ filter: (src) => {
117
+ if (shouldFilter(src)) return false;
118
+ try {
119
+ // Skip symlinks — prevents traversal to paths outside destSubdir
120
+ if (lstatSync(src).isSymbolicLink()) return false;
121
+ } catch {
122
+ return false;
123
+ }
124
+ return true;
125
+ }
126
+ });
127
+ }
128
+
129
+ return fileList;
130
+ }
@@ -0,0 +1,262 @@
1
+ import { createGunzip } from 'node:zlib';
2
+ import { mkdirSync, writeFileSync, rmSync, mkdtempSync } from 'node:fs';
3
+ import { join, dirname, resolve, sep } from 'node:path';
4
+ import { tmpdir } from 'node:os';
5
+ import { Writable, Readable } from 'node:stream';
6
+ import { pipeline } from 'node:stream/promises';
7
+ import { GITHUB_API, KIT_REPO } from './constants.js';
8
+
9
+ // ---------------------------------------------------------------------------
10
+ // Constants
11
+ // ---------------------------------------------------------------------------
12
+
13
+ const KIT_BRANCH = 'main';
14
+ const TARBALL_URL = `${GITHUB_API}/repos/${KIT_REPO}/tarball/${KIT_BRANCH}`;
15
+
16
+ // ---------------------------------------------------------------------------
17
+ // Manual tar stream parser (zero-dependency, handles regular files + dirs)
18
+ // ---------------------------------------------------------------------------
19
+
20
+ /**
21
+ * A Writable stream that parses tar format and writes matching entries to disk.
22
+ * Only processes entries whose paths contain '.claude/' after stripping the root prefix.
23
+ *
24
+ * Tar format: 512-byte header blocks followed by data blocks (padded to 512 bytes).
25
+ * Two consecutive 512-byte zero blocks mark end of archive.
26
+ */
27
+ class TarExtractor extends Writable {
28
+ /**
29
+ * @param {string} destDir - Destination directory (resolved to absolute path)
30
+ */
31
+ constructor(destDir) {
32
+ super();
33
+ this.destDir = resolve(destDir);
34
+ // Chunk list avoids O(n²) Buffer.concat on every write
35
+ this._chunks = [];
36
+ this._totalLen = 0;
37
+ this._state = 'header'; // 'header' | 'data' | 'skip'
38
+ this._remaining = 0; // bytes left in current entry data
39
+ this._paddedSize = 0; // padded size of current entry (multiple of 512)
40
+ this._currentPath = ''; // relative path being written ('' if not .claude/)
41
+ this._rootPrefix = null; // first root directory prefix to strip
42
+ this._zeroBlocks = 0;
43
+ }
44
+
45
+ _write(chunk, encoding, callback) {
46
+ this._chunks.push(chunk);
47
+ this._totalLen += chunk.length;
48
+ try {
49
+ this._process();
50
+ callback();
51
+ } catch (err) {
52
+ callback(err);
53
+ }
54
+ }
55
+
56
+ /** Consolidate pending chunks into one Buffer (lazy — only when access needed). */
57
+ _getBuffer() {
58
+ if (this._chunks.length !== 1) {
59
+ this._chunks = [Buffer.concat(this._chunks)];
60
+ }
61
+ return this._chunks[0];
62
+ }
63
+
64
+ /** Consume n bytes from the front of the chunk list. */
65
+ _consumeBuffer(n) {
66
+ const buf = this._getBuffer();
67
+ const remaining = buf.slice(n);
68
+ this._chunks = remaining.length > 0 ? [remaining] : [];
69
+ this._totalLen -= n;
70
+ }
71
+
72
+ _process() {
73
+ while (this._totalLen >= 512) {
74
+ if (this._state === 'header') {
75
+ this._parseHeader();
76
+ } else if (this._state === 'data') {
77
+ this._readData();
78
+ } else if (this._state === 'skip') {
79
+ this._skipData();
80
+ }
81
+
82
+ // Don't loop if we can't make progress
83
+ if (this._state === 'data' && this._totalLen < 512) break;
84
+ if (this._state === 'skip' && this._totalLen < 512) break;
85
+ }
86
+ }
87
+
88
+ /**
89
+ * Assert that resolvedPath is safely contained within this.destDir.
90
+ * Throws if the path would escape the destination directory.
91
+ * @param {string} resolvedPath
92
+ */
93
+ _assertSafe(resolvedPath) {
94
+ if (resolvedPath !== this.destDir && !resolvedPath.startsWith(this.destDir + sep)) {
95
+ throw new Error(`Path traversal detected: "${resolvedPath}" escapes destination directory`);
96
+ }
97
+ }
98
+
99
+ _parseHeader() {
100
+ const block = this._getBuffer().slice(0, 512);
101
+
102
+ // Check for zero block (end of archive)
103
+ if (block[0] === 0 && block.every(b => b === 0)) {
104
+ this._zeroBlocks++;
105
+ this._consumeBuffer(512);
106
+ return;
107
+ }
108
+ this._zeroBlocks = 0;
109
+
110
+ // Parse header fields
111
+ const rawName = block.slice(0, 100).toString('utf8').replace(/\0+$/, '');
112
+ const prefix = block.slice(345, 500).toString('utf8').replace(/\0+$/, '');
113
+ const fullName = prefix ? `${prefix}/${rawName}` : rawName;
114
+
115
+ const sizeOctal = block.slice(124, 136).toString('utf8').replace(/\0/g, '').trim();
116
+ const size = sizeOctal ? parseInt(sizeOctal, 8) : 0;
117
+
118
+ const typeFlag = String.fromCharCode(block[156]) || '0';
119
+
120
+ // Detect and strip root prefix (first directory component)
121
+ if (this._rootPrefix === null) {
122
+ const firstSlash = fullName.indexOf('/');
123
+ this._rootPrefix = firstSlash >= 0 ? fullName.slice(0, firstSlash + 1) : '';
124
+ }
125
+
126
+ const strippedName = fullName.startsWith(this._rootPrefix)
127
+ ? fullName.slice(this._rootPrefix.length)
128
+ : fullName;
129
+
130
+ this._consumeBuffer(512);
131
+
132
+ // Block symlinks and hard links entirely — prevents symlink-based escapes
133
+ if (typeFlag === '1' || typeFlag === '2') {
134
+ this._state = 'header';
135
+ return;
136
+ }
137
+
138
+ // Only process entries under .claude/
139
+ const isClaudePath = strippedName.startsWith('.claude/');
140
+
141
+ if (typeFlag === '5' || typeFlag === '\0' || typeFlag === '') {
142
+ // Directory entry
143
+ if (isClaudePath && strippedName) {
144
+ const dirPath = resolve(join(this.destDir, strippedName));
145
+ this._assertSafe(dirPath);
146
+ mkdirSync(dirPath, { recursive: true });
147
+ }
148
+ this._state = 'header';
149
+ return;
150
+ }
151
+
152
+ // Regular file entry (typeFlag '0' or empty/null)
153
+ if (size === 0) {
154
+ if (isClaudePath) {
155
+ const filePath = resolve(join(this.destDir, strippedName));
156
+ this._assertSafe(filePath);
157
+ mkdirSync(dirname(filePath), { recursive: true });
158
+ writeFileSync(filePath, Buffer.alloc(0));
159
+ }
160
+ this._state = 'header';
161
+ return;
162
+ }
163
+
164
+ this._remaining = size;
165
+ this._paddedSize = Math.ceil(size / 512) * 512;
166
+
167
+ if (isClaudePath) {
168
+ this._currentPath = strippedName;
169
+ this._state = 'data';
170
+ } else {
171
+ this._state = 'skip';
172
+ }
173
+ }
174
+
175
+ _readData() {
176
+ if (this._totalLen < this._paddedSize && this._totalLen < 512) {
177
+ return; // Wait for more data
178
+ }
179
+
180
+ if (this._totalLen >= this._paddedSize) {
181
+ // We have all the data for this entry
182
+ const buf = this._getBuffer();
183
+ const rawData = buf.slice(0, this._remaining);
184
+ this._consumeBuffer(this._paddedSize);
185
+
186
+ const filePath = resolve(join(this.destDir, this._currentPath));
187
+ this._assertSafe(filePath);
188
+ mkdirSync(dirname(filePath), { recursive: true });
189
+ writeFileSync(filePath, rawData);
190
+
191
+ this._currentPath = '';
192
+ this._state = 'header';
193
+ }
194
+ // else: not enough data yet — wait
195
+ }
196
+
197
+ _skipData() {
198
+ if (this._totalLen < this._paddedSize) {
199
+ return; // Wait for more data
200
+ }
201
+ this._consumeBuffer(this._paddedSize);
202
+ this._state = 'header';
203
+ }
204
+
205
+ _final(callback) {
206
+ callback();
207
+ }
208
+ }
209
+
210
+ // ---------------------------------------------------------------------------
211
+ // Public API
212
+ // ---------------------------------------------------------------------------
213
+
214
+ /**
215
+ * Download the kit repository as a tarball and extract .claude/ to targetDir.
216
+ *
217
+ * @param {string} token - GitHub Bearer token
218
+ * @param {{ targetDir?: string }} [opts]
219
+ * @returns {Promise<string>} The targetDir path
220
+ */
221
+ export async function downloadAndExtractKit(token, opts = {}) {
222
+ const { targetDir = mkdtempSync(join(tmpdir(), 'mk-kit-')) } = opts;
223
+
224
+ let res;
225
+ try {
226
+ res = await fetch(TARBALL_URL, {
227
+ headers: {
228
+ Authorization: `Bearer ${token}`,
229
+ Accept: 'application/vnd.github.v3+json'
230
+ },
231
+ redirect: 'follow'
232
+ });
233
+ } catch (err) {
234
+ throw new Error(`Network connection failed: ${err.message}`);
235
+ }
236
+
237
+ if (!res.ok) {
238
+ throw new Error(`GitHub API error: ${res.status} ${res.statusText}`);
239
+ }
240
+
241
+ mkdirSync(targetDir, { recursive: true });
242
+
243
+ const gunzip = createGunzip();
244
+ const extractor = new TarExtractor(targetDir);
245
+
246
+ await pipeline(
247
+ Readable.fromWeb(res.body),
248
+ gunzip,
249
+ extractor
250
+ );
251
+
252
+ return targetDir;
253
+ }
254
+
255
+ /**
256
+ * Remove a temp directory created by downloadAndExtractKit.
257
+ *
258
+ * @param {string} tempDir
259
+ */
260
+ export function cleanupTempDir(tempDir) {
261
+ rmSync(tempDir, { recursive: true, force: true });
262
+ }
@@ -0,0 +1,115 @@
1
+ import { readFileSync, writeFileSync } from 'node:fs';
2
+
3
+ /**
4
+ * Write a manifest file to disk.
5
+ * @param {string} manifestPath - Absolute path to write
6
+ * @param {Object} files - Map of relativePath -> { checksum, size }
7
+ * @param {string} version - Kit version
8
+ * @param {'project'|'global'} scope
9
+ */
10
+ export function writeManifest(manifestPath, files, version, scope) {
11
+ const now = new Date().toISOString();
12
+ const manifest = {
13
+ version,
14
+ installedAt: now,
15
+ updatedAt: now,
16
+ scope,
17
+ files
18
+ };
19
+ writeFileSync(manifestPath, JSON.stringify(manifest, null, 2), 'utf8');
20
+ }
21
+
22
+ /**
23
+ * Update an existing manifest's files, version, and updatedAt.
24
+ * @param {string} manifestPath
25
+ * @param {Object} files - New files map
26
+ * @param {string} version - New kit version
27
+ */
28
+ export function updateManifest(manifestPath, files, version) {
29
+ const existing = readManifest(manifestPath);
30
+ const updated = {
31
+ ...existing,
32
+ version,
33
+ updatedAt: new Date().toISOString(),
34
+ files
35
+ };
36
+ writeFileSync(manifestPath, JSON.stringify(updated, null, 2), 'utf8');
37
+ }
38
+
39
+ /**
40
+ * Read and parse a manifest file.
41
+ * @param {string} manifestPath
42
+ * @returns {Object} Parsed manifest
43
+ * @throws {Error} If file not found or invalid JSON
44
+ */
45
+ export function readManifest(manifestPath) {
46
+ let raw;
47
+ try {
48
+ raw = readFileSync(manifestPath, 'utf8');
49
+ } catch (err) {
50
+ if (err.code === 'ENOENT') {
51
+ throw new Error(`Manifest not found: ${manifestPath}`);
52
+ }
53
+ throw err;
54
+ }
55
+ try {
56
+ return JSON.parse(raw);
57
+ } catch (err) {
58
+ throw new Error(`Invalid JSON in manifest: ${manifestPath} (${err.message})`);
59
+ }
60
+ }
61
+
62
+ /**
63
+ * Perform a three-way diff between manifest, source files, and current disk state.
64
+ *
65
+ * @param {Object} manifest - Parsed manifest object
66
+ * @param {Object} sourceFiles - Map of relativePath -> { checksum, size } from source
67
+ * @param {Object} diskChecksums - Map of relativePath -> checksum of current disk state
68
+ * @returns {{ unchanged: string[], updated: string[], conflicts: string[], added: string[], removed: string[] }}
69
+ */
70
+ export function diffManifest(manifest, sourceFiles, diskChecksums) {
71
+ const result = {
72
+ unchanged: [],
73
+ updated: [],
74
+ conflicts: [],
75
+ added: [],
76
+ removed: []
77
+ };
78
+
79
+ const manifestFiles = manifest.files || {};
80
+
81
+ // Check all source files against manifest
82
+ for (const [relPath, sourceEntry] of Object.entries(sourceFiles)) {
83
+ const sourceChecksum = sourceEntry.checksum;
84
+
85
+ if (!(relPath in manifestFiles)) {
86
+ // New file in source not in manifest -> ADD
87
+ result.added.push(relPath);
88
+ } else {
89
+ const manifestChecksum = manifestFiles[relPath].checksum;
90
+ if (sourceChecksum === manifestChecksum) {
91
+ // Source matches manifest -> UNCHANGED
92
+ result.unchanged.push(relPath);
93
+ } else {
94
+ // Source differs from manifest -> kit updated this file
95
+ const diskChecksum = diskChecksums[relPath];
96
+ if (!diskChecksum || diskChecksum === manifestChecksum) {
97
+ // Disk is same as manifest (user didn't modify) -> SAFE UPDATE
98
+ result.updated.push(relPath);
99
+ } else {
100
+ // Disk differs from manifest (user modified) AND kit changed -> CONFLICT
101
+ result.conflicts.push(relPath);
102
+ }
103
+ }
104
+ }
105
+ }
106
+
107
+ // Check manifest files not in source -> REMOVED from kit
108
+ for (const relPath of Object.keys(manifestFiles)) {
109
+ if (!(relPath in sourceFiles)) {
110
+ result.removed.push(relPath);
111
+ }
112
+ }
113
+
114
+ return result;
115
+ }
@@ -0,0 +1,70 @@
1
+ import { join, dirname, resolve, sep } from 'node:path';
2
+ import { homedir } from 'node:os';
3
+ import { fileURLToPath } from 'node:url';
4
+
5
+ const __dirname = dirname(fileURLToPath(import.meta.url));
6
+
7
+ /**
8
+ * Resolve the source .claude/ directory from the npm package root.
9
+ * When installed via npm, the package root is two levels up from src/lib/.
10
+ * @returns {string} Absolute path to the package's .claude/ directory
11
+ */
12
+ export function resolveSourceDir() {
13
+ // src/lib/paths.js -> src/lib -> src -> packageRoot
14
+ const packageRoot = join(__dirname, '..', '..');
15
+ return join(packageRoot, '.claude');
16
+ }
17
+
18
+ /**
19
+ * Resolve the target .claude/ directory.
20
+ * @param {{ global: boolean }} options
21
+ * @returns {string} Absolute path to target .claude/ directory
22
+ */
23
+ export function resolveTargetDir(options) {
24
+ if (options.global) {
25
+ return join(homedir(), '.claude');
26
+ }
27
+ return join(process.cwd(), '.claude');
28
+ }
29
+
30
+ /**
31
+ * Resolve the manifest file path.
32
+ * @param {{ global: boolean }} options
33
+ * @returns {string} Absolute path to .mk-manifest.json
34
+ */
35
+ export function resolveManifestPath(options) {
36
+ if (options.global) {
37
+ return join(homedir(), '.claude', '.mk-manifest.json');
38
+ }
39
+ return join(process.cwd(), '.mk-manifest.json');
40
+ }
41
+
42
+ /**
43
+ * Derive project root from manifest scope and path.
44
+ * For project installs the manifest is at cwd/.mk-manifest.json → root = cwd.
45
+ * For global installs the manifest is at ~/.claude/.mk-manifest.json → root = homedir,
46
+ * because all relPaths start with '.claude/' and the actual files live at ~/.<file>.
47
+ * @param {Object} manifest - Parsed manifest object
48
+ * @param {string} manifestPath - Absolute path to .mk-manifest.json
49
+ * @returns {string} Absolute project root path
50
+ */
51
+ export function deriveProjectRoot(manifest, manifestPath) {
52
+ return manifest.scope === 'global' ? homedir() : dirname(manifestPath);
53
+ }
54
+
55
+ /**
56
+ * Assert that absPath is safely inside rootDir (prevent path-traversal from manifest keys).
57
+ * Throws if the resolved path escapes the allowed directory.
58
+ * @param {string} absPath - Path to validate
59
+ * @param {string} rootDir - Allowed root directory
60
+ * @param {string} [label] - Label for error messages
61
+ */
62
+ export function assertSafePath(absPath, rootDir, label = 'path') {
63
+ const resolved = resolve(absPath);
64
+ const root = resolve(rootDir);
65
+ if (resolved !== root && !resolved.startsWith(root + sep)) {
66
+ throw new Error(
67
+ `Security: ${label} "${absPath}" resolves outside allowed directory "${rootDir}"`
68
+ );
69
+ }
70
+ }